深入tiny-emitter源码,如何实现发布订阅模式?
本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。
hey🖐! 我是pino😊😊。一枚小透明,期待关注➕ 点赞,共同成长~
什么是tiny-emitter ❓
官方介绍: A tiny (less than 1k) event emitter library.
其实就是一个很小的发布订阅的库😂😂
如何使用 ❓
首先安装一下:
npm install tiny-emitter --save
tiny-emitter
中提供的四个函数的用法:
on
:订阅函数emit
:触发函数once
:只会触发一次off
:删除指定的订阅函数
先来看一下on
函数和emit
函数:
// 引入tiny-emitter
var Emitter = require('tiny-emitter');
// 初始化
var emitter = new Emitter();
// 使用on方法进行订阅事件
emitter.on('test', function (arg1, arg2, arg3) {
console.log(arguments) // 'arg1 value', 'arg2 value', 'arg3 value'
});
// 使用emit进行触发函数,可以传入多参
emitter.emit('test', 'arg1 value', 'arg2 value', 'arg3 value');
使用on
方法可以为同一个事件名定义多个事件函数:
emitter.on('test', function (arg1) {
console.log(arg1)
});
emitter.on('test', function (arg2) {
console.log(arg2)
});
如上所示,我们为some-event
创建了两个处理函数。
而使用emit
进行触发的时候会取出这两个函数,依次进行执行:
// 取出之前订阅的全部函数,依次进行执行,由于订阅的两个函数是相同的功能
// 输出两次pino
emitter.emit('test', 'pino') // pino pino
此外tiny-emitter
中支持链式调用,也就是说下面两种写法是相同的:
emitter.on('test', function (arg1) {
// ...
});
emitter.on('test', function (arg2) {
// ...
});
等价于:
emitter.on('test', function (arg1) {
// ...
}).on('test', function (arg2) {
// ...
})
接下来看一下off
函数,off
函数实现了删除某个订阅名中的指定函数:
let p = new E()
let fn1 = function(str) {
console.log(str)
}
let fn2 = function(str) {
console.log(str + '爱吃瓜')
}
// 订阅fn1函数和fn2函数
p.on('test', fn1).on('test', fn2)
// 删除fn2函数
p.off('test', fn1)
p.emit('test', 'pino') // pino爱吃瓜
上面示例中我们订阅了fn1
和fn2
两个函数,所以函数被触发后理应输出pino
和pino爱吃瓜
,但是由于调用了off
函数删除了fn2
,所以最后只会输出pino爱吃瓜
。
最后还有一个once
函数,它的作用是订阅一个只执行一次的函数。
let p = new E()
let fn1 = function(a1) {
console.log(a1)
}
p.once('test', fn1)
p.emit('test', 'pino')
p.emit('test', '吃瓜')
上面的例子中我们调用了两次emit
函数,但是由于我们订阅fn1
函数使使用了once
函数进行订阅,所以,fn1
函数只会执行一次。
tiny-emitter
的基本用法就是这些了,下面来看一下如何来实现这四个核心的函数呢?
对了,在看核心函数之前还有一个地方可以提前揭晓一下: 在tiny-emitter
的官方示例中提供了两种初始化方法:
var Emitter = require('tiny-emitter');
// 使用new Emitter来初始化
var emitter = new Emitter();
emitter.on('some-event', function (arg1, arg2, arg3) {
//
});
emitter.emit('some-event', 'arg1 value', 'arg2 value', 'arg3 value');
Alternatively, you can skip the initialization step by requiring tiny-emitter/instance instead. This pulls in an already initialized emitter.
(另外,你可以通过要求 tiny-emitter/instance
来跳过初始化步骤。这将拉入一个已经初始化的emitter
。)
// 直接引入tiny-emitter/instance,可以不进行new的初始化
var emitter = require('tiny-emitter/instance');
emitter.on('some-event', function (arg1, arg2, arg3) {
//
});
emitter.emit('some-event', 'arg1 value', 'arg2 value', 'arg3 value');
简言之,就是如果你使用直接引入tiny-emitter/instance
的方式,可以避免使用new
进行初始化的操作。
那么这是怎么实现的呢? 来看一下源码中./instance
文件:
// 引入主文件
var E = require('./index.js');
// 返回一个使用new实例化的对象
module.exports = new E();
可以看到,其实就是引入了主文件,然后使用new
帮我们实例化了一个对象返回。😂😂
如何实现 ❓
整体结构
在看具体的核心函数之前先来看一下tiny-emitter
这个库的整体结构,下面直接放出大体的结构:
function E () {}
// 核心的方法全部放在E函数的prototype中
E.prototype = {
on: function () {},
once: function () {},
emit: function () {},
off: function () {}
}
// 导出构造函数E
module.exports = E;
其实整体结构也非常简单,只是创建了一个函数,然后将核心函数全部挂载在函数的原型对象上,所以初始化后的实例可以直接通过this
进行访问。
那么怎么实现的链式调用呢?
tiny-emitter
在每个核心函数中最后全部返回了this
,由于在原型对象中this
都指向实例,那么自然就相当于每个函数最后都返回了实例,所以可以继续进行链式调用。
on
通过事件名进行订阅
根据上面的例子,on
函数要实现如下功能:需要根据订阅名称来存储订阅函数,返回实例对象。
先来实现一个简单版:
function on(name, callback) {
// `tiny-emitter`中定义了一个e的对象,作为保存所有订阅器的仓库
// 如果不存在,直接初始化对象
var e = this.e || (this.e = {})
// 判断订阅名称是否存在,不存在的话初始化数组
if(!e[name]) e[name] = []
// 根据订阅名保存函数
e[name].push(callback)
// 返回实例
return this
},
如上我们就实现了一个基本的on
函数,但是还不够完美,比如如果允许用户自定义绑定执行函数的this
该如何处理?判断语句有没有更加简洁的写法?
接下来就可以优化第二版的实现:
/**
* @param { string } name 订阅名
* @param { function } callback 订阅函数
* @param { string } context 上下文对象
* @return { object } 实例
*/
function(name, callback, context) {
var e = this.e || (this.e = {})
// 将绑定函数包装为一个对象,包括存储指定的this对象
// 简写方式,判断是否存在,不存在初始化为空数组
;(e[name] || (e[name] = [])).push({
fn: callback,
context: context
})
return this
},
此时,this.e
对象中我们保存的数据结构是这样的:
// 此为伪代码
this.e = {
name: [fn1, fn2, fn3]
}
emit
触发函数的核心思想就是找到订阅名,然后取出所有的订阅函数,执行。
/**
* @param { string } name 订阅名
* @return { object } 实例
*/
function emit(name) {
// 取出所有的参数,第一个参数为订阅名,之后的参数都被视为执行订阅函数所需的参数
let args = [].slice.call(arguments, 1)
// 取出所有的订阅函数
let events = ((this.e || (this.e = {}))[name] || []).slice()
let i = 0
let len = events.length
for(; i < len; i++) {
// 遍历执行订阅函数,此时取出上下文对象,使用apply来执行函数,同时绑定this
events[i].fn.apply(events[i].context, args)
}
// 返回实例
return this
},
这里面有一个点,我觉得挺有意思的,就是这一句代码:
let events = ((this.e || (this.e = {}))[name] || []).slice()
这一句代码其实逻辑很简单,就是根据订阅名称来获取订阅名称下面的所有订阅函数,但是它的写法让我学到了很多,如果正常的写法这段代码会写成下面这样
let e = this.e || (this.e = {})
if(!e[name]) e[name] = []
let events = e[name].slice()
可以看到tiny-emitter
这个库中的实现非常的简洁,既节省了冗余的代码,还显得非常美观,我觉得看源码其中一个非常重要的点就是,我们可以吸取到非常多的优秀的做法和思想,不只是对于功能的逻辑实现,优秀的开源项目中的代码风格也是非常值得我们学习的。
off
主要实现根据订阅名来删除订阅函数。
/**
* @param { string } name 订阅名
* @param { function } callback 订阅函数
* @return { object } 实例
*/
function off(name, callback) {
// 取出对象仓库
let e = this.e || (this.e = {})
// 取出订阅函数列表
let events = e[name]
// 定义数组
let liveEvents = []
if(events && callback ) {
for(let i = 0, len = events.length; i < len; i++) {
// `tiny-emitter`采用的策略是过滤掉与传入的回调函数相同的函数
// 重新设置新数组
if(events[i].fn !== callback) {
liveEvents.push(events[i])
}
}
}
// 判断新数组中是否存在,如果长度不为0,那么直接讲新数组设置为新的订阅名称的仓库
// 长度为0的话,说明没有被筛选中函数,直接将订阅项删除
(liveEvents.length) ? e[name] = liveEvents : delete e[name]
// 返回实例
return this
}
once
once
这个函数实现的功能是,设置一个只执行一次的订阅函数
那么该如何实现呢?其实最直观的思路就是,当我们执行一次订阅函数后,再把它删除掉!那么问题来了,在哪里实现这个删除的操作呢?
其实可以讲订阅函数进行包装,利用闭包的特性,在执行前先对订阅函数进行删除!
// 定义listener函数
function listener() {
// 先执行一次删除操作
self.off(name, listener)
// 然后执行订阅函数,callback与context参数均通过once函数的参数获取
callback.apply(context, arguments)
}
// 直接江listener作为订阅函数进行收集
return this.on(name, listener, context)
由于最后this.on
的调用,收集完成后会返回实例,所以在once
函数中无需返回实例对象。
所以once
函数实现如下:
function once(name, callback, context) {
let self = this
function listener() {
self.off(name, listener)
callback.apply(context, arguments)
}
return this.on(name, listener, context)
},
如果调用过once
,此时this.e
的仓库中可能会是这样的话:
// 伪代码
this.e = {
// listener为我们自定义的只执行一次的函数
name: [fn1, fn2, listener, fn3]
}
这样就大功告成了吗?...了吗...吗?!
其实并没有,我们忽略了一个很重要的地方,就是在执行了once
函数之后,如果我们再调用off
函数想把它删除怎么办? 先来看一下off
函数的实现:
function off(name, callback) {
// ...忽略代码
if(events && callback ) {
for(let i = 0, len = events.length; i < len; i++) {
// 这里我们直接取出每个订阅函数与传入的函数直接进行判断
if(events[i].fn !== callback) {
liveEvents.push(events[i])
}
}
}
// 忽略代码
}
在off
函数中我们直接取出每个订阅函数与传入的函数直接进行判断,但是这里有一个非常重要的地方被遗漏掉了,我们在执行once
函数的时候在数组中保存的是我们自定义的listener
函数!这也就说明,在删除由once
收集的订阅函数时,永远都是失败的,因为用户传入的函数与我们自定义的listener
函数是永远不可能相等的。
其实解决的方法也很简单,把用户在执行once
函数时传入的函数拿出来对比不就可以了吗?
所幸在js中函数也是一个对象,所以我们可以直接在listener
函数上再定义一个属性,用来保存用户传入的原始订阅函数:
function once(name, callback, context) {
let self = this
function listener() {
self.off(name, listener)
callback.apply(context, arguments)
}
// 定义_属性,用于保存callback函数
listener._ = callback // 新增
return this.on(name, listener, context)
},
在off
函数中增加判断:
function off(name, callback) {
// ...忽略代码
if(events && callback ) {
for(let i = 0, len = events.length; i < len; i++) {
// 取出_中保存的原始函数进行对比
if(events[i].fn !== callback && events[i].fn._ !== callback) { // 新增
liveEvents.push(events[i])
}
}
}
// 忽略代码
}
这下我们的整个发布订阅函数终于没有套路的完成了。🫠
完整代码(附赠详细注释) 🖋
// 定义构造函数E
function E() {}
// 函数E的原型对象
E.prototype = {
// on方法:接受订阅名,订阅函数,上下文对象
on: function(name, callback, context) {
// 初始化e仓库
var e = this.e || (this.e = {})
// 收集订阅函数
// 包装为对象,收集订阅函数与上下文对象
;(e[name] || (e[name] = [])).push({
fn: callback,
context
})
// 返回实例对象
return this
},
// once函数:接收订阅名,订阅函数,上下文对象
// 与on的区别是:once函数收集只执行一遍的订阅函数
once: function(name, callback, context) {
let self = this
// 包装对象,用于自定义执行逻辑(删除操作)
function listener() {
self.off(name, listener)
callback.apply(context, arguments)
}
// 保存原始函数
listener._ = callback
// 使用on收集自定义后的函数
// 执行on方法会返回this,所以once函数内不需要返回this
return this.on(name, listener, context)
},
// emit方法用于触发订阅函数:接收订阅名称
emit: function(name) {
// 收集参数
let args = [].slice.call(arguments, 1)
// 收集订阅函数数组
let events = ((this.e || (this.e = {}))[name] || []).slice()
let i = 0
let len = events.length
// 循环执行订阅函数
for(; i < len; i++) {
// 使用apply调用函数并绑定this
events[i].fn.apply(events[i].context, args)
}
// 返回this实例
return this
},
// off用于删除订阅函数:接收订阅名和订阅函数
off: function(name, callback) {
let e = this.e || (this.e = {})
// 获取订阅名称对应的数组
let events = e[name]
let liveEvents = []
// 处理函数数组&传入的订阅函数是否都存在?
if(events && callback ) {
// 循环遍历,过滤操作
for(let i = 0, len = events.length; i < len; i++) {
// 判断数组中的订阅函数是否与传入的订阅函数相等?
// 使用once创建的函数取_属性中的原始函数进行对比
if(events[i].fn !== callback && events[i].fn._ !== callback) {
liveEvents.push(events[i])
}
}
}
// 重置订阅名结果数组
(liveEvents.length) ? e[name] = liveEvents : delete e[name]
// 返回实例this
return this
}
}
收获与感想 🤯
整体深入的学习完tiny-emitter
的源码后,不止收获到了发布订阅模式的实现,逻辑思路,更学到了优秀的开源项目的代码组织形式,整体的架构和代码结构,还有简洁的代码风格。我想,我们每个人的技术沉淀就是不断的学习优秀的作者代码的思想精髓,不断的扩充自己的技术视野,实现一个功能能够迸发出不同的思路和灵感,才能使我们不断的进步,自己的技术储备更加的充实,使我们源源不断的得到前进的动力,获得更加长久的进步!😬😬
写在最后 ⛳
未来可能会更新实现mini-vue3
和javascript
基础知识系列,希望能一直坚持下去,期待多多点赞🤗🤗,一起进步!🥳🥳
转载自:https://juejin.cn/post/7122595241126789127