深入tiny-emitter源码,如何实现发布订阅模式?

1,230 阅读11分钟

本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。

hey🖐! 我是pino😊😊。一枚小透明,期待关注➕ 点赞,共同成长~

什么是tiny-emitter ❓

官方介绍: A tiny (less than 1k) event emitter library.

其实就是一个很小的发布订阅的库😂😂

WechatIMG529.png

如何使用 ❓

首先安装一下:

 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爱吃瓜

上面示例中我们订阅了fn1fn2两个函数,所以函数被触发后理应输出pinopino爱吃瓜,但是由于调用了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-vue3javascript基础知识系列,希望能一直坚持下去,期待多多点赞🤗🤗,一起进步!🥳🥳

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿