使用 React Context 结合 EventEmitter

本文最后更新于:2021年1月20日 凌晨

场景

EventEmitter 很适合在不修改组件状态结构的情况下进行组件通信,然而它的生命周期不受 react 管理,需要手动添加/清理监听事件很麻烦。而且,如果一个 EventEmitter 没有使用就被初始化也会有点麻烦。

目的

所以使用 react context 结合 event emitter 的目的便是

  • 添加高阶组件,通过 react context 为所有子组件注入 em 对象
  • 添加自定义 hooks,从 react context 获取 emitter 对象,并暴露出合适的函数。
  • 自动清理 emitter 对象和 emitter listener。

实现

实现基本的 EventEmitter

首先,实现一个基本的 EventEmitter,这里之前吾辈曾经就有 实现过,所以直接拿过来了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
type EventType = string | number;

export type BaseEvents = Record<EventType, any[]>;

/**
* 事件总线
* 实际上就是发布订阅模式的一种简单实现
* 类型定义受到 {@link https://github.com/andywer/typed-emitter/blob/master/index.d.ts} 的启发,不过只需要声明参数就好了,而不需要返回值(应该是 {@code void})
*/
export class EventEmitter<Events extends BaseEvents> {
private readonly events = new Map<keyof Events, Function[]>();

/**
* 添加一个事件监听程序
* @param type 监听类型
* @param callback 处理回调
* @returns {@code this}
*/
add<E extends keyof Events>(type: E, callback: (...args: Events[E]) => void) {
const callbacks = this.events.get(type) || [];
callbacks.push(callback);
this.events.set(type, callbacks);
return this;
}
/**
* 移除一个事件监听程序
* @param type 监听类型
* @param callback 处理回调
* @returns {@code this}
*/
remove<E extends keyof Events>(
type: E,
callback: (...args: Events[E]) => void
) {
const callbacks = this.events.get(type) || [];
this.events.set(
type,
callbacks.filter((fn: any) => fn !== callback)
);
return this;
}
/**
* 移除一类事件监听程序
* @param type 监听类型
* @returns {@code this}
*/
removeByType<E extends keyof Events>(type: E) {
this.events.delete(type);
return this;
}
/**
* 触发一类事件监听程序
* @param type 监听类型
* @param args 处理回调需要的参数
* @returns {@code this}
*/
emit<E extends keyof Events>(type: E, ...args: Events[E]) {
const callbacks = this.events.get(type) || [];
callbacks.forEach((fn) => {
fn(...args);
});
return this;
}

/**
* 获取一类事件监听程序
* @param type 监听类型
* @returns 一个只读的数组,如果找不到,则返回空数组 {@code []}
*/
listeners<E extends keyof Events>(type: E) {
return Object.freeze(this.events.get(type) || []);
}
}

结合 context 实现一个包裹组件

包裹组件的目的是为了能直接提供一个包裹组件,以及提供 provider 的默认值,不需要使用者直接接触 emitter 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import * as React from "react";
import { createContext, PropsWithChildren } from "react";
import { BaseEvents, EventEmitter } from "./util/EventEmitter";

export const EventEmitterContext = createContext<EventEmitter<any>>(
null as any
);

export function EventEmitterRC<T extends BaseEvents>(
props: PropsWithChildren<{ value: EventEmitter<T> }>
) {
return (
<EventEmitterContext.Provider value={props.value}>
{props.children}
</EventEmitterContext.Provider>
);
}

使用 hooks 暴露 emitter api

我们主要需要暴露的 API 只有三个

  • useListener: 添加监听器,使用 hooks 是为了能在组件卸载时自动清理监听函数
  • emit: 触发监听器,直接调用即可
  • emitter: 在当前组件树生效的 emitter 对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import {
DependencyList,
useCallback,
useContext,
useEffect,
useMemo,
} from "react";
import { EventEmitterContext } from "../EventEmitterRC";
// noinspection ES6PreferShortImport
import { BaseEvents, EventEmitter } from "../util/EventEmitter";

function useEmit<Events extends BaseEvents>() {
const em = useContext(EventEmitterContext);
return useCallback(
<E extends keyof Events>(type: E, ...args: Events[E]) => {
console.log("emitter emit: ", type, args);
em.emit(type, ...args);
},
[em]
);
}

export function useEventEmitter<Events extends BaseEvents>() {
const emit = useEmit<Events>();
// 这里使用 useMemo 产生的 emitter 对象的原因是在当前组件树 emitter 仅初始化一次
const emitter = useMemo(() => new EventEmitter<Events>(), []);
return {
useListener: <E extends keyof Events>(
type: E,
listener: (...args: Events[E]) => void,
deps: DependencyList = []
) => {
const em = useContext(EventEmitterContext);
useEffect(() => {
console.log("emitter add: ", type, listener.name);
em.add(type, listener);
return () => {
console.log("emitter remove: ", type, listener.name);
em.remove(type, listener);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [listener, type, ...deps]);
},
emit,
emitter,
};
}

使用

使用起来非常简单,在需要使用的 emitter hooks 的组件外部包裹一个 EventEmitterRC 组件,然后就可以使用 useEventEmitter 了。

下面是一个简单的 Todo 示例,使用 emitter 实现了 todo 表单 与 todo 列表之间的通信。

目录结构如下

  • todo
    • component
      • TodoForm.tsx
      • TodoList.tsx
    • modal
      • TodoEntity.ts
      • TodoEvents.ts
    • Todo.tsx

Todo 父组件,使用 EventEmitterRC 包裹子组件

1
2
3
4
5
6
7
8
9
const Todo: React.FC<PropsType> = () => {
const { emitter } = useEventEmitter();
return (
<EventEmitterRC value={emitter}>
<TodoForm />
<TodoList />
</EventEmitterRC>
);
};

在表单组件中使用 useEventEmitter hooks 获得 emit 方法,然后在添加 todo 时触发它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const TodoForm: React.FC<PropsType> = () => {
const { emit } = useEventEmitter<TodoEvents>();

const [title, setTitle] = useState("");

function handleAddTodo(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
emit("addTodo", {
title,
});
setTitle("");
}

return (
<form onSubmit={handleAddTodo}>
<div>
<label htmlFor={"title"}>标题:</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
id={"title"}
/>
<button type={"submit"}>添加</button>
</div>
</form>
);
};

在列表组件中使用 useEventEmitter hooks 获得 useListener hooks,然后监听添加 todo 的事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const TodoList: React.FC<PropsType> = () => {
const [list, setList] = useState<TodoEntity[]>([]);
const { useListener } = useEventEmitter<TodoEvents>();
useListener(
"addTodo",
(todo) => {
setList([...list, todo]);
},
[list]
);
const em = { useListener };
useEffect(() => {
console.log("em: ", em);
}, [em]);
return (
<ul>
{list.map((todo, i) => (
<li key={i}>{todo.title}</li>
))}
</ul>
);
};

下面是一些 TypeScript 类型

1
2
3
export interface TodoEntity {
title: string;
}
1
2
3
4
5
6
import { BaseEvents } from "../../../components/emitter";
import { TodoEntity } from "./TodoEntity";

export interface TodoEvents extends BaseEvents {
addTodo: [entity: TodoEntity];
}

参考


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!