我到各个平台看了一下,只发现了一篇讲解Vue的订阅发布的文章,那我补充一片React Hook的吧,应该是全网唯一的了。
“发布-订阅”模式可谓是解决通信类问题的“万金油”,在前端世界的应用非常广泛,比如:
- 前两年爆火的 socket.io 模块,它就是一个典型的跨端发布-订阅模式的实现;
- 在 Node.js 中,许多原生模块也是以 EventEmitter 为基类实现的;
- 不过大家最为熟知的,应该还是 Vue.js 中作为常规操作被推而广之的“全局事件总线” EventBus。
这些应用之间虽然名字各不相同,但内核是一致的,也就是我们下面要讲到的“发布-订阅”模型。
理解事件的发布-订阅机制
发布-订阅机制早期最广泛的应用,应该是在浏览器的 DOM 事件中。 相信有过原生 JavaScript 开发经验的同学,对下面这样的用法都不会陌生:
target.addEventListener(type, listener, useCapture);
通过调用 addEventListener 方法,我们可以创建一个事件监听器,这个动作就是“订阅”。比如我可以监听 click(点击)事件:
el.addEventListener("click", func, false);
这样一来,当 click 事件被触发时,事件会被“发布”出去,进而触发监听这个事件的 func 函数。这就是一个最简单的发布-订阅案例。
使用发布-订阅模式的优点在于,监听事件的位置和触发事件的位置是不受限的,就算相隔十万八千里,只要它们在同一个上下文里,就能够彼此感知。这个特性,太适合用来应对“任意组件通信”这种场景了。
发布-订阅模型 API 设计思路
通过前面的讲解,不难看出发布-订阅模式中有两个关键的动作:事件的监听(订阅)和事件的触发(发布) ,这两个动作自然而然地对应着两个基本的 API 方法。
- on():负责注册事件的监听器,指定事件触发时的回调函数。
- emit():负责触发事件,可以通过传参使其在触发的时候携带数据 。
最后,只进不出总是不太合理的,我们还要考虑一个 off() 方法,必要的时候用它来删除用不到的监听器:
- off():负责监听器的删除。
发布-订阅模型编码实现
“发布-订阅”模式不仅在应用层面十分受欢迎,它更是面试官的心头好。在涉及设计模式的面试中,如果只允许出一道题,那么我相信大多数的面试官都会和我一样,会毫不犹豫地选择考察“发布-订阅模式的实现”。 接下来我就手把手带你来做这道题,写出一个同时拥有 on、emit 和 off 的 EventEmitter。
在写代码之前,先要捋清楚思路。这里我把“实现 EventEmitter”这个大问题,拆解为 3 个具体的小问题,下面我们逐个来解决。
- 问题一:事件和监听函数的对应关系如何处理?
提到“对应关系”,应该联想到的是“映射”。在 JavaScript 中,处理“映射”我们大部分情况下都是用对象来做的。所以说在全局我们需要设置一个对象,来存储事件和监听函数之间的关系:
const globalEvent = {};
- 问题二:如何实现订阅?
所谓“订阅”,也就是注册事件监听函数的过程。这是一个“写”操作,具体来说就是把事件和对应的监听函数写入到 globalEvent 里面去:
export const event$on = (type, handler) => {
if (!(handler instanceof Function)) throw new Error("handler type error")
if (!globalEvent[type]) {
globalEvent[type] = []
}
globalEvent[type].push(handler)
}
- 问题三:如何实现发布?
订阅操作是一个“写”操作,相应的,发布操作就是一个“读”操作。发布的本质是触发安装在某个事件上的监听函数,我们需要做的就是找到这个事件对应的监听函数队列,将队列中的 handler 依次执行出队:
export const event$emit = (type, params) => {
if (globalEvent[type]) {
globalEvent[type].forEach((handler, index) => {
handler(params);
});
}
}
到这里,最最关键的 on 方法和 emit 方法就实现完毕了。最后我们补充一个 off 方法:
export const event$off = (type,handler) => {
if (globalEvent[type]) {
globalEvent[type].splice(globalEvent[type].indexOf(handler) >>> 0, 1);
}
}
考虑 indexOf 返回-1 的情况:splice方法喜欢把-1解读为当前数组的最后一个元素,这样子的话,在压根没有对应函数可以删的情况下,不管三七二十一就把最后一个元素给干掉了。而 >>> 符号对正整数没有影响,但对于-1来说它会把-1转换为一个巨大的数(你可以本地运行下试试看,应该是一个32位全是1的二进制数,折算成十进制就是 4294967295)。这个巨大的索引splice是找不到的,找不到就不删,于是一切保持原状,刚好符合我们的预期。
接着把这些代码片段拼接进一个新项目里面,一个核心功能完备的 EventEmitter 就完成啦:
const globalEvent = {};
export const event$on = (type, handler) => {
if (!(handler instanceof Function)) throw new Error("handler type error")
if (!globalEvent[type]) {
globalEvent[type] = []
}
globalEvent[type].push(handler)
}
export const event$emit = (type, params) => {
if (globalEvent[type]) {
globalEvent[type].forEach((handler, index) => {
handler(params);
});
}
}
export const event$off = (type,handler) => {
if (globalEvent[type]) {
globalEvent[type].splice(globalEvent[type].indexOf(handler) >>> 0, 1);
}
}
下面我们对 Hook 进行一个简单的测试,针对名为 “test” 的事件进行监听和触发:
import { event$emit, event$on, event$off } from './pubsub';
const handler = (params) => {
console.log(`test事件被触发了,testHandler 接收到的入参是${params}`);
};
event$on("test", handler);
event$emit("test", 123);
以上代码放在一个全新的React工程里面就能跑起来了,效果如下:
由此可以看出,已经具备发布-订阅的能力,执行结果符合预期。
现在你可以试想一下,对于任意的两个组件 A 和 B,假如我希望实现双方之间的通信,借助 EventEmitter 来做就很简单了,以数据从 A 流向 B 为例。
我们先定义A和B组件:
import { useState } from "react"
const A = () => {
const [state, setState] = useState("A的状态");
return <div>
<button>点我将我的state传递给B</button>
</div>
}
export default A;
import { useState } from "react"
const B = ()=>{
const [state,setState] = useState("B的状态");
return <div>
<input type="text" value={state} onChange={()=>{}}/>
</div>
}
export default B;
然后我们现在A中定义发布:
import { useEffect } from "react"
import { event$emit } from "../../pubsub";
const A = () => {
const publish = ()=>{
event$emit("A2B_message", "我是A发布的信息")
}
return <div>
<button onClick={publish}>点我将我的state传递给B</button>
</div>
}
export default A;
在B中实现订阅:
import { useEffect, useState } from "react"
import { event$on } from "../../pubsub";
const B = () => {
const [state, setState] = useState("B的状态");
const handler = (params) => {
setState(params)
}
useEffect(() => {
event$on("A2B_message",handler);
})
return <div>
<input type="text" value={state} onChange={() => { }} />
</div>
}
export default B;
由此我们便可以验证到发布-订阅模式驱动 React 数据流的可行性。为了强化你对过程的理解,我将 A 与 B 的通信过程梳理进了一张图里,供你参考: