深入NodeJS事件系统

对于前端开发,js的事件系统可以说是我们接触最多的特性之一,甚至很多人把他当作了js与生俱来的特性,把js看作一门基于事件驱动的语言。

但当我们追本朔源,我们会发现,这个看似高深的特性又是一个基于发布/订阅模式的经典实现。下面,我们就仿照nodejs中的这一模块,一步步实现一个基本的事件发布系统。

基本特性

nodejs中的eventEmitter包含以下几个最基本的方法:

1
2
3
4
5
6
7
8
9
10
11
12

addListener/on

removeListener

once

emit

setMaxListener

getMaxListener

其中,最为核心的部分就是emit和addListener方法。

原理

既然是基于发布/订阅模式,那么这套功能的原理说起来也就简单了。首先我们维护一个事件中心EventEmitter,其中保存了一个如下所示的object,用来保存所有事件对应的回调函数:

1
2
3
4
5
6

{

eventType: [array of listeners]

}

addListener/on方法的作用就是向该object中注册相应的回调函数。如 button.on('click', function(){ console.log('hello') })方法就该对象中的click事件对应的数组添加了一个匿名回调函数。

当对应的事件被触发时,对应事件的回调函数数组就会依次被调用。

实现

大概理解了原理之后,我们来看看一个简化版的addListener方法的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

EventEmitter.prototype.addListener = function(type, listener) {

if(typeof listener !== 'function') {

throw new TypeError('argument "listener" must be a function.')

}

const events = this._events;

if(!events) {

events = this._events = new EventHandler(); // EventHandler是一个没有原型链的Object,之所以没有原型链应该是考虑到效率上的提升

}

if(!events[type]) {

events[type] = listener; // 如果没有对应的事件,添加该事件(此时事件对应一个回调函数)



return;

}

if(typeof events[type] === 'function') {

events[type] = [listener, events[type]]; // 如果已经存在回调函数,则将新老回调函数添加到数组中

} else {

events[type].push(listener); // 如果已经存在回调数组,则将新的回调函数添加至末尾

}

}

由于事件系统使用范围十分广泛,因此,此处做了许多性能上的优化。

有了订阅函数,接下来我们再看看另一个核心函数emit:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

EventEmitter.prototype.emit =function(type) {

const events = this._events; // 优化1: 使用深拷贝复制events数组,避免调用回调函数时,发生event的增加/减少

if(!events[type]) {

console.log(`event ${type} is not exist`);

return;

}

if(typeof events[type] === 'function') {

events[type](...arguments);

} else if(events[type].length > 0) {

for(let i = events[type].length; i-- > 0;) {

events[type][i](...arguments);

}

}

}

相比官方的实现,此处我们省略了很多优化点,只考虑当事件发生时,如何调用相应的回调函数/数组。事实上,官方的实现中,针对传入的参数列表长度,实现了多个emit方法emitOne, emitTwo, emitThree。另一个值得注意的地方是,每次emit函数调用时,对应该拷贝一份eventType对应的回调数组,避免回调数组中的函数在调用过程中发生增加/删除的情况。

有了这两个函数,我们基本上就实现了一个简单的事件发布/订阅系统。不过还有一个函数值得我们推敲,那就是once:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

EventEmitter._onceWrap = function(listener, state) {

return function() {

state.target.removeListener(state.type);

if(!state.fired) {

state.fired = true;

}

listener.apply(state.target, arguments);

}

}

EventEmitter.prototype.once = function(type, listener) {

if(typeof listener !== 'function') {

throw new TypeError('"listener" argument must be a function.');

}

const state = { fired: false, listener, target: this, type };

const onceListener = EventEmitter._onceWrap(listener, state);

this.addListener(type, onceListener);

}

使用once函数注册的回调函数在回调一次之后马上就被销毁,其中onceWrap函数将once注册的函数进行包装,保证了这一特性。它的实现值得我们推敲。

总结

到这里,我们的简化版nodejs事件发布/订阅系可以说是告一段落了。阅读NodeJS的源码使我受益匪浅,除了精巧的设计,其中对很多情况的处理和优化也值得我们推敲学习。

有兴趣的读者可以尝试自己造一下轮子,相信你会有意想不到的收获。

参考

What is the reason for cloning listener array in emitMany

解析NodeJS事件驱动系统源码