那些短小精悍的源码仓库——mitt源码解析

645 阅读12分钟

mitt

mitt是对EventEmitter或者说发布订阅模式的函数式实现库,它具有以下特性:

  • 使用TypeScript编码,类型友好
  • 代码风格是函数式的,不需要new对象,方法调用不依赖this
  • Node的EventEmitter一致的命名
  • 支持使用事件名称通配符*
  • 极其轻量,构建并gzip压缩后小于200b的体积

最值得一提的特性当然还是它的轻量,它没有任何外部依赖,除了工程配置、测试等相关代码外,核心代码就一个src/index.ts。算上所有的TS类型代码、注释代码、空行,源码总行数才123行(当然了,代码是不断迭代的,本文探讨的是当前最新的版本3.0.1)。我去除了所有的TS类型、注释、空行后的js版本总共才42行。这样一个短小精悍的库是很适合作为源码来阅读解析的。本文我们就来解析一下这个源码库。

工程目录

Snipaste_2023-11-28_17-04-08.png

整个项目的工程目录如上图所示,其中红色框中的文件是安装依赖后产生的,绿色框中的文件是构建产物,包括各种格式的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})

playground

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})

playground

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})

playground

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})

playground

5. mitt()支持传一个map

你可以在调用mitt的时候传递一个mapmap会赋给返回值的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:

mitt-all.png

知道了这一点,我们可以直接通过emitter.all实现on()off()的功能,甚至是直接清空所有:

// on()设置handleFoo1和handleFoo2
emitter.all.set('foo', [handleFoo1, handleFoo2])
// off()移除handleFoo1
emitter.all.set('foo', [handleFoo2])
// 清空所有
emitter.all.clear()

playground

由简入繁,先看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一样的参数。

  • 首先也是通过typemap数据结构中获取到对应的回调函数集合数组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^32Array时会抛出一个错误: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的项赋值呢?你会发现好像确实是那么回事,没有报错,数组项也插入成功了。

Snipaste_2023-11-30_10-19-56.png

但是此时array.length会是0,并且插入的数组项并不会被遍历到:

Snipaste_2023-11-30_10-21-56.png

曲线救国宣告失败。

你可能会说,我用下面的代码能实现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)

额外有两点需要注意的地方:

  1. 第一点是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)

playground

  1. 第二点是代码中遍历用的是数组的map方法而非forEach,但是这里其实我们并不需要map的返回值,原因很简单:还是为了减少文件大小,mapforEach短。为此我还开了一个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)

这里就用到了函数重载,来声明不同调用方式的参数类型:

Snipaste_2023-12-01_09-13-38.png

2. 泛型约束与类型推导

我们在在调用mitt的时候,可以预先对后续我们可能处理的事件类型做一个统一的类型约束:

const emitter = mitt<{
	'foo': number
	'bar': string
}>()

这样我们在使用on,emit,off方法的时候都可以得到智能的类型提示。比如当我们在用on方法的时候,它会自动提示我们可用的事件类型有哪些:

image.png

明确了类型以后,该类型回调的参数类型也会提示给你:

image.png

其它方法也是会有一样。

源码中的类型推导我能看懂,但是当我尝试用文字把它解释清楚时,发现我办不到。

撒花。