Node.js 最主要的两个亮点就是异步I/O 和 事件驱动。那么啥是事件驱动呢?
目录
addEventListner和removeEventListener
Node.js的events模块下的EventEmitter
浏览器的事件驱动
在没有学习Node.js之前,我们就已经知道了事件驱动这个名词,因为浏览器端就实现了事件驱动。
浏览器的事件驱动机制指的就是DOM元素的事件绑定,DOM元素的事件绑定分为了HTML级,DOM0级,DOM2级,其中DOM2级是最新实现
addEventListner和removeEventListener
DOM2级事件绑定,有两个关键的方法addEventListner(用于注册事件处理程序)和removeEventListener(用于解绑事件处理程序)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<button>点击</button>
<script>
const btn = document.querySelector('button')
function callback(){
alert(111)
}
btn.addEventListener('click', callback)
</script>
</body>
</html>
事件绑定三要素
在浏览器的事件绑定中存在三个要素:事件源,事件类型,事件处理程序
比如上面DOM2级示例中,btn元素就是事件源,click是事件类型,callback就是事件处理程序。
而所谓事件驱动机制,在浏览器中就可以解释为:
当 btn元素 发生click事件时,才执行callback回调函数。
浏览器的事件绑定中的回调函数是同步执行的
但是需要注意的是callback是同步执行的,而不是异步执行的:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<button>点击</button>
<script>
const btn = document.querySelector('button')
btn.addEventListener('click', ()=>{
console.log(111);
})
btn.click()
console.log(222);
</script>
</body>
</html>
那么 浏览器端的DOM元素事件绑定是如何实现 回调函数的延迟同步执行,而不是异步执行的呢?
事件驱动与发布订阅模式
在浏览器中,存在一个专门用于处理事件的线程,该线程内部会为每一个DOM元素都创建一个用于缓存事件处理程序的队列,当DOM元素使用addEventListener在某事件类型上注册回调函数时,该回调函数就会被缓存到事件处理线程内部的队列中。
当该DOM元素的该事件类型触发后,事件处理线程就会按照队列出队的方式依次取出回调函数出来同步执行。
而浏览器端事件驱动在实现上,完全符合发布订阅模式。
发布订阅模式
所谓发布订阅模式,一般由三部分组成:订阅者,发布者,调度中心
订阅者将订阅信息 发送到 调度中心,由调度中心缓存
发布者将发布消息 发送到 调度中心,由调度中心发布(即告知相应的订阅者)
具体实现上:
程序员使用addEventListner订阅btn元素的click事件,并将回调函数发送到调度中心,
调度中心 监听 btn元素的click事件,当事件触发时,调度中心就会直接触发回调函数。
可以发现这里调度中心 指的就是 浏览器事件处理线程,及其内部的事件队列。
浏览器端事件驱动模拟实现
class DispacthCenter {
constructor() {
this.store = new Map()
}
on(source, type, callback) {
if (!this.store.get(source)) {
this.store.set(source, new Map())
}
if (this.store.get(source).get(type)) {
this.store.get(source).get(type).push(callback)
} else {
this.store.get(source).set(type, [callback])
}
}
emit(source, type) {
if (this.store.get(source) && this.store.get(source).get(type)) {
this.store.get(source).get(type).forEach(item => {
item()
});
}
}
remove(source, type, callback) {
if (this.store.get(source) && this.store.get(source).get(type)) {
let newArr = this.store.get(source).get(type).filter(item => {
return item !== callback
})
this.store.get(source).set(type, newArr)
}
}
}
const dc = new DispacthCenter()
const obj = Object.create(null)
function fn1() {
console.log(111)
}
function fn2() {
console.log(222)
}
dc.on(obj, 'click', fn2)
dc.remove(obj, 'click', fn2)
dc.on(obj, 'click', fn1)
dc.remove(obj, 'click', fn1)
dc.emit(obj, 'click')
以上是对调度中心的模拟实现
Node.js 的事件驱动
Node.js 的事件驱动在原理上,和浏览器的事件驱动本质相同,都是基于发布订阅模式。
差别在于,浏览器的事件驱动大部分是基于UI事件,如鼠标点击事件,键盘敲击事件,
而 Node.js 作为后端环境,不存在UI交互,所以 Node.js 的事件在理解上会更加抽象,但是我们依旧可以从事件源,事件类型,事件处理程序三个要素去理解。
在浏览器事件驱动中,事件源是DOM元素。
Node.js的events模块下的EventEmitter
在Node.js事件驱动中,事件源是可以是任意一个继承自Node.js的内置模块events的EventEmitter的类的实例。
当某个类继承EventEmitter后,其实例的私有属性如下
实例上会具有_events属性,该属性指向一个空对象,用于缓存事件类型和事件处理程序,事件类型作为空对象的属性名,事件处理程序作为空对象的属性值,当一个事件类型下有多个事件处理程序时,会被包装进一个数组中,并将该数组作为新的属性值
_eventsCount,该属性用于记录注册事件类型的个数
_maxListeners,该属性用于记录每个事件最多能注册多少个事件处理程序
当某个类继承EventEmitter后,其实例就拥有了EventEmitter原型上的方法
其中on,once用于注册事件处理程序,emit用于触发事件处理程序,off用于取消事件处理程序
关于on的注意事项
on可以为某个实例的某个事件类型注册多个事件处理程序,并且emit触发该事件类型时,会按照先进先出的原则依次触发注册的事件处理程序
on注册的事件处理程序,即回调函数的this就是实例本身,注意如果回调函数是箭头函数,则没有this,箭头函数中使用的this指向箭头函数定义时所在作用域的this。
const { EventEmitter } = require('events')
module.exports.test = 'test'
class MyModule extends EventEmitter {
constructor() {
super()
}
}
const mm = new MyModule()
mm.on('事件1', function () { // 回调函数是 普通函数
console.log('--->', this); // 实例本身mm
console.log('事件1回调函数2');
})
mm.on('事件1', () => { // 回调函数是 箭头函数
console.log('--->', this === module.exports); // 相当于模块包装函数中的this,等价于module.exports初始指向的空对象
console.log('事件1回调函数1');
})
mm.on('事件2', function () {
console.log('事件2回调函数1');
})
mm.emit('事件1') // 依次触发 z注册在 事件1 上的多个回调函数
这里关于node模块作用域下的this,模块中函数作用域下的this指向简单说明下:
node模块作用域下的this指向module.exports初始时指向的空对象,模块中函数作用域下的this指向函数的调用者,即四种场景(默认绑定【无调用者,默认是全局对象global】,隐式绑定【有调用者,this隐式指向调用者】,new绑定【this指向new创建的对象】,硬绑定【this指向call,apply,bind指定的对象】)
关于once使用注意事项
once和on,在多个事件处理程序绑定,以及事件处理程序(回调函数)的this指向上的设计相同。
即once也可以为某个实例的某个事件类型绑定多个事件处理程序,并且emit触发该实例的该事件类型时,多个事件处理程序会先进先出依次执行。
once指定的事件处理程序(回调函数)的this也是指向实例本身,若为箭头函数,则this指向箭头函数定义时所在作用域的this。
once和on的区别,once注册的事件处理程序只能被emit触发一次,相当于执行一次后,once注册的事件处理程序就会出队列。而on注册的事件处理程序会被缓存,可以被多次执行。
const { EventEmitter } = require('events')
module.exports.test = 'test'
class MyModule extends EventEmitter {
constructor() {
super()
}
}
const mm = new MyModule()
mm.once('事件1', () => {
console.log('事件1的回调函数1');
})
mm.once('事件1', () => {
console.log('事件1的回调函数2');
})
console.log('--->', mm);
mm.emit('事件1')
console.log('--->', mm);
观察MyModule实例的_events是由属性,发现“事件1”的事件处理程序在被emit触发后已经全部出队了。
关于emit使用注意事项
emit用于触发注册在EventEmitter实例上的某事件类型下的所有回调函数,并且会依次执行。
emit可以传参给事件回调函数。
关于off的使用注意事项
off用于解除实例下指定事件类型的指定事件回调函数,这里要求绑定的事件回调函数是具名函数。
注册的事件处理程序是同步执行的
我们需要注意的是,由于Node.js的事件驱动也是基于发布订阅模式的,所以Node.js的事件回调函数也是同步执行的。
原理是,Node.js中,当我们为某个EventEmitter实例注册事件回调函数时,该事件回调函数其实被缓存在了该实例的_events属性中,当事件触发时,再从该实例的_events属性中取出对应事件类型的回调函数执行。
模拟实现Node.js的事件驱动EventEmitter
class MyEventEmitter {
constructor() {
this._events = Object.create(null)
}
on(type, callback) {
if (this._events[type]) {
this._events[type].push(callback)
} else {
this._events[type] = [callback]
}
}
once(type, callback) {
const fn = (...args) => {
callback.call(this, ...args)
this.off(type, fn)
}
callback.link = fn
this.on(type, fn)
}
emit(type, ...args) {
if (this._events[type]) {
this._events[type].forEach(item => {
item(...args)
})
}
}
off(type, callback) {
if (this._events[type]) {
let newArr = this._events[type].filter(item => {
return item !== callback && item !== callback.link
})
this._events[type] = newArr
}
}
}
const me = new MyEventEmitter()
// me.on('事件1', (...args) => {
// console.log('事件1回调函数1', ...args);
// })
// function fn1() {
// console.log('fn1');
// }
// me.on('事件1', fn1)
// me.off('事件1', fn1)
// me.emit('事件1', 1, 2, 3)
me.once('事件2', (...args) => {
console.log('事件2回调函数1', ...args);
})
function fn2() {
console.log('fn2');
}
me.once('事件2', fn2)
me.off('事件2', fn2)
me.emit('事件2', 1, 2, 3)
me.emit('事件2')
EventEmitter实现难点主要在于once,由于once是一次性注册,即注册的事件处理程序在调用后就会被取消注册,即需要在事件处理程序调用中执行off操作,所以只能进行函数包装,最终将包装函数注册。
但是将once注册的事件处理程序包装后,off将无法取消注册对应的事件处理程序,原因是once时机注册的是包装函数,而不是原函数,而off只能获取到原函数,所以需要在包装原函数时,建立原函数和包装函数的关系,即callback.link = wrap
EventEmitter内置事件类型
之前我们使用的事件都是自定义的事件类型,如“事件1”,在EventEmitter中,事件类型只是一个简单的字符串,而EventEmitter为我们准备了两个通用的内置事件类型
newListener:当为EventEmitter实例注册新的事件处理程序时,newListener事件会被触发
removeListener:当为EventEmitter实例移除某事件处理程序时,removeListener事件会被触发
const { EventEmitter } = require('events')
class MyModule extends EventEmitter {
constructor() {
super()
}
}
const mm = new MyModule()
mm.on('newListener', (...args) => {
console.log(`newLisener事件触发, 添加的新事件类型和回调为--->\n${args}`)
})
mm.on('事件1', () => {
console.log('事件1触发')
})
function fn() {
console.log('test');
}
mm.on('事件1', fn)
mm.on('removeListener', (...args) => {
console.log(`removeListner事件触发,移除的事件类型和回调为--->\n${args}`)
})
mm.off('事件1', fn)
可以发现newListener和removeListener事件的回调函数都有两个参数:type和callback
即事件类型和事件处理程序 。
这些内置事类型和自定义事件类型的区别在于:自定义事件类型需要手动emit,而newListener和removeListener可以实现不通过emit触发事件,原因是Node.js系统内部帮我们自动emit了,优点类似于浏览器端UI事件被UI页面动作被触发后,浏览器的事件处理线程自动emit事件处理程序。