毫无疑问,nodeJS改变了整个前端开发生态。本文经过分析nodeJS当中events模块源码,由浅入深,动手实现了属于本身的ES6事件观察者系统。千万不要被nodeJS的外表吓到,无论你是写nodeJS已经轻车熟路的老司机,仍是初入前端的小菜鸟,都不妨碍对这篇文章的阅读和理解。javascript
nodeJS官方介绍中,第二句话即是:前端
"Node.js uses an event-driven, non-blocking I/O model that makes it lightweight and efficient"。java
由此,“事件驱动(event-driven)”理念对nodeJS设计的重要性可见一斑。好比,咱们对于文件的读取,任务队列的执行等,都须要这样一个观察者模式来保障。node
同时,做为前端开发人员,咱们对于所谓的“事件驱动”理念——即“事件发布订阅模式(Pub/Sub模式)”必定再熟悉不过了。这种模式在js里面有与生俱来的基因。咱们能够认为JS自己就是事件驱动型语言:
好比,页面上有一个button, 点击一下就会触发上面的click事件。这是由于此时有特定程序正在监听这个事件,随之触发了相关的处理程序。git
这个模式最大的一个好处在于可以解耦,实现“高内聚、低耦合”的理念。那么这样一个“熟悉的”模式应该怎么实现呢?程序员
其实社区上已经有很多前辈的实现了,可是都不能算特别完美,或者不能彻底符合特定的场景需求。github
本文经过解析nodeJS源码中的events模块,提取其精华,一步步打造了一个基于ES6的eventEmitter系统。api
读者有任何想法,欢迎与我交流。同时但愿各路大神给予斧正。数组
为了方便你们理解,我从一个很简单的页面实例提及。浏览器
百度某产品页面中,存在两处不一样的收藏组件:
第一次点击一个收藏组件按钮,发送异步请求,进行收藏,同时请求成功的回调函数里,须要将页面中全部“收藏”按钮转换状态为“已收藏”。以达到“当前文章”收藏状态的全局同步。
完成这样的设计很简单,咱们大可在业务代码中进行混乱的操做处理,好比初学者常见的作法是:点击第一处收藏,异步请求以后的回调逻辑里,修改页面当中全部收藏按钮状态。
这样作的问题在于耦合混乱,不只仅是一个收藏组件,试想当代码中全部组件全都是这样的“随意”操做,后期维护成本便一发不可收。
个人Github仓库中,也有对于这么一个页面实例的分析,读者若想本身玩一下,能够访问这里。
固然,更优雅的作法就是使用事件订阅发布系统。
咱们先来看看nodeJS是怎么作的吧!
读者能够本身去nodeJS仓库查找源码,不过更推荐参考个人Github-事件发布订阅研究项目,里面不只有本身实现的多套基于ES6的事件发布订阅系统,也“附赠”了nodeJS实现源码。同时我对源码加上了汉语注释,方便你们理解。
在nodeJS中,引入eventEmitter的方式和实例化方法以下:
// 引入 events 模块
var events = require('events');
// 建立 eventEmitter 对象
var eventEmitter = new events.EventEmitter();复制代码
咱们要研究的,固然就是这个eventEmitter实例。先不急于深刻源码,咱们须要在使用层面先有一个清晰的理解和认知。否则盲目阅读源码,便极易成为一只“无头苍蝇”。
一个eventEmitter实例,自身包含有四个属性:
_events:
这是一个object,其实至关于一个哈希map。他用来保存一个eventEmitter实例中全部的注册事件和事件所对应的处理函数。以键值对方式存储,key为事件名;value分为两种状况,当当前注册事件只有一个注册的监听函数时,value为这个监听函数;若是此事件有多个注册的监听函数时,value值为一个数组,数组每一项顺序存储了对应此事件的注册函数。
须要说明的是,理解value值的这两种状况,对于后面的源码分析很是重要。我认为nodeJS之因此有这样的设计,是出于性能上的考虑。由于不少状况(单一监听函数状况)并不须要在内存上新建一个额外数组。
_eventsCount:整型,表示此eventEmitter实例中注册的事件个数。
_maxListeners:整型,表示此eventEmitter实例中,一个事件最多所能承载的监听函数个数。
domain:在node v0.8+版本的时候,发布了一个模块:domain。这个模块作的是捕捉异步回调中出现的异常。这里与主题无关,不作展开。
一样,eventEmitter实例的构造函数原型上,包含了一些更为重要的属性和方法,包括但不限于:
上一段其实简要介绍了nodeJS中eventEmitter的使用方法。下面,咱们要作的就是深刻nodeJS events模块源码,了解并学习他的设计之美。
咱们已经了解到,_events是要来储存监听事件(key)、监听器数组(value)的map。那么,他的初始值必定是一个空对象。直观上,咱们能够这样建立一个空对象:
this._events = {};复制代码
可是nodeJS源码中的实现方式倒是这样:
function EventHandlers() {};
EventHandlers.prototype = Object.create(null);
this._events = new EventHandlers();复制代码
官方称,这么作的缘由是出于性能上的考虑,通过jsperf比较,在v8 v4.9版本中,后者性能有超出2倍的表现。
对此,做为一个“吹毛求疵”有态度的程序员,我写了一个benchmark,对一个对象进行一千次取值操做,求平均时间进行验证:
_events = {};
_events.test='test'
for (let i = 0; i < 1000; i++) {
window.performance.mark('test empty object start');
console.log(_events.test);
window.performance.mark('test empty object end');
window.performance.measure('test empty object','test empty object start','test empty object end');
}
let sum1 = 0
for (let k = 0; k < 1000; k++) {
sum1 +=window.performance.getEntriesByName('test empty object')[k].duration
}
let averge1 = sum1/1000;
console.log(averge1*1000);
function EventHandlers() {};
EventHandlers.prototype = Object.create(null);
_events = new EventHandlers();_events.test='test';
for (let i = 0; i < 1000; i++) {
window.performance.mark('test empty object start');
console.log(_events.test);
window.performance.mark('test empty object end');
window.performance.measure('test empty object','test empty object start','test empty object end');
}
let sum1 = 0
for (let k = 0; k < 1000; k++) {
sum1 +=window.performance.getEntriesByName('test empty object')[k].duration
}
let averge1 = sum1/1000;
console.log(averge1*1000);复制代码
多执行几回会发现,第一段也存在时间上短于第二段执行时间的状况。整体来看,第二段时间上更短,但两次时间比较相近。
我本身的想法是,使用nodeJS源码中这样建立空对象的方式,在对对象属性的读取上可以节省原型链查找的时间。可是,若是一个属性直接在该对象上,即hasOwnProperty()为true,是否还有节省查找时间,性能优化的空间呢?
另外,不一样浏览器引擎的处理可能也存在差异,即便是流行的V8引擎,处理机制也“深不可测”。同时,benchmark中都是对同一属性的读取,通常来说浏览器引擎对一样的操做行为应该会有一个“cache”机制:据我了解JIT(just-in-time)实时汇编,会将重复执行的"hot code"编译为本地机器码,极大增长效率。因此benchmark实现的purity也有被必定程度的干扰。不过好在测试实例都是在相同环境下执行。
因此源码中,此处性能优化上的2倍数值,我持必定的保留态度。
通过整理,适当删减后的源码点击这里查看,保留了个人注释。咱们来一步一步解读下源码。
判断添加的监听器是否为函数类型,使用了typeof进行验证:
if (typeof listener !== 'function') {
throw new TypeError('"listener" argument must be a function');
}复制代码
接下来,要分为几种状况。
case1:
判断_events表是否已经存在,若是不存在,则说明是第一次为eventEmitter实例添加事件和监听器,须要新建立_events:
if (!events) {
events = target._events = new EventHandlers();
target._eventsCount = 0;
} 复制代码
还记得EventHandlers是什么吗?忘记了把屏幕往上滚动再看一下吧。
同时,添加指定的事件和此事件对应的监听器:
existing = events[type] = listener;
++target._eventsCount;复制代码
注意第一次建立时,为了节省内存,提升性能,events[type]值是一个监听器函数。若是再次为相同的events[type]添加监听器时(下面case2),events[type]对应的值须要变成一个数组来存储。
case2:
又啰嗦一遍:若是_events已存在,在为相关事件添加监听器时,须要判断events[type]是函数类型(只存在一个监听函数)仍是已经成为了一个数组类型(已经存在一个以上监听函数)。
而且根据相关参数prepend,分为监听器数组头部插入和尾部插入两种状况,以保证监听器的顺序执行:
if (typeof existing === 'function') {
existing = events[type] = prepend ? [listener, existing] :
[existing, listener];
}
else {
if (prepend) {
existing.unshift(listener);
}
else {
existing.push(listener);
}
}复制代码
case3:
在阅读源码时,我还发现了一个很“诡异”的逻辑:
if (events.newListener) {
target.emit('newListener', type,
listener.listener ? listener.listener : listener);
events = target._events;
}
existing = events[type];复制代码
仔细分析,他的目的是由于nodeJS默认:当全部的eventEmitter对象在添加新的监听函数时,都会发出newListener事件。这其实也并不奇怪,我我的认为这么设计仍是很是合理的。
cae4:
以前介绍了咱们能够设置一个事件对应的最大监听器个数,nodeJS源码中经过这样的代码来实现:
EventEmitter.prototype.setMaxListeners = function setMaxListeners(n) {
if (typeof n !== 'number' || n < 0 || isNaN(n)) {
throw new TypeError('"n" argument must be a positive number');
}
this._maxListeners = n;
return this;
};复制代码
当对这个值进行了设置以后,若是超过此阈值,将会进行报警:
if (!existing.warned) {
m = $getMaxListeners(target);
if (m && m > 0 && existing.length > m) {
existing.warned = true;
const w = new Error('Possible EventEmitter memory leak detected. ' +
`${existing.length} ${String(type)} listeners ` +
'added. Use emitter.setMaxListeners() to ' +
'increase limit');
w.name = 'MaxListenersExceededWarning';
w.emitter = target;
w.type = type;
w.count = existing.length;
process.emitWarning(w);
}
}复制代码
有了以前的注册监听器过程,那么咱们再来看看监听器是如何被触发的。其实触发过程直观上并不难理解,核心思想就是将监听器数组中的每一项,即监听函数逐个执行就行了。
通过整理,适当删减后的源码一样能够这里找到。源码中,包含了较多的错误信息处理内容,忽略不表。下面我挑出一些“出神入化”的细节来分析。
首先,有了上面的分析,咱们如今能够清晰的意识到某个事件的监听处理多是一个函数类型,表示该事件只有一个事件处理程序;也多是个数组,表示该事件有多个事件处理程序,存储在监听器数组中。(我又啰嗦了一遍,由于理解这个过重要了,否则你会看晕的)
同时,emit方法能够接受多个参数。第一个参数为事件类型:type,下面两行代码用于获取某个事件的监听处理类型。用isFn布尔值来表示。
handler = events[type];
var isFn = typeof handler === 'function';复制代码
isFn为true,表示该事件只有一个监听函数。不然,存在多个,储存在数组中。
源码中对于emit参数个数有判断,并进行了switch分支处理:
switch (len) {
case 1:
emitNone(handler, isFn, this);
break;
case 2:
emitOne(handler, isFn, this, arguments[1]);
break;
case 3:
emitTwo(handler, isFn, this, arguments[1], arguments[2]);
break;
case 4:
emitThree(handler, isFn, this, arguments[1], arguments[2], arguments[3]);
break;
// slower
default:
args = new Array(len - 1);
for (i = 1; i < len; i++) {
args[i - 1] = arguments[i];
}
emitMany(handler, isFn, this, args);
}复制代码
咱们挑一个相对最复杂的看一下——默认模式调用的emitMany:
function emitMany(handler, isFn, self, args) {
if (isFn) {
handler.apply(self, args);
}
else {
var len = handler.length;
var listeners = arrayClone(handler, len);
for (var i = 0; i < len; ++i) {
listeners[i].apply(self, args);
}
}
}复制代码
对于只有一个事件处理程序的状况(isFn为true),直接执行:
handler.apply(self, args);复制代码
不然,便使用for循环,逐个调用:
listeners[i].apply(self, args);复制代码
很是有意思的一个细节在于:
var listeners = arrayClone(handler, len);复制代码
这里须要读者细心体会。
源码读到这里,我不由要感叹设计的严谨精妙之处。上面代码处理的意义在于:防止在一个事件监听器中监听同一个事件,从而致使死循环的出现。
若是您不理解,且看我这个例子:
let emitter = new eventEmitter;
emitter.on('message1', function test () {
// some codes here
// ...
emitter.on('message1', test}
});
emit('message1');复制代码
讲道理,正常来说,不通过任何处理,上述代码在事件处理程序内部又添加了对于同一个事件的监听,这必然会带来死循环问题。
由于在emit执行处理程序的时候,咱们又向监听器队列添加了一项。这一项执行时,又会“子子孙孙无穷匮也”的向监听器数组尾部添加。
源码中对于这个问题的解决方案是:在执行emit方法时,使用arrayClone方法拷贝出另外一个如出一辙的数组,进而执行它。这样一来,当咱们在监听器内监听同一个事件时,的确给原监听器数组添加了新的监听函数,但并无影响到当前这个被拷贝出来的副本数组。在循环中,咱们执行的也是这个副本函数。
once(event, listener)是为指定事件注册一个单次事件处理程序,即监听器最多只会触发一次,触发后马上解除该监听器。
实现方式主要是在进行监听器绑定时,对于监听函数进行一层包装。该包装方式在原有函数上添加一个flag标识位,并在触发监听函数前就调用removeListener()方法,除掉此监听函数。我理解,这是一种“双保险”的体现。
代码里,咱们能够抽丝剥茧(已进行删减)学习一下:
EventEmitter.prototype.once = function once(type, listener) {
this.on(type, _onceWrap(this, type, listener));
return this;
};复制代码
once方法调用on方法(即addListener方法,on为别名),第二个参数即监听程序进行_onceWrap化包装,包装过程为:
this.target.removeListener(this.type, this.wrapFn);
if (!this.fired) {
this.fired = true;
this.listener.apply(this.target, arguments);
}复制代码
_onceWrap化的主要思想是将once第二个参数listener的执行,包上了一次判断,并在执行前进行removeListener删除该监听程序。:
this.listener.apply(this.target, arguments);复制代码
removeListener(type, listener)移除指定事件的某个监听器。其实这个实现思路也比较容易理解,咱们已经知道events[type]多是函数类型,也多是数组类型。若是是数组类型,只须要进行遍历,找到相关的监听器进行删除就能够了。
不过关键问题就在于对数组项的删除。
平时开发,咱们经常使用splice进行数组中某一项的删除,99%的case都会想到这个方法。但是nodeJS相关源码中,对于删除进行了优化。本身封装了一个spliceOne方法,用于删除数组中指定角标。而且号称这个方法比使用splice要快1.5倍。咱们就来看一下他是如何实现的:
function spliceOne(list, index) {
for (var i = index, k = i + 1, n = list.length; k < n; i += 1, k += 1) {
list[i] = list[k];
}
list.pop();
}复制代码
传统删除方法:
list.splice(index, 1);复制代码
到底是否计算更快,我也实现了一个benchmark,产生长度为1000的数组,删除其第52项。反复执行1000次求平均耗时:
let arr = Array.from(Array(100).keys());
for (let i = 0; i < 1000; i++) {
window.performance.mark('test splice start');
arr.splice(52, 1);
window.performance.mark('test splice end');
window.performance.measure('test splice','test splice start','test splice end');
}
let sum1 = 0
for (let k = 0; k < 1000; k++) {
sum1 +=window.performance.getEntriesByName('test splice')[k].duration
}
let averge1 = sum1/1000;
console.log(averge1*1000); // 1.7749999999869034
let arr = Array.from(Array(100).keys());
for (let i = 0; i < 1000; i++) {
window.performance.mark('test splice start');
spliceOne(arr, 52);
window.performance.mark('test splice end');
window.performance.measure('test splice','test splice start','test splice end');
}
let sum1 = 0
for (let k = 0; k < 1000; k++) {
sum1 +=window.performance.getEntriesByName('test splice')[k].duration
}
let averge1 = sum1/1000;
console.log(averge1*1000); // 1.5350000000089494复制代码
明显使用spliceOne方法更快,时间上缩短了13.5%,不过依然没有达到官方的1.5,须要说明的是我采用最新版本的Chrome进行测试。
前文咱们感觉了nodeJS中的eventEmitter实现方式。我也对于其中的核心方法,在源码层面进行了剖析。学习到了“精华”以后,更重要的要学以至用,本身实现一个基于ES6的事件发布订阅系统。
个人实现版本中充分利用了ES6语法特性,而且相对于nodeJS实现减小了一些“没必要要的”优化和判断。
由于nodeJS的实现中,不少api在前端浏览器环境开发中并用不到。因此我对对外暴露的方法进行了精简。最终实现上,除去注释部分,只用了不到40行代码。若是您有兴趣,能够去代码仓库访问,整个逻辑仍是很简单的。
里面同时附赠了我同事@颜海镜大神基于zepto实现版本,以及nodeJS events模块源码,方便读者进行对比。
整个过程编写时间仓促,其中必然不乏疏漏之处,还请您斧正并与我讨论。
对于nodeJS源码events模块的阅读,令我受益不浅。设计层面上,优秀的包装和抽象思路对我必定的启发;实现层面上,不少“意想不到”的case处理,让我“叹为观止”。
虽然业务上暂时使用不到nodeJS,可是对于每个前端开发人员来讲,这样的学习我认为是有必要的。从此,我会整理出文章,总结对nodeJS源码更多模块的分析,但愿同读者可以保持交流和探讨。
整篇文章里面列出的benchmark,我认为并不完美。同时,对于浏览器引擎处理上,我存在知识盲点和漏洞,但愿有大神给与斧正。
PS:百度知识搜索部大前端继续招兵买马,有意向者火速联系。。。