前言
本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。
这是源码共读的第8期,链接:【若川视野 x 源码共读】第8期 | mitt、tiny-emitter 发布订阅。
铁子们,我是跑不快的猪,好久不见,恭喜北京行程码摘星,感觉马上就能环游地球了。这次继续源码解读的第二篇,选择了 mitty
、tiny-emmitter
包模块的解读,两个包都是为了实现简单的发布订阅者模式,这个模式距离我最近应该就是使用的 vue
了。所以本次也算是为了以后遥遥无期的解读 vue
的源码做一次粗浅的准备吧。本篇是第一篇,解读 tiny-emitter
。之所以如此,好吧,我得承认我刚开始学习 typescript
没多久,而 mitt
模块全文 typescript
, 所以为了保证质量,我还要提升下。
发布订阅者模式vs观察者模式
不知道各位是否对这两种模式有熟悉的了解,但是在今天之前我还是懵懂的状态。在网上疯狂查阅后,我更迷茫了。在我查阅的时候,发现了两种说法。
- 发布订阅者模式是观察者模式的别名,两者没有本质的区别。
- 发布订阅者模式是观察者模式演变而来 ,两者不是相同的东西。
在查阅了维基百科,与一些同行大佬请教,疯狂机翻了一些外文的文章后。这里说一下我的结论,两者其实不是一回事。
但是两者其实又都解决了类似的问题,就是在一对多的场景中,当“一”发生变化,需要让“多”同时做出相应改变的问题。
就比如现在疫情期间,小区原本通知每天做一次核酸,那小区的居民每天都会去监测点做一次。在疫情得到一定的控制后,小区通知每三天做一次核酸就可以了,那相应的小区居民就会三天做一次。
在上面这个简陋的例子中,宏观来说。 小区 其实就是 观察者模式
的 被观察者 , 同时也是 发布订阅者模式
的 发布者 ,小区居民 就是观察者,也是 订阅者。
不同点
两者的不同点在于:
- 观察者模式(Observer pattern):观察者和被观察者是在直接进行消息的传递。(紧耦合)
- 发布订阅模式(Publish-subscribe pattern): 发布者和订阅者通过一个事件处理中心进行信息的传递。(松耦合)
其实稍微延申一下,观察者模式
更像是一种同步的,在一个单一应用中的模式,观察者
和被观察者
都有一个比较熟悉的了解。反之,发布订阅
模式更像是一种异步,跨端应用的模式。发布者
和订阅者
并不关心到底彼此是谁。仅仅通过事件处理中心能达到自己所需即可。
tiny-emitter
tiny-emitter
就是一个简单实现 发布订阅模式
的npm包,通过它简单的可以达到这一模式的实现。具体的使用方法:tiny-emitter github地址。
tiny-emitter
有四个方法提供给使用者。
- on:订阅事件
- once:订阅事件且仅被触发一次
- emit:发布事件
- off:关闭订阅
整体解析
tiny-emitter
包模块的代码并不复杂,整体来说就是一个函数,在函数的原型对象空间定义了 on
、once
、emit
、off
四个函数方法。
// 源码概略
function E() {}
E.prorotype = {
on: function(name, callback, ctx) {...},
once: function (name, callback, ctx) {...},
emit: function (name) {...}
off: function (name, callback){...}
}
module.exports = E;
module.exports.TinyEmitter = E;
所以当我们引入了这个包模块后,需要将包模块暴露出来的构造函数进行实例化。
// index.js
const E = require('tiny-emitter');
const emitter = new E();
on
E.prototype = {
on: function (name, callback, ctx) {
var e = this.e || (this.e = {}); // 这里运用了运算符的短路特性
(e[name] || (e[name] = [])).push({
fn: callback,
ctx: ctx
});
return this;
}
on
方法的源码如上,它支持三个参数 :
- name:要订阅的事件名称
- callback:当通过使用
emit
后调用的回调函数 - ctx:上下文,也就是this的指向。
这个方法代码不多,主要做了下面几个事情:
- 如果实例化的对象上面没有属性
e
,则创建属性e
作为一个对象,用来保存订阅的事件名称,和触发事件时执行的回调。也就是事件中心。 - 将事件名作为
e
的属性,且是个数组,数组中包含着一个由上下文以及回调函数组成的对象 - 返回当前E的实例
// e的结构
{
name: [
{
fn: callback,
ctx: ctx
},
{
fn: callback1,
ctx: ctx
}
]
}
这里有个点要注意下,作者在示例代码中没有说明一个事件可以订阅多次,但是实际上相同的事件可以订阅多次,因为都存入了数组中,并且,相同事件的回调函数以及上下文完全可以不同。
emit
在订阅后,我们就可以用提供的 emit
方法来进行事件的发布了。
E.prototype = {
emit: function (name) {
var data = [].slice.call(arguments, 1);
var evtArr = ((this.e || (this.e = {}))[name] || []).slice();
var i = 0;
var len = evtArr.length;
for (i; i < len; i++) {
evtArr[i].fn.apply(evtArr[i].ctx, data);
}
return this;
}
}
emit
方法的源码如上,它支持一个参数 :
- name:我们要发布的事件名
这个方法就是发布的主体方法,这个方法做了如下的事情:
- 通过
call
让argument
调用数组的slice
方法,实际上也就是除了事件名字以外的参数,所以,虽然只提供一个参数,但是我们可以传入多个参数,在订阅该事件的回调函数中,也可以用多个参数变量来接。 - 通过名称找到事件在订阅时的回调函数和上下文集合数组,遍历该数组,通过apply调用每个回调函数。这样就能绑定传入的上下文。
- 返回当前实例
off
E.prototype = {
off: function (name, callback) {
var e = this.e || (this.e = {});
var evts = e[name];
var liveEvents = [];
if (evts && callback) {
for (var i = 0, len = evts.length; i < len; i++) {
if (evts[i].fn !== callback && evts[i].fn._ !== callback)
liveEvents.push(evts[i]);
}
}
// Remove event from queue to prevent memory leak
// Suggested by https://github.com/lazd
// Ref: https://github.com/scottcorgan/tiny-emitter/commit/c6ebfaa9bc973b33d110a84a307742b7cf94c953#commitcomment-5024910
(liveEvents.length)
? e[name] = liveEvents
: delete e[name];
return this;
}
}
off
方法的源码如上,它支持两个参数 :
- name:我们要取消订阅的事件名
- callback:订阅事件时的回调函数
它做了如下的几件事情
- 找到存储要取消订阅的事件所对应的数组
- 通过遍历数组查找出与传入的回调函数不相同的项,然后将这些保存进入一个空数组。
- 对上述的数组进行判断,如果是空数组,直接删除在
e
中订阅的事件,来达到防止内存泄漏的目的。如源码中的注释所示。 - 返回当前实例。
这里面有个
evts[i].fn._ !== callback
, 是用来和once
做对应的,看完once
的解释后可能会更了解,不要在这卡住。
这里有个在使用上比较麻烦的地方,因为作者是用 evts[i].fn !== callback && evts[i].fn._ !== callback
来进行比较,所以我们如果要去取消订阅一个事件的时候,回调函数要和订阅时候的回调函数保持一致。
// 失败的取消订阅
const E = require('tiny-emitter');
const emitter = new E();
emitter.on('event', function() {console.log('hellow world')});
emitter.off('event', function() {console.log('hellow world')});
emitter.emit('event');
// 成功的取消订阅
const E = require('tiny-emitter');
const emitter = new E();
const calback = function(){
console.log('hellow world');
}
emitter.on('event', calback);
emitter.off('event', calback);
emitter.emit('event');
失败的取消订阅,就是因为函数是引用类型,所以这种写法,虽然看似一样,但是实际上,两个匿名函数在内存中占用的空间是不同的,所以不是相同的方法,导致了取消订阅的失败。
once
E.prototype {
once: function (name, callback, ctx) {
var self = this;
function listener () {
self.off(name, listener);
callback.apply(ctx, arguments);
};
listener._ = callback
return this.on(name, listener, ctx);
}
}
once
方法的源码如上,参数和 on
一样,所以这里不做赘述
它做了这样几件事情:
- 声明一个函数
listener
,这个函数内部先调用取消订阅的方法,然后再执行回调函数。此时回调函数通过闭包进行访问。 - 在
listener
声明属性_
,保存的值是callback, 这个属性仅仅为了在off
的时候进行判断。 - 通过
on
方法将事件与listener
做一次绑定。 - 返回当前实例
// e的伪代码
{
name: [
{
fn: listener, // 函数也是对象,所以可以给函数增加属性这里的listener._就是通过once订阅时候传的回调
ctx: ctx
}
]
}
在 once
方法中声明的 listener
, 其实就是为了让这个订阅只能执行一次。它代替了我们使用 once
订阅时传入的回调函数。如此一来,当我们调用 emit
的时候,就会执行 listener
中的代码,首先调用 off
取消掉订阅,然后执行通过闭包保存住的 callback
函数。
不得不说这种写法,对我来说是比较新颖的,平时完成类似的需求,我可能仅仅会做的就是声明一个计数器。实在惭愧!
我在阅读这部分还是稍微花费了点时间,也因此列在 off
方法后面, 如此一来或许会更好理解?在off
方法中,验证取消订阅的函数用了 evts[i].fn !== callback && evts[i].fn._ !== callback
这样的一个验证判断。在 off
方法解说中,我们也提到了这个 evts[i].fn._
。其实道理很简单,我们通过 once
订阅后,实际上保存在事件中心的是 listener
,那如果我们订阅后(通过once
) 想立刻取消的话,我们是不知道内部的 listener
函数的,还记得上文提到,函数也是引用类型,我们拿不到自然也就取消不掉。所以,作者这里将原有的 callback
保存在了 listener
的 _
属性上。如此一来,就可以在 emit
之前进行取消订阅了。
很重要的一点
如果我们打算改变 this
的指向,也就是在 on
和 once
中传入 ctx
参数,那我们回调函数,不能使用箭头函数。原因这里不做详解,和本文内容相差过大。网上铺天盖地的关于箭头函数this指向的问题,我就不献丑了。