跨组件通信和数据共享不是一件容易的事,如果通过 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-ignoreinitialListener?: (...args: M[typeof initialEventName][]) => void,config?: Partial<EventEmitterConfig>
): EventEmitter;// @ts-ignore
function useEmitter<M = {}>(nameOrConfig?: string | Partial<EventEmitterConfig>,initialEventNameOrConfig?: string | Partial<EventEmitterConfig>,// @ts-ignoreinitialListener?: (...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-ignoreconfigActual[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-ignoreK extends string = keyof T,// @ts-ignoreV = K extends string ? T[K] | undefined : any// @ts-ignore
>(name: K): [V, Dispatch<SetStateAction<V>>] {// @ts-ignoreconst [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 (<><ButtononClick={() =>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 并没有开发命名空间,你们可以拓展参数来提供更细粒度的数据隔离)