跨组件通信和数据共享不是一件容易的事,如果通过 prop 一层层传递,太繁琐,而且仅适用于从上到下的数据传递;建立一个全局的状态 Store,每个数据可能两三个组件间需要使用,其他地方用不着,挂那么大个状态树也浪费了。当然了,有一些支持局部 store 的状态管理库,比如 zustand,我们可以直接使用它来跨组件共享数据。不过本文将基于事件机制的原理带来一个新的协同方案。
目标
vue3 中有 provide 和 inject 这两个 api,可以将一个组件内的状态透传到另外的组件中。那我们最终要实现的 hook 就叫 useProvide 和 useInject 吧。要通过事件机制来实现这两个 hook,那少不了具备事件机制的 hook,所以我们要先来实现一个事件发射器(useEmitter)和一个事件接收器(useReceiver)
事件 Hook 思路
- 需要一个事件总线
- 需要一对多的事件和侦听器映射关系
- 需要具备订阅和取消功能
- 支持命名空间来提供一定的隔离性
useEmitter
很简单,我们创建一个全局的 Map 对象来充当事件总线,在里面根据事件名和侦听器名存储映射关系即可。
代码不做太多解释,逻辑很简单,根据既定的命名规则来编排事件,注意重名的处理即可。
(Ukey 是一个生成唯一id的工具函数,你可以自己写一个,或者用nanoid等更专业的库替代)
import { useEffect, useContext, createContext } from "react";
import Ukey from "./utils/Ukey";
interface EventListener {
namespace?: string;
eventName: string;
listenerName: string;
listener: (...args: any[]) => void;
}
// 创建一个全局的事件监听器列表
const globalListeners = new Map<string, EventListener>();
// 创建一个 Context 来共享 globalListeners
const GlobalListenersContext = createContext(globalListeners);
export const useGlobalListeners = () => useContext(GlobalListenersContext);
interface EventEmitterConfig {
name?: string;
initialEventName?: string;
initialListener?: (...args: any[]) => void;
namespace?: string;
}
interface EventEmitter {
name: string;
emit: (eventName: string, ...args: any[]) => void;
subscribe: (eventName: string, listener: (...args: any[]) => void) => void;
unsubscribe: (eventName: string) => void;
unsubscribeAll: () => void;
}
function useEmitter(
name: string,
config?: Partial<EventEmitterConfig>
): EventEmitter;
function useEmitter(config: Partial<EventEmitterConfig>): EventEmitter;
function useEmitter<M = {}>(
name?: string,
initialEventName?: string,
// @ts-ignore
initialListener?: (...args: M[typeof initialEventName][]) => void,
config?: Partial<EventEmitterConfig>
): EventEmitter;
// @ts-ignore
function useEmitter<M = {}>(
nameOrConfig?: string | Partial<EventEmitterConfig>,
initialEventNameOrConfig?: string | Partial<EventEmitterConfig>,
// @ts-ignore
initialListener?: (...args: M[typeof initialEventNameOrConfig][]) => void,
config?: Partial<EventEmitterConfig>
) {
const globalListeners = useContext(GlobalListenersContext);
// 根据参数类型确定实际的参数值
let configActual: Partial<EventEmitterConfig> = {};
if (typeof nameOrConfig === "string") {
configActual.name = nameOrConfig;
if (typeof initialEventNameOrConfig === "string") {
configActual.initialEventName = initialEventNameOrConfig;
configActual.initialListener = initialListener;
} else if (typeof initialEventNameOrConfig === "object") {
Object.entries(initialEventNameOrConfig).map(([key, value]) => {
if (value !== void 0) {
// @ts-ignore
configActual[key] = value;
}
});
}
} else {
configActual = nameOrConfig || {};
}
if (!configActual.name) {
configActual.name = `_emitter_${Ukey()}`;
}
if (!configActual.namespace) {
configActual.namespace = "default";
}
// 如果没有传入 name,使用 Ukey 方法生成一个唯一的名称
const listenerName = configActual.name;
const emit = (eventName: string, ...args: any[]) => {
globalListeners.forEach((value, key) => {
if (key.startsWith(`${configActual.namespace}_${eventName}_`)) {
value.listener(...args);
}
});
};
const subscribe = (eventName: string, listener: (...args: any[]) => void) => {
const key = `${configActual.namespace}_${eventName}_${listenerName}`;
if (globalListeners.has(key)) {
throw new Error(
`useEmitter: Listener ${listenerName} has already registered for event ${eventName}`
);
}
globalListeners.set(key, { eventName, listenerName, listener });
};
const unsubscribe = (eventName: string) => {
const key = `${configActual.namespace}_${eventName}_${listenerName}`;
globalListeners.delete(key);
};
const unsubscribeAll = () => {
const keysToDelete: string[] = [];
globalListeners.forEach((value, key) => {
if (key.endsWith(`_${listenerName}`)) {
keysToDelete.push(key);
}
});
keysToDelete.forEach((key) => {
globalListeners.delete(key);
});
};
useEffect(() => {
if (configActual.initialEventName && configActual.initialListener) {
subscribe(configActual.initialEventName, configActual.initialListener);
}
return () => {
globalListeners.forEach((value, key) => {
if (key.endsWith(`_${listenerName}`)) {
globalListeners.delete(key);
}
});
};
}, [configActual.initialEventName, configActual.initialListener]);
return { name: listenerName, emit, subscribe, unsubscribe, unsubscribeAll };
}
export default useEmitter;
export { GlobalListenersContext };
useReceiver
我们在 useEmitter 的基础上封装一个 hook 来实时存储事件的值
import { useState, useEffect, useCallback } from "react";
import useEmitter from "./useEmitter";
import Ukey from "./utils/Ukey";
import { Prettify } from "./typings";
type EventReceiver = {
stop: () => void;
start: () => void;
reset: (args: any[]) => void;
isListening: boolean;
// emit: (event: string, ...args: any[]) => void;
};
type EventReceiverOptions = {
name?: string;
namespace?: "default" | (string & {});
eventName: string;
callback?: EventCallback;
};
type EventCallback = (...args: any[]) => void;
function useReceiver(
eventName: string,
callback?: EventCallback
): [any[] | null, EventReceiver];
function useReceiver(
options: Prettify<EventReceiverOptions>
): [any[] | null, EventReceiver];
function useReceiver(
eventNameOrOptions: string | Prettify<EventReceiverOptions>,
callback?: EventCallback
): [any[] | null, EventReceiver] {
let eventName: string;
let name: string;
let namespace: string;
let cb: EventCallback | undefined;
if (typeof eventNameOrOptions === "string") {
eventName = eventNameOrOptions;
name = `_receiver_${Ukey()}`;
namespace = "default";
cb = callback;
} else {
eventName = eventNameOrOptions.eventName;
name = eventNameOrOptions.name || `_receiver_${Ukey()}`;
namespace = eventNameOrOptions.namespace || "default";
cb = eventNameOrOptions.callback;
if (cb) {
if (callback) {
console.warn(
"useReceiver: Callback is ignored when options.callback is set"
);
} else {
cb = callback;
}
}
}
const { subscribe, unsubscribe, emit } = useEmitter({
name: name,
namespace: namespace,
});
const [isListening, setIsListening] = useState(true);
const [eventResult, setEventResult] = useState<any[] | null>(null);
const eventListener = useCallback((...args: any[]) => {
setEventResult(args);
cb?.(...args);
}, []);
useEffect(() => {
subscribe(eventName, eventListener);
return () => {
unsubscribe(eventName);
};
}, [eventName, eventListener]);
const stopListening = useCallback(() => {
unsubscribe(eventName);
setIsListening(false);
}, [eventName]);
const startListening = useCallback(() => {
subscribe(eventName, eventListener);
setIsListening(true);
}, [eventName, eventListener]);
const reveiver = {
stop: stopListening,
start: startListening,
reset: setEventResult,
isListening,
get emit() {
return emit;
},
} as EventReceiver;
return [eventResult, reveiver];
}
export default useReceiver;
这里我们开放了 emit,但在类型声明上隐藏它,因为使用者不需要它,留着 emit 是因为我们在接来下实现 useInject 还需要它。
共享 Hook 思路
有了 useEmitter 和 useReceiver 这两大基石后,一切都豁然开朗。我们只需要在 useEmitter 的基础上封装 useProvide,传入唯一键名,state 值和 setState,将其和事件绑定即可,注意这里额外订阅了一个 query 事件,来允许其监听者主动请求提供者广播一次数据(用处后面提)。
useProvide
import { Dispatch, SetStateAction, useEffect } from "react";
import useEmitter from "./useEmitter";
export function useProvide<T = any>(
name: string,
state: T,
setState?: Dispatch<SetStateAction<T>>
) {
const emitter = useEmitter(`__Provider::${name}`, {
namespace: "__provide_inject__",
initialEventName: `__Inject::${name}::query`,
initialListener() {
emitter.emit(`__Provider::${name}`, state, setState);
},
});
useEffect(() => {
emitter.emit(`__Provider::${name}`, state, setState);
}, [name, state, setState]);
}
export default useProvide;
useInject
useInject 只需要封装 useReceiver 并返回 state即可,注意在 useInject 挂载之初,我们需要主动向提供者请求一次同步,因为提供者通常情况下比注入者挂载的更早,提供者初始主动同步的那一次,绝大多数注入者并不能接收到。
import { Dispatch, SetStateAction, useEffect } from "react";
import useReceiver from "./useReceiver";
import UKey from "./utils/Ukey";
/**
* useInject is a hook that can be used to inject a value from a provider.
*
* ---
* ### Parameters
* - `name` - The name of the provider to inject from.
*
* ---
* ### Returns
* - [0]`value` - The value of the provider.
* - [1]`setValue` - A function to set the value of the provider.
*/
function useInject<
T extends Object = { [x: string]: any },
// @ts-ignore
K extends string = keyof T,
// @ts-ignore
V = K extends string ? T[K] | undefined : any
// @ts-ignore
>(name: K): [V, Dispatch<SetStateAction<V>>] {
// @ts-ignore
const [result, { emit }] = useReceiver({
name: `__Inject::${name}_${UKey()}`,
eventName: `__Provider::${name}`,
namespace: "__provide_inject__",
});
const query = () => emit(`__Inject::${name}::query`, true);
useEffect(() => {
query();
}, []);
return [result?.[0], result?.[1]];
}
export default useInject;
然后你就可以像这样快乐的共享数据了:
import useInject from "@/hooks/useInject";
import useProvide from "@/hooks/useProvide";
import { Button } from "@mui/material";
import { useState } from "react";
type Person = {
name: string;
age: number;
};
const UseProvideExample = () => {
const [state, setState] = useState<Person>({
name: "Evan",
age: 20,
});
useProvide("someone", state);
return (
<>
<Button
onClick={() =>
setState({ ...state, name: state.name === "Evan" ? "Nave" : "Evan" })
}
>
{state.name}
</Button>
<Button onClick={() => setState({ ...state, age: state.age + 1 })}>
{state.age}
</Button>
</>
);
};
const UseInjectExample = () => {
const [state] = useInject<{ someone: Person }>("someone");
const [state2] = useInject<{ someone: Person }>("someone");
return (
<>
<div style={{ display: "flex" }}>
<span>{state?.name}</span>
<div style={{ width: "2rem" }}></div>
<span>{state?.age}</span>
</div>
<div style={{ display: "flex" }}>
<span>{state2?.name}</span>
<div style={{ width: "2rem" }}></div>
<span>{state2?.age}</span>
</div>
</>
);
};
const View = () => {
return (
<>
<h4>UseProvide</h4>
<UseProvideExample />
<h4>Inject</h4>
<UseInjectExample />
</>
);
};
Demo 效果图:
Bingo! 用于跨组件协同的 useProvide 和 useInject 就这样实现了!
(PS : 我这里的 useProvide 和 useInject 并没有开发命名空间,你们可以拓展参数来提供更细粒度的数据隔离)