无论什么框架,都会纠结用什么方式进行跨组件通信;尤其是现在比较热门的微前端,可能layout是用react的nextjs渲染的,content则用vue编写;其中涉及到用户信息、操作分发等成为了一个问题,这里将先分析在单框架下的常见解决方案,然后给出一种优雅的解决方案。
状态管理工具
比如react常用的redux、mobx、zustand等全局状态管理工具,angular可能就是rxjs,vue可能就是vuex啥的。其特点是可以和UI框架结合的很好,但是至少存在以下几种问题:
额外的学习或时间成本
这个看上去有点牵强,都用了UI框架还不用状态管理工具?其实不然,作为react开发人员应该都挺讨厌redux这个库的;模版代码太多,我就想搞一个全局状态怎么要写这么多代码?mobx好像没这个问题,但是响应式又是啥,这和react的单向数据流是不是冲突了?那用recoil吧,天,这么多api,用了atom还得用selector,异步请求还不能用selector...
什么样的数据才放进去
选择了某个状态管理工具后也不是万事大吉,接下来又开始思考什么样的数据才存到全局store里面去呢?用户数据?这个可以存;搜索词?这个好像不用存,那如果加上过滤项呢?好像又有必要存了...更加特别还有一种场景:过滤项本身就是一个页面,选择完成之后返回,这部分过滤数据应该存在哪呢?
能否和其他框架结合
正如文章开头所说,假如是一个多框架页面,我在vue子项目中如何拿到从react项目中获得的登录数据呢?当然有些库可以做到,比如redux。
模拟cache
这个算是比较常见的做法,比如我要保存编辑的数据但是不想让用户通过sessionStorage啥的看到,那就用在框架外面用一个变量存储起来,像这样:
js
// xxx.jsx
export const draftCache='';
const Edit=()=>{
const [value,setValue]=useState(draftCache);
const onChange=(newValue)=>{
setValue(newValue);
draftCache=newValue;
}
return <Input defaultValue={draftCache} value={value} onChange={onChange} />
}
然后发现这种方法还挺好用的,编辑草稿写一个cache、上次阅读进度写一个cache、首页搜索栏写一个cache...然后满项目调用。
那当然用Storage啊
上面那种方法可能很多人都不理解为什么不用如localStorage、sessionStorage等去保存。确实如果是一些相对局部需要共享的数据,用sessionStorage或者localStorage去保存比较好,但还是有一些问题:
安全问题
即使是局部共享的数据也不是都能存进Storage,一旦将数据存进Storage就代表这部分数据公开了,当然你可以说在存储的时候进行加密,这又牵涉到效率问题了;更别提可能有有心人禁用Storage,那就又需要额外处理代码的健壮性了。
效率问题
Storage最大的问题是只能存储string,当然你可能没注意:如果你保存的不是string类型的数据时会自动帮你转换一下类型。如果你还想保证数据安全给数据加密,一来二去仅仅保存和读取一个数据就得花不少时间。
IndexDB或webSQL
最大的就是兼容问题了,除此之外仅仅存个局部数据有些浪费了。
参考原生
上面的方案有些是和框架绑定的,有些是借助web api。其实原生跨组件通信方法每个人都知道,就是这种:
js
window.addEventListener('click',()=>{
//code
})
尤其DOM还能自定义事件,参考MDN。
当然为了尽可能的减少对api的依赖,实现跨平台我们可以手动实现一个typescript版本的事件分发。
这里我们用vite快速创建一个react项目,删除多余的文件后,编写类型:
ts
//src/utils/event/types.ts
/** 事件回调模版 */
export type EventListener<T> = (data: T) => void;
/** 所有事件回调 */
export type ListenerMap<E extends PropertyKey> = {
[K in E]: EventListener<unknown>;
};
/** 事件对应回调 */
export type EventCallback<
E extends PropertyKey,
M extends ListenerMap<E>
> = NonNullable<M[E]>;
/** 事件回调reducer */
export type EventReducers<E extends PropertyKey, M extends ListenerMap<E>> = {
[K in E]: EventCallback<K, M>[];
};
/** 事件回调参数 */
export type EventParams<
E extends string,
M extends ListenerMap<E>
> = Parameters<EventCallback<E, M>>[number];
然后简单写一个订阅发布模式
ts
//src/utils/event/index.ts
import {
ListenerMap,
EventCallback,
EventParams,
EventReducers,
} from "./types";
class PubSub<T extends string, M extends ListenerMap<T>> {
private reducers: EventReducers<T, M> = {};
/** reducers守卫,保证某事件存在回调数组 */
private checkReducers<E extends T>(event: E): void {
if (!Reflect.has(this.reducers, event)) {
this.setEventReducer(event, []);
}
}
/** 获取某事件的回调数组 */
private getEventReducer<E extends T>(event: E) {
this.checkReducers(event);
return Reflect.get(this.reducers, event) as EventReducers<T, M>[E];
}
/** 设置某事件的回调数组 */
private setEventReducer<E extends T>(event: E, list: EventReducers<T, M>[E]) {
Reflect.set(this.reducers, event, list);
}
/** 订阅事件 */
subscribe<E extends T>(
event: E,
listener: EventCallback<E, M>
): { unsubscribe: () => void } {
const callbacks = this.getEventReducer(event);
callbacks.push(listener);
this.setEventReducer(event, callbacks);
return {
unsubscribe: () => this.unsubscribe(event, listener),
};
}
/** 取消订阅 */
unsubscribe<E extends T>(event: E, listener: EventCallback<E, M>): void {
const callbacks = this.getEventReducer(event);
this.setEventReducer(
event,
callbacks.filter((callback) => callback !== listener)
);
}
/** 发布事件 */
publish<E extends T>(event: E, data: EventParams<E, M>): void {
const callbacks = this.getEventReducer(event);
callbacks.map((callback) => callback(data));
}
}
同时为了测试我们编写两个自定义事件:
ts
//src/utils/event/index.ts
export enum Events {
"EDIT" = "edit",
"LOGIN" = "login",
}
export interface EventMaps {
[Events.EDIT]: (value: string) => void;
[Events.LOGIN]: (user: Record<"username" | "uuid", string>) => void;
}
const events = new PubSub<Events, EventMaps>();
export default events;
然后在input.tsx文件内,写一个react组件订阅edit事件:
tsx
//react
function App() {
const [value, setValue] = useState("");
useEffect(() => {
events.subscribe(Events.EDIT, (val) => {
setValue(val);
});
}, []);
return (
<div>
<span>react-{">"}</span>
{value}
</div>
);
}
我们可以简单看一下类型提示是否生效:
嗯,可以根据事件名自动切换回调参数类型。
然后在同一个文件下写一个Vanilla js框架的模块,模拟跨框架通信:
tsx
//Vanilla JS
(function createEdit() {
const container = document.getElementById("VanillaJS"),
inputDom = document.createElement("input");
inputDom.type = "text";
inputDom.addEventListener("input", (e) => {
const target = e.target as HTMLInputElement;
events.publish(Events.EDIT, target.value);
});
container.replaceChildren(inputDom);
})();
因为我们是通过HTML id获取的,在index.html文件内,我们这样写:
html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<!-- 新增 -->
<div id="VanillaJS"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
然后启动项目,查看效果:
成功跨框架跨组件通信。最后,为了方便react项目使用,将上述封装成自定义hooks:
ts
export function useCustomEvent<E extends Events>(
eventName: E,
defaultValue?: EventParams<E, EventMaps>
) {
const [value, setValue] = useState(defaultValue);
useEffect(() => {
const event = events.subscribe(eventName, (val) => {
setValue(val);
});
return event.unsubscribe;
}, [eventName]);
return value;
}
类型提示依旧生效。
总结
其实懂得人看完就知道上面的步骤都是在模拟rxjs,关于rxjs可以看深红老师的这篇文章。如果条件允许可以直接用rxjs,毕竟大部分情况人家都帮你处理了。但是作为一名现代前端工程师,写代码不应该只有自己看得懂,在引入rxjs之前,应该考虑会遇到的所有问题,比如其他成员是否能学会rxjs、引入后打包体积等问题。关于这个其实有一项标准可以衡量:如果你写的功能别人不需要看你的源码就知道如何使用,那就是好的实现。以上面的代码为例,虽然写了一大串的类型推导,但是使用者完全不需要了解内部原理,只需要调用一个hooks就能完成跨组件的通信。