發(fā)布訂閱模式這個面試題該咋答
本文來自 @simonezhou 小姐姐投稿的第八期筆記。面試官常問發(fā)布訂閱、觀察者模式,我們?nèi)粘i_發(fā)也很常用。文章講述了
mitt、tiny-emitter、Vue eventBus這三個發(fā)布訂閱、觀察者模式相關(guān)的源碼。
源碼地址
-
mitt:https://github.com/developit/mitt -
tiny-emitter:https://github.com/scottcorgan/tiny-emitter
1. mitt 源碼解讀
1.1 package.json 項目 build 打包(運(yùn)用到包暫不深究,保留個印象即可)
執(zhí)行 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",
...
},
-
使用 npm-run-all(A CLI tool to run multiple npm-scripts in parallel or sequential:https://www.npmjs.com/package/npm-run-all) 命令執(zhí)行
-
clean 命令,使用 rimraf(The UNIX command rm -rf for node. https://www.npmjs.com/package/rimraf)刪除 dist 文件路徑 -
bundle 命令,使用 microbundle(The zero-configuration bundler for tiny modules, powered by Rollup. https://www.npmjs.com/package/microbundle) 進(jìn)行打包 -
microbundle 命令指定 format: es, cjs, umd, package.json 指定 soucre 字段為打包入口 js:
{
"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
},
...
}
1.2 如何調(diào)試查看分析?
使用 microbundle watch 命令,新增 script,執(zhí)行 npm run dev:
"dev": "microbundle watch -f es,cjs,umd"
對應(yīng)目錄新增入口,比如 test.js,執(zhí)行 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 });
對應(yīng)源碼 src/index.js 也依然可以加相關(guān)的 log 進(jìn)行查看,代碼變動后會觸發(fā)重新打包
1.3. TS 聲明
使用上可以(官方給的例子),比如定義 foo 事件,回調(diào)函數(shù)里面的參數(shù)要求是 string 類型,可以想象一下源碼 TS 是怎么定義的:
import mitt from 'mitt';
// key 為事件名,key 對應(yīng)屬性為回調(diào)函數(shù)的參數(shù)類型
type Events = {
foo: string;
bar?: number; // 對應(yīng)事件允許不傳參數(shù)
};
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) )
源碼內(nèi)關(guān)于 TS 定義(關(guān)鍵幾句):
export type EventType = string | symbol;
// Handler 為事件(除了*事件)回調(diào)函數(shù)定義
export type Handler<T = unknown> = (event: T) => void;
// WildcardHandler 為事件 * 回調(diào)函數(shù)定義
export type WildcardHandler<T = Record<string, unknown>> = (
type: keyof T, // keyof T,事件名
event: T[keyof T] // T[keyof T], 事件名對應(yīng)的回調(diào)函數(shù)入?yún)㈩愋?/span>
) => 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;
// 這句主要兼容無參數(shù)類型的事件,如果說事件對應(yīng)回調(diào)必須傳參,使用中如果未傳,那么會命中 never,如下圖
emit<Key extends keyof Events>(type: undefined extends Events[Key] ? Key : never): void;
}
以下是會報 TS 錯誤:
以下是正確的:
1.4 主邏輯
-
整體就是一個 function,輸入為事件 Map,輸出為 all 所有事件 Map,還有 on,emit,off 幾個關(guān)于事件方法:
export default function mitt<Events extends Record<EventType, unknown>>(
// 支持 all 初始化
all?: EventHandlerMap<Events>
): Emitter<Events> {
// 內(nèi)部維護(hù)了一個 Map(all),Key 為事件名,Value 為 Handler 回調(diào)函數(shù)數(shù)組
all = all || new Map();
return {
all, // 所有事件 & 事件對應(yīng)方法
emit, // 觸發(fā)事件
on, // 訂閱事件
off // 注銷事件
}
}
-
on 為【事件訂閱】,push 對應(yīng) Handler 到對應(yīng)事件 Map 的 Handler 回調(diào)函數(shù)數(shù)組內(nèi)(可熟悉下 Map 相關(guān)API https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Map):
on<Key extends keyof Events>(type: Key, handler: GenericEventHandler) {
// Map get 獲取
const handlers: Array<GenericEventHandler> | undefined = all!.get(type);
// 如果已經(jīng)初始化過的話,是個數(shù)組,直接 push 即可
if (handlers) {
handlers.push(handler);
}
// 如果第一次注冊事件,則 set 新的數(shù)組
else {
all!.set(type, [handler] as EventHandlerList<Events[keyof Events]>);
}
}
-
off 為【事件注銷】,從對應(yīng)事件 Map 的 Handlers 中,splice 掉:
off<Key extends keyof Events>(type: Key, handler?: GenericEventHandler) {
// Map get 獲取
const handlers: Array<GenericEventHandler> | undefined = all!.get(type);
// 如果有事件列表,則進(jìn)入,沒有則忽略
if (handlers) {
// 對 handler 事件進(jìn)行 splice 移出數(shù)組
// 這里是對找到的第一個 handler 進(jìn)行移出,所以如果訂閱了多次,只會去除第一個
// handlers.indexOf(handler) >>> 0,>>> 為無符號位移
// 關(guān)于網(wǎng)上對 >>> 用法說明: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);
}
// 如果不傳對應(yīng)的 Handler,則為清空事件對應(yīng)的所有訂閱
else {
all!.set(type, []);
}
}
}
-
emit 為【事件觸發(fā)】,讀取事件 Map 的 Handlers,循環(huán)逐一觸發(fā),如果訂閱了 * 全事件,則讀取 * 的 Handlers 逐一觸發(fā):
emit<Key extends keyof Events>(type: Key, evt?: Events[Key]) {
// 獲取對應(yīng) type 的 Handlers
let handlers = all!.get(type);
if (handlers) {
(handlers as EventHandlerList<Events[keyof Events]>)
.slice()
.map((handler) => {
handler(evt!);
});
}
// 獲取 * 對應(yīng)的 Handlers
handlers = all!.get('*');
if (handlers) {
(handlers as WildCardEventHandlerList<Events>)
.slice()
.map((handler) => {
handler(type, evt!);
});
}
}
為什么是使用 slice().map() ,而不是直接使用 forEach() 進(jìn)行觸發(fā)?具體可查看:https://github.com/developit/mitt/pull/109,具體可以拷貝相關(guān)代碼進(jìn)行調(diào)試,直接更換成 forEach 的話,針對以下例子所觸發(fā)的 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) // 觸發(fā) A,C 事件,B 會被漏掉
Emitter.emit('test', 32432) // 觸發(fā) B,C,這個是正確的
// 原因解釋:
// forEach 時,在 Handlers 循環(huán)過程中,同時觸發(fā)了 off 操作
// 按這個例子的話,A 是第一個被注冊的,所以第一個會被 slice 掉
// 因為 array 是引用類型,slice 之后,那么 B 函數(shù)就會變成第一個
// 但此時遍歷已經(jīng)到第二個了,所以 B 函數(shù)就會被漏掉執(zhí)行
// 解決方案:
// 所以對數(shù)組進(jìn)行 [].slice() 做一個淺拷貝,off 的 Handlers 與 當(dāng)前循環(huán)中的 Handlers 處理成不同一個
// [].slice.forEach() 效果其實也是一樣的,用 map 的話個人感覺不是很語義化
1.5 小結(jié)
-
TS keyof 的靈活運(yùn)用 -
undefined extends Events[Key] ? Key : never,為 TS 的條件類型(https://www.typescriptlang.org/docs/handbook/2/conditional-types.html) -
undefined extends Events[Key] ? Key : never,當(dāng)我們想要編譯器不捕獲當(dāng)前值或者類型時,我們可以返回 never類型。never 表示永遠(yuǎn)不存在的值的類型
// 來自 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 本身的類型
-
mitt 的事件回調(diào)函數(shù)參數(shù),只會有一個,而不是多個,如何兼容多個參數(shù)的情況,官方推薦是使用 object 的(object is recommended and powerful),這種設(shè)計擴(kuò)展性更高,更值得推薦。
2. tiny-emitter 源碼解讀
2.1 主邏輯
-
所有方法都是掛載在 E 的 prototype 內(nèi)的,總共暴露了 once,emit,off,on 四個事件的方法:
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 訂閱一次事件,當(dāng)被觸發(fā)一次后,就會被銷毀:
once: function (name, callback, ctx) {
var self = this;
// 構(gòu)造另一個回調(diào)函數(shù),調(diào)用完之后,銷毀該 callback
function listener () {
self.off(name, listener); // 銷毀
callback.apply(ctx, arguments); // 執(zhí)行
};
listener._ = callback
// on 函數(shù)返回 this,所以可以鏈?zhǔn)秸{(diào)用
return this.on(name, listener, ctx); // 訂閱這個構(gòu)造的回調(diào)函數(shù)
}
-
on 事件訂閱
on: function (name, callback, ctx) {
var e = this.e || (this.e = {});
// 單純 push 進(jìn)去,這里也沒有做去重,所以同一個回調(diào)函數(shù)可以被訂閱多次
(e[name] || (e[name] = [])).push({
fn: callback,
ctx: ctx
});
// 返回 this,可以鏈?zhǔn)秸{(diào)用
return this;
}
-
off 事件銷毀
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,對應(yīng)的事件 name 也可以被 delete
(liveEvents.length)
? e[name] = liveEvents
: delete e[name];
// 返回 this,可以鏈?zhǔn)秸{(diào)用
return this;
}
-
emit 事件觸發(fā)
emit: function (name) {
// 取除了第一位的剩余所有參數(shù)
var data = [].slice.call(arguments, 1);
// slice() 淺拷貝
var evtArr = ((this.e || (this.e = {}))[name] || []).slice();
var i = 0;
var len = evtArr.length;
// 循環(huán)逐個觸發(fā) handler,把 data 傳入其中
for (i; i < len; i++) {
evtArr[i].fn.apply(evtArr[i].ctx, data);
}
// 返回 this,可以鏈?zhǔn)秸{(diào)用
return this;
}
2.2 小結(jié)
-
return this,支持鏈?zhǔn)秸{(diào)用 -
emit 事件觸發(fā)時,[].slice.call(arguments, 1) 剔除第一個參數(shù),獲取到剩余的參數(shù)列表,再使用 apply 來調(diào)用 -
on 事件訂閱時,記錄的是 { fn, ctx },fn 為回調(diào)函數(shù),ctx 支持綁定上下文
3. mitt 與 tiny-emitter 對比
-
TS 靜態(tài)類型校驗上 mitt > tiny-emitter,開發(fā)更友好,對于回調(diào)函數(shù)參數(shù)的管理,tiny-emitter 支持多參數(shù)調(diào)用的,但是 mitt 提倡使用 object 管理,設(shè)計上感覺 mitt 更加友好以及規(guī)范 -
在 off 事件銷毀中,tiny-emitter 與 mitt 處理方式不同,tiny-emitter 會一次性銷毀所有相同的 callback,而 mitt 則只是銷毀第一個 -
mitt 不支持 once 方法,tiny-emitter 支持 once 方法 -
mitt 支持 * 全事件訂閱,tiny-emitter 則不支持
4. Vue eventBus 事件總線(3.x 已廢除,2.x 依然存在)
-
關(guān)于 events 的處理:https://github.com/vuejs/vue/blob/dev/src/core/instance/events.js -
事件相關(guān)初始化:https://github.com/vuejs/vue/blob/dev/src/core/instance/index.js
-
初始化過程
// index.js 調(diào)用 initMixin 方法,初始化 _events object
initMixin(Vue)
// event.js 定義 initEvents 方法
// vm._events 保存所有事件 & 事件回調(diào)函數(shù),是個 object
export function initEvents (vm: Component) {
vm._events = Object.create(null)
// ...
}
// index.js 調(diào)用 eventsMixin,往 Vue.prototype 掛載相關(guān)事件方法
eventsMixin(Vue)
// event.js 定義了 eventsMixin 方法
export function eventsMixin (Vue: Class<Component>) {
// 事件訂閱
Vue.prototype.$on = function (event: string | Array<string>, fn: Function): Component {}
// 事件訂閱執(zhí)行一次
Vue.prototype.$once = function (event: string, fn: Function): Component {}
// 事件退訂
Vue.prototype.$off = function (event?: string | Array<string>, fn?: Function): Component {}
// 事件觸發(fā)
Vue.prototype.$emit = function (event: string): Component {}
}
-
$on 事件訂閱
// event 是個 string,也可以是個 string 數(shù)組
// 說明可以一次性對多個事件,訂閱同一個回調(diào)函數(shù)
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 {
// 本質(zhì)是就是對應(yīng) event,push 對應(yīng)的 fn
(vm._events[event] || (vm._events[event] = [])).push(fn)
// 以下先不展開,關(guān)于 hookEvent 的調(diào)用說明
// 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
}
-
$once 事件訂閱&執(zhí)行一次
// 包裝一層 on,內(nèi)包含退訂操作以及調(diào)用操作
// 訂閱的是包裝后的 on 回調(diào)函數(shù)
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
}
-
$off 事件退訂
Vue.prototype.$off = function (event?: string | Array<string>, fn?: Function): Component {
const vm: Component = this
// 沒有傳參數(shù),說明全部事件退訂,直接清空
if (!arguments.length) {
vm._events = Object.create(null)
return vm
}
// 存在 event 數(shù)組,遍歷逐一調(diào)用自己
if (Array.isArray(event)) {
for (let i = 0, l = event.length; i < l; i++) {
vm.$off(event[i], fn)
}
return vm
}
// 以下情況為非數(shù)組事件名,為單一事件,則獲取該事件對應(yīng)訂閱的 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
}
-
$emit 事件觸發(fā)
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
// 獲取除了第一位,剩余的其他所有參數(shù)
const args = toArray(arguments, 1)
const info = `event handler for "${event}"`
// 遍歷逐一觸發(fā)
for (let i = 0, l = cbs.length; i < l; i++) {
// 以下暫不展開,這是 Vue 中對于方法調(diào)用錯誤異常的處理方案
invokeWithErrorHandling(cbs[i], vm, args, vm, info)
}
}
return vm
}
實現(xiàn)邏輯大致和 mitt,tiny-emitter 一致,也是 pubsub,整體思路都是維護(hù)一個 object 或者 Map,on 則是放到數(shù)組內(nèi),emit 則是循環(huán)遍歷逐一觸發(fā),off 則是查找到對應(yīng)的 handler 移除數(shù)組
TODO:
-
Vue 中對于方法調(diào)用錯誤異常的處理方案:invokeWithErrorHandling -
hookEvent 的使用&原理
5. 附錄
-
rimraf:https://www.npmjs.com/package/rimraf -
microbundle:https://www.npmjs.com/package/microbundle -
package.json exports 字段:https://nodejs.org/api/packages.html#packages_conditional_exports -
Map:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Map -
TS 條件類型:https://www.typescriptlang.org/docs/handbook/2/conditional-types.html -
TS Never:https://www.typescriptlang.org/docs/handbook/basic-types.html#never -
TS keyof: https://www.typescriptlang.org/docs/handbook/2/keyof-types.html#the-keyof-type-operator -
What is the JavaScript >>> operator and how do you use it? https://stackoverflow.com/questions/1822350/what-is-the-javascript-operator-and-how-do-you-use-it
