本文来自 @simonezhou 小姐姐投稿的第八期笔记。面试官常问发布订阅、观察者模式,我们日常开发也很常用。文章讲述了 mitt、tiny-emitter、Vue eventBus
这三个发布订阅、观察者模式相关的源码。
源码地址
执行 npm run build:
//
"scripts": {
...
"bundle": "microbundle -f es,cjs,umd",
"build": "npm-run-all --silent clean -p bundle -s docs",
"clean": "rimraf dist",
"docs": "documentation readme src/index.ts --section API -q --parse-extension ts",
...
},
{
"name": "mitt", // package name
...
...
"module": "dist/mitt.mjs", // ES Modules output bundle
"main": "dist/mitt.js", // CommonJS output bundle
"jsnext:main": "dist/mitt.mjs", // ES Modules output bundle
"umd:main": "dist/mitt.umd.js", // UMD output bundle
"source": "src/index.ts", // input
"typings": "index.d.ts", // TypeScript typings directory
"exports": {
"import": "./dist/mitt.mjs", // ES Modules output bundle
"require": "./dist/mitt.js", // CommonJS output bundle
"default": "./dist/mitt.mjs" // Modern ES Modules output bundle
},
...
}
使用 microbundle watch 命令,新增 script,执行 npm run dev:
"dev": "microbundle watch -f es,cjs,umd"
对应目录新增入口,比如 test.js,执行 node test.js 测试功能:
const mitt = require('./dist/mitt');
const Emitter = mitt();
Emitter.on('test', (e, t) => console.log(e, t));
Emitter.emit('test', { a: 12321 });
对应源码 src/index.js 也依然可以加相关的 log 进行查看,代码变动后会触发重新打包
使用上可以(官方给的例子),比如定义 foo 事件,回调函数里面的参数要求是 string 类型,可以想象一下源码 TS 是怎么定义的:
import mitt from 'mitt';
// key 为事件名,key 对应属性为回调函数的参数类型
type Events = {
foo: string;
bar?: number; // 对应事件允许不传参数
};
const emitter = mitt<Events>(); // inferred as Emitter<Events>
emitter.on('foo', (e) => {}); // 'e' has inferred type 'string'
emitter.emit('foo', 42); // Error: Argument of type 'number' is not assignable to parameter of type 'string'. (2345)
emitter.on('*', (type, e) => console.log(type, e) )
源码内关于 TS 定义(关键几句):
export type EventType = string | symbol;
// Handler 为事件(除了*事件)回调函数定义
export type Handler<T = unknown> = (event: T) => void;
// WildcardHandler 为事件 * 回调函数定义
export type WildcardHandler<T = Record<string, unknown>> = (
type: keyof T, // keyof T,事件名
event: T[keyof T] // T[keyof T], 事件名对应的回调函数入参类型
) => void;
export interface Emitter<Events extends Record<EventType, unknown>> {
// ...
on<Key extends keyof Events>(type: Key, handler: Handler<Events[Key]>): void;
on(type: '*', handler: WildcardHandler<Events>): void;
// ...
emit<Key extends keyof Events>(type: Key, event: Events[Key]): void;
// 这句主要兼容无参数类型的事件,如果说事件对应回调必须传参,使用中如果未传,那么会命中 never,如下图
emit<Key extends keyof Events>(type: undefined extends Events[Key] ? Key : never): void;
}
以下是会报 TS 错误:
以下是正确的:
export default function mitt<Events extends Record<EventType, unknown>>(
// 支持 all 初始化
all?: EventHandlerMap<Events>
): Emitter<Events> {
// 内部维护了一个 Map(all),Key 为事件名,Value 为 Handler 回调函数数组
all = all || new Map();
return {
all, // 所有事件 & 事件对应方法
emit, // 触发事件
on, // 订阅事件
off // 注销事件
}
}
on<Key extends keyof Events>(type: Key, handler: GenericEventHandler) {
// Map get 获取
const handlers: Array<GenericEventHandler> | undefined = all!.get(type);
// 如果已经初始化过的话,是个数组,直接 push 即可
if (handlers) {
handlers.push(handler);
}
// 如果第一次注册事件,则 set 新的数组
else {
all!.set(type, [handler] as EventHandlerList<Events[keyof Events]>);
}
}
off<Key extends keyof Events>(type: Key, handler?: GenericEventHandler) {
// Map get 获取
const handlers: Array<GenericEventHandler> | undefined = all!.get(type);
// 如果有事件列表,则进入,没有则忽略
if (handlers) {
// 对 handler 事件进行 splice 移出数组
// 这里是对找到的第一个 handler 进行移出,所以如果订阅了多次,只会去除第一个
// handlers.indexOf(handler) >>> 0,>>> 为无符号位移
// 关于网上对 >>> 用法说明:It doesn't just convert non-Numbers to Number, it converts them to Numbers that can be expressed as 32-bit unsigned ints.
if (handler) {
handlers.splice(handlers.indexOf(handler) >>> 0, 1);
}
// 如果不传对应的 Handler,则为清空事件对应的所有订阅
else {
all!.set(type, []);
}
}
}
emit<Key extends keyof Events>(type: Key, evt?: Events[Key]) {
// 获取对应 type 的 Handlers
let handlers = all!.get(type);
if (handlers) {
(handlers as EventHandlerList<Events[keyof Events]>)
.slice()
.map((handler) => {
handler(evt!);
});
}
// 获取 * 对应的 Handlers
handlers = all!.get('*');
if (handlers) {
(handlers as WildCardEventHandlerList<Events>)
.slice()
.map((handler) => {
handler(type, evt!);
});
}
}
为什么是使用 slice().map() ,而不是直接使用 forEach() 进行触发?具体可查看:https://github.com/developit/mitt/pull/109,具体可以拷贝相关代码进行调试,直接更换成 forEach 的话,针对以下例子所触发的 emit 是错误的:
import mitt from './mitt'
type Events = {
test: number
}
const Emitter = mitt<Events>()
Emitter.on('test', function A(num) {
console.log('A', num)
Emitter.off('test', A)
})
Emitter.on('test', function B() {
console.log('B')
})
Emitter.on('test', function C() {
console.log('C')
})
Emitter.emit('test', 32432) // 触发 A,C 事件,B 会被漏掉
Emitter.emit('test', 32432) // 触发 B,C,这个是正确的
// 原因解释:
// forEach 时,在 Handlers 循环过程中,同时触发了 off 操作
// 按这个例子的话,A 是第一个被注册的,所以第一个会被 slice 掉
// 因为 array 是引用类型,slice 之后,那么 B 函数就会变成第一个
// 但此时遍历已经到第二个了,所以 B 函数就会被漏掉执行
// 解决方案:
// 所以对数组进行 [].slice() 做一个浅拷贝,off 的 Handlers 与 当前循环中的 Handlers 处理成不同一个
// [].slice.forEach() 效果其实也是一样的,用 map 的话个人感觉不是很语义化
// 来自 typescript 中的 lib.es5.d.ts 定义
/**
* Exclude null and undefined from T
*/
type NonNullable<T> = T extends null | undefined ? never : T;
// 如果 T 的值包含 null 或者 undefined,则会 never 表示不允许走到此逻辑,否则返回 T 本身的类型
function E () {
// Keep this empty so it's easier to inherit from
// (via https://github.com/lipsmack from https://github.com/scottcorgan/tiny-emitter/issues/3)
}
// 所有事件都挂载在 this.e 上,是个 object
E.prototype = {
on: function (name, callback, ctx) {},
once: function (name, callback, ctx) {},
emit: function (name) {},
off: function (name, callback) {}
}
module.exports = E;
module.exports.TinyEmitter = E;
once: function (name, callback, ctx) {
var self = this;
// 构造另一个回调函数,调用完之后,销毁该 callback
function listener () {
self.off(name, listener); // 销毁
callback.apply(ctx, arguments); // 执行
};
listener._ = callback
// on 函数返回 this,所以可以链式调用
return this.on(name, listener, ctx); // 订阅这个构造的回调函数
}
on: function (name, callback, ctx) {
var e = this.e || (this.e = {});
// 单纯 push 进去,这里也没有做去重,所以同一个回调函数可以被订阅多次
(e[name] || (e[name] = [])).push({
fn: callback,
ctx: ctx
});
// 返回 this,可以链式调用
return this;
}
off: function (name, callback) {
var e = this.e || (this.e = {});
var evts = e[name];
var liveEvents = []; // 保存还有效的 hanlder
// 传递的 callback,如果命中,就不会被放到 liveEvents 里面
// 所以这里的销毁是一次性销毁全部相同的 callback,与 mitt 不一样
if (evts && callback) {
for (var i = 0, len = evts.length; i < len; i++) {
if (evts[i].fn !== callback && evts[i].fn._ !== callback)
liveEvents.push(evts[i]);
}
}
// Remove event from queue to prevent memory leak
// Suggested by https://github.com/lazd
// Ref: https://github.com/scottcorgan/tiny-emitter/commit/c6ebfaa9bc973b33d110a84a307742b7cf94c953#commitcomment-5024910
// 如果没有任何 handler,对应的事件 name 也可以被 delete
(liveEvents.length)
? e[name] = liveEvents
: delete e[name];
// 返回 this,可以链式调用
return this;
}
emit: function (name) {
// 取除了第一位的剩余所有参数
var data = [].slice.call(arguments, 1);
// slice() 浅拷贝
var evtArr = ((this.e || (this.e = {}))[name] || []).slice();
var i = 0;
var len = evtArr.length;
// 循环逐个触发 handler,把 data 传入其中
for (i; i < len; i++) {
evtArr[i].fn.apply(evtArr[i].ctx, data);
}
// 返回 this,可以链式调用
return this;
}
// index.js 调用 initMixin 方法,初始化 _events object
initMixin(Vue)
// event.js 定义 initEvents 方法
// vm._events 保存所有事件 & 事件回调函数,是个 object
export function initEvents (vm: Component) {
vm._events = Object.create(null)
// ...
}
// index.js 调用 eventsMixin,往 Vue.prototype 挂载相关事件方法
eventsMixin(Vue)
// event.js 定义了 eventsMixin 方法
export function eventsMixin (Vue: Class<Component>) {
// 事件订阅
Vue.prototype.$on = function (event: string | Array<string>, fn: Function): Component {}
// 事件订阅执行一次
Vue.prototype.$once = function (event: string, fn: Function): Component {}
// 事件退订
Vue.prototype.$off = function (event?: string | Array<string>, fn?: Function): Component {}
// 事件触发
Vue.prototype.$emit = function (event: string): Component {}
}
// event 是个 string,也可以是个 string 数组
// 说明可以一次性对多个事件,订阅同一个回调函数
Vue.prototype.$on = function (event: string | Array<string>, fn: Function): Component {
const vm: Component = this
if (Array.isArray(event)) {
for (let i = 0, l = event.length; i < l; i++) {
vm.$on(event[i], fn)
}
} else {
// 本质是就是对应 event,push 对应的 fn
(vm._events[event] || (vm._events[event] = [])).push(fn)
// 以下先不展开,关于 hookEvent 的调用说明
// optimize hook:event cost by using a boolean flag marked at registration
// instead of a hash lookup
if (hookRE.test(event)) {
vm._hasHookEvent = true
}
}
return vm
}
// 包装一层 on,内包含退订操作以及调用操作
// 订阅的是包装后的 on 回调函数
Vue.prototype.$once = function (event: string, fn: Function): Component {
const vm: Component = this
function on () {
vm.$off(event, on)
fn.apply(vm, arguments)
}
on.fn = fn
vm.$on(event, on)
return vm
}
Vue.prototype.$off = function (event?: string | Array<string>, fn?: Function): Component {
const vm: Component = this
// 没有传参数,说明全部事件退订,直接清空
if (!arguments.length) {
vm._events = Object.create(null)
return vm
}
// 存在 event 数组,遍历逐一调用自己
if (Array.isArray(event)) {
for (let i = 0, l = event.length; i < l; i++) {
vm.$off(event[i], fn)
}
return vm
}
// 以下情况为非数组事件名,为单一事件,则获取该事件对应订阅的 callbacks
const cbs = vm._events[event]
// 若 callbacks 为空,什么都不用做
if (!cbs) {
return vm
}
// 如果传入的 fn 为空,说明退订这个事件的所有 callbacks
if (!fn) {
vm._events[event] = null
return vm
}
// callbacks 不为空,并且 fn 不为空,则为退订某个 callback
let cb
let i = cbs.length
while (i--) {
cb = cbs[i]
// 订阅多次的 callback,都会被退订,一次退订所有相同的 callback
if (cb === fn || cb.fn === fn) {
cbs.splice(i, 1)
break
}
}
return vm
}
Vue.prototype.$emit = function (event: string): Component {
const vm: Component = this
if (process.env.NODE_ENV !== 'production') {
const lowerCaseEvent = event.toLowerCase()
if (lowerCaseEvent !== event && vm._events[lowerCaseEvent]) {
tip(
`Event "${lowerCaseEvent}" is emitted in component ` +
`${formatComponentName(vm)} but the handler is registered for "${event}". ` +
`Note that HTML attributes are case-insensitive and you cannot use ` +
`v-on to listen to camelCase events when using in-DOM templates. ` +
`You should probably use "${hyphenate(event)}" instead of "${event}".`
)
}
}
// 获取这个 event 的 callbacks 出来
let cbs = vm._events[event]
if (cbs) {
cbs = cbs.length > 1 ? toArray(cbs) : cbs
// 获取除了第一位,剩余的其他所有参数
const args = toArray(arguments, 1)
const info = `event handler for "${event}"`
// 遍历逐一触发
for (let i = 0, l = cbs.length; i < l; i++) {
// 以下暂不展开,这是 Vue 中对于方法调用错误异常的处理方案
invokeWithErrorHandling(cbs[i], vm, args, vm, info)
}
}
return vm
}
实现逻辑大致和 mitt,tiny-emitter 一致,也是 pubsub,整体思路都是维护一个 object 或者 Map,on 则是放到数组内,emit 则是循环遍历逐一触发,off 则是查找到对应的 handler 移除数组 TODO: