利用“发布-订阅”模式驱动数据流
“发布-订阅”模式可谓是解决通信类问题的“万金油”,在前端世界的应用非常广泛,比如:
-
前两年爆火的
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
方法。
-
addListener()
:负责注册事件的监听器,指定事件触发时的回调函数。 -
emit()
:负责触发事件,可以通过传参使其在触发的时候携带数据 。
最后,只进不出总是不太合理的,还要考虑一个 removeListener()
方法,必要的时候用它来删除用不到的监听器:
removeListener()
:负责监听器的删除。
发布-订阅模型编码实现
在写代码之前,先要捋清楚思路。这里把“实现 EventEmitter
”这个大问题,拆解为 3 个具体的小问题,下面逐个来解决。
问题一:事件和监听函数的对应关系如何处理?
提到“对应关系”,应该联想到的是“映射”。在 JavaScript
中,处理“映射”大部分情况下都是用对象来做的。所以说在全局需要设置一个对象,来存储事件和监听函数之间的关系:
class EventBus {
constructor() {
// events 用来存储事件和监听函数之间的关系
this.events = this.events || new Object();
}
}
问题二:如何实现订阅?
所谓“订阅”,也就是注册事件监听函数的过程。这是一个“写”操作,具体来说就是把事件和对应的监听函数写入到 events 里面去:
EventBus.prototype.addListener = function (type, fun) {
const e = this.events[type];
if (!e) { //如果从未注册过监听函数,则将函数放入数组存入对应的键名下
this.events[type] = [fun];
} else { //如果注册过,则直接放入
e.push(fun);
}
};
问题三:如何实现发布?
订阅操作是一个“写”操作,相应的,发布操作就是一个“读”操作。发布的本质是触发安装在某个事件上的监听函数,需要做的就是找到这个事件对应的监听函数队列,将队列中的 fun
依次执行出队:
EventBus.prototype.emit = function (type, ...args) {
let e;
e = this.events[type];
// 查看这个type的event有多少个回调函数,如果有多个需要依次调用。
if (!e) {
return
} else if (Array.isArray(e)) {
for (let i = 0; i < e.length; i++) {
e[i].apply(this, args);
}
}
};
一个核心功能完备的 EventEmitter
如下:
class EventBus {
constructor() {
this.events = this.events || new Object();
}
}
//首先构造函数需要存储event事件,使用键值对存储
//然后需要发布事件,参数是事件的type和需要传递的参数
EventBus.prototype.emit = function (type, ...args) {
let e;
e = this.events[type];
// 查看这个type的event有多少个回调函数,如果有多个需要依次调用。
if (!e) {
return
} else if (Array.isArray(e)) {
for (let i = 0; i < e.length; i++) {
e[i].apply(this, args);
}
}
};
//然后需要写监听函数,参数是事件type和触发时需要执行的回调函数
EventBus.prototype.addListener = function (type, fun) {
const e = this.events[type];
if (!e) { //如果从未注册过监听函数,则将函数放入数组存入对应的键名下
this.events[type] = [fun];
} else { //如果注册过,则直接放入
e.push(fun);
}
};
EventBus.prototype.removeListener = function (type) {
delete this.events[type]
}
// 实例化
const eventBus = new EventBus();
export default eventBus;
测试
下面对 eventBus
进行一个简单的测试,针对名为 “test” 的事件进行监听和触发:
import eventBus from 'EventBus'
// 编写一个简单的 fun
const testHandler = function (params) {
console.log(`test事件被触发了,testHandler 接收到的入参是${params}`);
};
// 监听 test 事件
eventBus.addListener("test", testHandler);
// 在触发 test 事件的同时,传入希望 testHandler 感知的参数
eventBus.emit("test", "newState");
以上代码会输出下面红色矩形框住的部分作为运行结果:
由此可以看出,EventEmitter
的实例已经具备发布-订阅的能力,执行结果符合预期。