mitt
mitt是对EventEmitter或者说发布订阅模式的函数式实现库,它具有以下特性:
- 使用TypeScript编码,类型友好
- 代码风格是函数式的,不需要
new对象,方法调用不依赖this - 与Node的EventEmitter一致的命名
- 支持使用事件名称通配符
* - 极其轻量,构建并gzip压缩后小于200b的体积
最值得一提的特性当然还是它的轻量,它没有任何外部依赖,除了工程配置、测试等相关代码外,核心代码就一个src/index.ts。算上所有的TS类型代码、注释代码、空行,源码总行数才123行(当然了,代码是不断迭代的,本文探讨的是当前最新的版本3.0.1)。我去除了所有的TS类型、注释、空行后的js版本总共才42行。这样一个短小精悍的库是很适合作为源码来阅读解析的。本文我们就来解析一下这个源码库。
工程目录
整个项目的工程目录如上图所示,其中红色框中的文件是安装依赖后产生的,绿色框中的文件是构建产物,包括各种格式的js和类型声明文件。核心源码当然是src/index.ts,test目录包含了很多测试用例,其实阅读测试用例也可以帮助我们快速了解一个库有哪些用法,这一点后面我们会说到。
从用例开始
在看源码实现之前,让我们先来看看这个库应该怎么使用,了解了用法之后,再去反推实现。这个过程就像现在心里有了一个蓝图,然后再去一步步实现它。其实这也有点像 测试驱动开发(TDD) 的思想:先编写测试用例,然后编写能够跑通测试用例的代码。
只不过接下来的用例我使用运行时的js代码来模拟的,每个用例最后都会提供一个完整的用例运行时TS playground链接,点开链接你可以看到我在注释中标注了用例运行时的Log,当然你也可以通过ctrl + enter键在右侧面板中查看运行时Log结果。
1. 基本用法
最基本的用法就是:emitter可以用on()给某个事件添加多个事件处理程序,用emit()触发事件,并且可以用off()移除事件的处理程序。
const emitter = mitt()
const handleFoo1 = (e) => {
console.log('handleFoo1', e)
}
const handleFoo2 = (e) => {
console.log('handleFoo2', e)
}
// 给foo事件绑定两个事件处理程序
emitter.on('foo', handleFoo1)
emitter.on('foo', handleFoo2)
// 触发foo事件,此时分别执行handleFoo和handleFoo2
// [LOG]: "handleFoo1", "foo", { "value": 1 }
// [LOG]: "handleFoo2", "foo", { "value": 1 }
emitter.emit('foo', {value: 1})
// 移除掉1个foo事件的处理程序
emitter.off('foo', handleFoo1)
// 再次触发foo事件,此时只执行handleFoo2
// [LOG]: "handleFoo2", "foo", { "value": 2 }
emitter.emit('foo', {value: 2})
2. off()只传第1个参数
off()只传第1个参数,可以移除该事件下的所有事件处理程序
const emitter = mitt()
const handleFoo1 = (type, e) => {
console.log('handleFoo1', type, e)
}
const handleFoo2 = (type, e) => {
console.log('handleFoo2', type, e)
}
// 给foo事件添加两个事件处理程序
emitter.on('foo', handleFoo1)
emitter.on('foo', handleFoo2)
// 只传第1个参数,移除foo对应的所有事件处理程序
emitter.off('foo')
// 触发foo事件,没有任何Log
emitter.emit('foo', {value: 1})
3. 事件名可以是Symbol
const emitter = mitt()
const eventName = Symbol('foo')
const handleFoo = (e) => {
console.log(`触发${String(eventName)}事件,执行handleFoo`, e)
}
emitter.on(eventName, handleFoo)
emitter.emit(eventName, {value: 1})
4. 事件名支持通配符*
const emitter = mitt()
const handleAll1 = (type, e) => {
console.log('handleAll1', type, e)
}
const handleAll2 = (type, e) => {
console.log('handleAll2', type, e)
}
// 通过通配符监听所有的事件
emitter.on('*', handleAll1)
emitter.on('*', handleAll2)
// 分别触发foo和bar事件,此时Log顺序如下:
// [LOG]: "handleAll1", "foo", { "value": 1 }
// [LOG]: "handleAll2", "foo", { "value": 1 }
// [LOG]: "handleAll1", "bar", { "value": 1 }
// [LOG]: "handleAll2", "bar", { "value": 1 }
emitter.emit('foo', {value: 1})
emitter.emit('bar', {value: 1})
// 移除通配符*下的handleAll1事件处理程序,然后再触发foo和bar事件,此时Log顺序如下:
// [LOG]: "handleAll2", "foo", { "value": 1 }
// [LOG]: "handleAll2", "bar", { "value": 1 }
emitter.off('*', handleAll1)
emitter.emit('foo', {value: 1})
emitter.emit('bar', {value: 1})
// 不传off的第二个参数可以移除通配符*下的所有事件处理程序,然后再触发foo和bar事件,就不再有Log了
emitter.off('*')
emitter.emit('foo', {value: 1})
emitter.emit('bar', {value: 1})
5. mitt()支持传一个map
你可以在调用mitt的时候传递一个map,map会赋给返回值的all属性
const map = new Map()
const emitter = mitt(map)
emitter.all // Map(0) {size: 0}
这个map其实就是所有已注册的事件处理程序的集合,所以通过这种用法可以一次性给多个事件注册多个事件处理程序,这和分多次调用on()方法来注册事件处理程序效果是一样的:
const map = new Map()
map.set('foo', [(e) => {
console.log('handleFoo1', e)
}, (e) => {
console.log('handleFoo2', e)
}])
map.set('bar', [(e) => {
console.log('handleBar1', e)
}]
const emitter = mitt(map)
这里也能看到mitt采用的保存注册的事件处理程序所采用的数据结构,整体是一个map,map的key是事件名,value是一个回调函数的组成的array:
知道了这一点,我们可以直接通过emitter.all实现on()、off()的功能,甚至是直接清空所有:
// on()设置handleFoo1和handleFoo2
emitter.all.set('foo', [handleFoo1, handleFoo2])
// off()移除handleFoo1
emitter.all.set('foo', [handleFoo2])
// 清空所有
emitter.all.clear()
由简入繁,先看js代码
上一节我们通过几个用例基本了解了mitt的用法。接下来就到了重头戏,核心源码的分析。虽然源码很简短,但是为了兼顾对TS类型不够深入的同学,我们还是先从简单的js代码开始,文章开头已经给出了链接,这里我把代码再贴一遍:
function mitt(all) {
all = all || new Map()
return {
all,
on(type, handler) {
const handlers = all.get(type)
if (handlers) {
handlers.push(handler)
} else {
all.set(type, [handler])
}
},
off(type, handler) {
const handlers = all.get(type)
if (handlers) {
if (handler) {
handlers.splice(handlers.indexOf(handler) >>> 0, 1)
} else {
all.set(type, [])
}
}
},
emit(type, evt) {
let handlers = all.get(type)
if (handlers) {
handlers
.slice()
.map((handler) => {
handler(evt)
})
}
handlers = all.get('*')
if (handlers) {
handlers
.slice()
.map((handler) => {
handler(type, evt)
})
}
}
}
}
整体结构
我们把代码简化下,方便理清代码的整体结构:
function mitt(all) {
all = all || new Map()
return {
all,
on(type, handler) {},
off(type, handler) {},
emit(type, evt) {},
}
}
可以看到,代码整体是一个函数,接受一个map类型的参数all。如果没有传这个参数,会用new Map()初始化。最后返回一个对象,对象包含一个all属性和三个用来操作all的方法。
on()方法
我们来看on()的实现:
on(type, handler) {
// 获取map中type对应的所有回调函数的集合数组
const handlers = all.get(type)
if (handlers) {
// 如果数组存在,则push
handlers.push(handler)
} else {
// 如果不存在,则初始化
all.set(type, [handler])
}
}
on方法接受两个参数:事件类型type和回调函数handler。
- 首先通过
all.get(type)来获取到type对应的所有回调函数的集合handlers,它是一个数组(还记得前面用例里提到的map数据结构吗?)。 - 然后需要注意下,对于某个类型
type,如果是第一次调用on(type),map数据结构里还不存在这个类型的回调函数集合。所以需要判断一下,如果存在则向数组中push(handler),不存在则要初始化。
off()方法
off(type, handler) {
// 获取map中type对应的所有回调函数的集合数组
const handlers = all.get(type)
if (handlers) {
if (handler) {
// 如果handler存在,则从数组中删除handler
handlers.splice(handlers.indexOf(handler) >>> 0, 1)
} else {
// 如果不存在,则把数组设为空数组
all.set(type, [])
}
}
}
off方法接收和on一样的参数。
- 首先也是通过
type从map数据结构中获取到对应的回调函数集合数组handlers。 - 由于是要移除回调函数,所以需要先判断
type对应的回调数组handlers是存在的才会进行后续操作。 - 然后继续判断,如果第2个参数
handler存在,则从数组中删除该回调函数。如果不存在,则在map中把这个type设为空数组(这里就对应上面的第2个用例)。
题外话:无符号右移操作符
上面的代码中有这么个表达式:handlers.indexOf(handler) >>> 0,其中的>>>叫无符号右移操作符。关于无符号右移操作符这里不展开说明了,我们直接看运行结果。
对于数组的indexOf()方法,返回值无非两种情况0与正整数或者-1,对于第1种情况进行无符号右移的返回值仍然是原来的数。而-1 >>> 0的结果是一个很大的正整数4294967295。那么整行splice的语句的意图就很明显了:如果handlers中能找到handler,则把他删除。如果找不到(即索引为-1),则删除数组中索引为4294967295的项。而JavaScript中尝试创建一个长度为负数或长度大于等于2^32的Array时会抛出一个错误:Uncaught RangeError: Invalid array length。而Math.pow(2, 32) - (-1 >>> 0) === 1,即要删除-1 >>> 0位置的数组项,必须先有长度为2^32的数组,而前面我们说了,想创建一个这么长的数组是会报错的。
绝大多数情况下,我们用到的数组不会这么长,那如果就是有这么长度数组,这岂不是一个bug?然而实际情况并不是。尝试创建一个长度为(-1 >>> 0) + 1的数组会报错:
const array = Array((-1 >>> 0) + 1) // VM608:1 Uncaught RangeError: Invalid array length
MDN给出了解释:
无效的数组错误长度通常会在以下情形中出现:
- 当创建一个长度为负数或者长度大于等于 2^32 的
Array或者ArrayBuffer时。 当设置Array.length属性为负数或者长度大于等于 2^32 时。
而恰好,这个等式是成立的:(-1 >>> 0) + 1 === Math.pow(2, 32)。
如果你尝试曲线救国,不创建那么长的数组,而是直接给数组索引为-1 >>> 0的项赋值呢?你会发现好像确实是那么回事,没有报错,数组项也插入成功了。
但是此时array.length会是0,并且插入的数组项并不会被遍历到:
曲线救国宣告失败。
你可能会说,我用下面的代码能实现handlers.splice(handlers.indexOf(handler) >>> 0, 1)一样的效果,用右移操作符可能还有人读不懂,这样写完全是为了炫技吗?
const index = handlers.indexOf(handler)
if (index !== -1) {
handlers.splice(index, 1)
}
我想答案不是,而是因为作者对代码体积的极致追求,如果你到源码仓库的issue区去看看就会发现,有很多人给这个仓库提的PR都因为最终构建产物gzipped后大小大于200b而未被merge。另一方面,那样一行代码确实比上面的判断代码要更简洁优雅不是吗?
所以如果你下次也尝试做这样的事:在array中查找某个item,找到则移除,找不到则什么都不做,则可以用同样的方法:
function removeItem<T>(array: T[], item: T) {
array.splice(array.indexOf(item) >>> 0, 1)
}
emit()方法
emit(type, evt) {
// 获取map中type对应的所有回调函数的集合数组
let handlers = all.get(type)
if (handlers) {
// 如果数组存在,
handlers
.slice()
.map((handler) => {
handler(evt)
})
}
handlers = all.get('*')
if (handlers) {
handlers
.slice()
.map((handler) => {
handler(type, evt)
})
}
}
emit方法接收两个参数:事件类型type和事件对象evt,这里的evt就是on(type, (e) => {})里回调函数的参数e。
- 首先,需要从
map数据结构中取出我们想要的回调函数集合数组handlers。但是这里要分两种情况:当前type对应的数组,以及通配符*对应的数组(还记得嘛,我们可以通过on('*', fn)来监听所有类型的事件)。 - 然后对这两种情况下的数组,遍历出每一个回调函数并传递
evt调用。但是需要注意,对于*类型的数组,回调函数调用的时候需要把当前type额外传递过去:handler(type, evt)。
额外有两点需要注意的地方:
- 第一点是
handlers在遍历的时候先做了一个浅拷贝handlers.slice(),然后才遍历的。我猜是为了兼容once方法,once方法是只on方法注册的监听器只执行一次,执行后就从注册数组中移除。如果不拷贝直接遍历,那么once的执行会修改元素组,导致后续的遍历受到影响:
function mittWithOnce(all) {
const inst = mitt(all)
inst.once = (type, fn) => {
const wrapper = (evt) => {
fn(evt)
inst.off(type, wrapper)
}
inst.on(type, wrapper)
};
return inst
}
const emitter = mittWithOnce()
emitter.on('foo', handleFoo1)
emitter.once('foo', handleFoo2)
emitter.on('foo', handleFoo3)
// 预期的应该是handleFoo1和handleFoo3,但是由于once注册的函数会在执行后立即删除,这修改了元素组,影响了后续的数组遍历
// [LOG]: "handleFoo1", 1
// [LOG]: "handleFoo2", 1
emitter.emit('foo', 1)
- 第二点是代码中遍历用的是数组的
map方法而非forEach,但是这里其实我们并不需要map的返回值,原因很简单:还是为了减少文件大小,map比forEach短。为此我还开了一个discussions来问作者,developit就是这么回答的。
TS类型
前面我们已经把整个js代码分析了一遍,是时候来分析下它的TS类型了。整个文件在这里,我就不不全贴出来了。
其实把整个类型从头到尾过一遍没什么意义,而且类型的声明跳来跳去不自己去看其实很难解释清楚。所以这里还是挑几个用法,逆推到类型上是怎么实现的来说吧。
1. 函数重载
我们在使用on,off,emit三个方法的时候都有不止一种调用方式:
const emitter = mitt()
const handleFoo1 = (e) => {}
const bar = Symbol('bar')
// on
emitter.on('foo', handleFoo1)
emitter.on(bar, () => {})
emitter.on('*', () => {})
// off
emitter.off('foo', handleFoo1)
emitter.off('foo')
emitter.off('*', handleFoo1)
// emit
emitter.emit('foo', 1)
emitter.emit(bar)
这里就用到了函数重载,来声明不同调用方式的参数类型:
2. 泛型约束与类型推导
我们在在调用mitt的时候,可以预先对后续我们可能处理的事件类型做一个统一的类型约束:
const emitter = mitt<{
'foo': number
'bar': string
}>()
这样我们在使用on,emit,off方法的时候都可以得到智能的类型提示。比如当我们在用on方法的时候,它会自动提示我们可用的事件类型有哪些:
明确了类型以后,该类型回调的参数类型也会提示给你:
其它方法也是会有一样。
源码中的类型推导我能看懂,但是当我尝试用文字把它解释清楚时,发现我办不到。
撒花。