『 Vue小Case 』- 如何動態綁定多個事件(內附源碼解析)
- 2020 年 4 月 7 日
- 筆記
本文閱讀時間約為 16 分鐘,其中有一段苦澀的程式碼,如懶得看的話,可直接跳至最後一部分查收總結。
最近遇到這樣一個需求,需要在抽象出來的組件上綁定用戶傳入的事件及其處理函數,並且事件名、數量不定,也就是動態綁定多個事件。印象中,文檔中沒有提到過類似的用法。所以 Google 一下。
然後就遇到了下面這樣一個可愛的故事。
一、「可愛」的故事
在搜索的過程中,看到了這樣一條結果「初學 vue,請問怎麼在元素上綁定多個事件」[1],並且還是 Vue 的 Issue,那我當然得優先看看了。Issue 中具體的內容如下:

透過螢幕感受到了尤雨溪大佬的一絲絲嚴厲。心疼小哥 3 秒,不知道會不會因此想過放棄 Vue,放棄前端 ?。
不過大佬就是要這麼有威嚴不是嘛。嚴厲的同時還不忘給我們指一條「明路」。
我們可以按照圖中的方式試一下(示例 1[2]),會發現好像並不可行。這是為什麼呢?當然不是說大佬給我們「瞎指路」,這其實應該是某個版本迭代中支援的功能,只不過在現在的版本中不支援了(示例中試了 1.0,2.0 好像也不行),現在的版本中會有新的寫法,具體內容下面會詳述。
好了,可愛的故事到此結束,下面我們一起討論下如何實現動態綁定多個事件。
二、如何動態綁定多個事件
2.1 使用vm.$on
實現
vm.$on
大家一定都用過,其用法如下:vm.$on( event, callback )
,其中event
參數不僅可以是個字元串,還可以是個事件名稱組成的數組。
所以藉助vm.$on
,我們可以通過如下的方式(示例 2[3])實現動態綁定多個事件。
new Vue({ el: '#container', mounted: function() { const eventMaps = { 'my-event1': this.eventHandler, 'my-event2': this.eventHandler, } // 通過 forEach 遍歷綁定多個事件 Object.keys(eventMaps).forEach((event) => { this.$on(event, eventMaps[event]) }) // vm.$on 傳遞數組,綁定多個事件 this.$on(['my-event3', 'my-event4'], this.eventHandler) this.triggerEvents() }, methods: { eventHandler(eventName) { console.log(eventName + ' 事件被觸發!') }, // 不同時間間隔觸發多個事件 triggerEvents() { setTimeout(() => { this.$emit('my-event1', 'my-event1') }, 1000) setTimeout(() => { this.$emit('my-event2', 'my-event2') }, 2000) setTimeout(() => { this.$emit('my-event3', 'my-event3') this.$emit('my-event4', 'my-event4') }, 3000) } } })
上述程式碼中,我們可以通過forEach
的方式循環遍歷來綁定多個不同的事件及處理函數。
此外在 Vue 2.2.0+版本,還可以通過給vm.$on
傳遞數組參數為多個不同的事件綁定同一個處理函數。注意, 這種方式有個限制,只能綁定同一個處理函數。
運行上述程式碼,會依次(1s/2s/3s)觸發my-event1
、my-event2
、my-event3/my-event4
事件。
最後有一點需要注意,這一方式有一個局限,即該方式只能用於綁定自定義事件,不支援原生的 DOM 事件。如果你想眼見為實的話,那就點一下試試吧(示例 3[4]),你會發現通過this.$on(['click', 'mouseover'], this.eventHandler)
並不會被觸發。
文檔里有提到vm.$on
不支援原生事件,這主要是因為$on/$off/$emit
這一套介面,是 Vue 本身實現的事件處理機制,只能用來處理組件的自定義事件。第三部分我也會帶領大家看一下源碼中關於這一部分的實現。
2.2 使用v-on
指令實現
如果只是實現動態綁定事件,大家應該都知道,文檔[5]里也有提到。從 Vue 2.6.0 開始,可以通過如下的方式<a v-on:[eventName]="doSomething"> ... </a>
為一個動態的事件名綁定處理函數。
但是如果想要動態綁定多個事件及處理函數應該如何實現呢?
其實和v-bind
綁定全部對象屬性類似(只不過文檔里沒提到,不知道是為啥),我們可以通過如下方式v-on="{event1: callback, event2: callback, ...}"
同時綁定多個事件及處理函數(與第一部分提到的「明路」類似)。示例程式碼如下(示例 4[6]):
HTML:
<div id="container" v-on="eventMaps"> 動態綁定多個事件 </div>
JavaScript:
new Vue({ el: '#container', computed: { eventMaps() { return { 'click': this.clickHandler, 'mouseover': this.mouseoverHandler, 'my-event1': this.eventHandler, } } }, mounted: function() { this.triggerEvents() }, methods: { clickHandler(eventName) { console.log('原生 click 事件被觸發!') }, eventHandler(eventName) { console.log(eventName + ' 事件被觸發!') }, mouseoverHandler(eventName) { console.log('原生 mouseover 事件被觸發!') }, triggerEvents() { setTimeout(() => { console.log('主動觸發my-event1事件') this.$emit('my-event1', 'my-event1') }, 5000) } } })
運行一下,我們會發現兩個原生事件都會被監聽處理。而通過這種方式綁定了一個自定義事件,主動觸發事件後,事件並沒有被處理。通過這一現象,似乎可以得出結論通過v-on={...}
綁定多個事件時,不支援組件自定義事件。但其實並不是這樣。
通過v-on={...}
綁定多個事件時,如果是在 DOM 元素上綁定,則只支援原生事件,不支援自定義事件;如果是在 Vue 組件上綁定,則只支援自定義事件,不支援原生事件。如下所示(示例 5[7]),當是在自定義組件上綁定事件時,不支援原生事件。
到這裡就比較尷尬了,Vue 原生支援的兩種方式都不能很好地滿足需求,vm.$on
不支援原生 DOM 事件,v-on={...}
綁定多事件時,會因為宿主元素的不同有不同的限制。
此外v-on={...}
這種用法綁定的時候是不可以使用修飾符,否則會有如下警告:[Vue warn]: v-on without argument does not support modifiers.
。但是對於原生事件,我們有著一些很便捷的修飾符可以使用,這種情況下又該如何使用呢?
下面,我們通過 Vue 的源碼一起來分析下這些問題。
三、Vue 中$on
及v-on
的實現
3.1 $on
、$emit
、$off
以及$once
的實現
如果你對於 Node 中 EventEmitter 或者其他事件機制的實現邏輯有過了解,那麼對於這四個實例方法的實現一定不會陌生。它們就是基於常見的發布訂閱模式實現的。下面我們分別看下它們的實現。
3.1.1 $on
的實現
我們先來看 Vue 中$on
的實現,部分程式碼如下:
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 { (vm._events[event] || (vm._events[event] = [])).push(fn) // 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 }
可以看到else
中的部分,vm 實例上有一個_events
對象,其中的值為$on
所監聽的事件及其處理函數數組。當事件對應的屬性不存在時,新建一個空數組,將新的處理函數推入;存在時,直接推入新的處理函數。
如果參數是數組,則遞歸一下。也就是說使用$on
傳遞數組參數時,我們還可以傳多維數組,感興趣的同學可以自己試一下(示例 6[8])。
Tips:
$on
、$emit
、$off
以及$once
返回的都還是 vm 示例,所以還可以鏈式調用!
3.1.2 $emit
的實現
$emit
的部分程式碼如下:
Vue.prototype.$emit = function (event: string): Component { const vm: Component = this // 其他非核心邏輯 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++) { invokeWithErrorHandling(cbs[i], vm, args, vm, info) } } return vm }
這一段程式碼的核心邏輯就是獲取$on
中事件所對應的處理函數數組,如果存在,則依次調用數組中的處理函數。
3.1.3 $off
的實現
$off
的部分程式碼如下:
這段程式碼較長,解釋請直接看程式碼里的注釋
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 } // 如果事件名是個數組,則遞歸$off。與$on中類似,所以可以多維數組 if (Array.isArray(event)) { for (let i = 0, l = event.length; i < l; i++) { vm.$off(event[i], fn) } return vm } // 以下情況為指定了特定事件的處理 const cbs = vm._events[event] // 如果事件本身就沒有處理函數,則直接返回 if (!cbs) { return vm } // 如果沒有指定要移除的處理函數,則直接清空該事件的所有處理函數 if (!fn) { vm._events[event] = null return vm } // 如果指定了處理函數,則在事件對應的處理函數中找到該處理函數,移出數組 let cb let i = cbs.length while (i--) { cb = cbs[i] // 這裡的cb.fn是為了兼容$once中的用法 if (cb === fn || cb.fn === fn) { cbs.splice(i, 1) break } } return vm }
3.1.4 $once
的實現
$once
的實現邏輯如下:
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 }
其實$once
的實現邏輯也比較簡單,封裝了一個on
的函數,然後在內部調用的時候會執行一次$off
,從而實現調用一次就註銷事件。
最後解釋下vm.$on
中的事件修飾符,因為除once
外的修飾符都只能用於原生的 DOM 事件,而vm.$on
不支援原生 DOM 事件,所以不會有相關實現,僅僅實現了可以支援自定義事件的once
。
3.2 v-on="{...}"
的實現邏輯
本文要討論的是
v-on="{...}"
實現綁定多事件的邏輯,但因為實現多事件的邏輯和常規的v-on:event
用法是兩個不同的邏輯分支,本文只討論多事件的邏輯。如果對於常規用法感興趣的話,可以參考一下韭菜[9]的《深入剖析 Vue 源碼 – 揭秘 Vue 的事件機制》[10]一文。
3.2.1 模板編譯收集v-on
指令
與常規的v-on:eventName
類似,不帶事件名的v-on="{...}"
也會在模板編譯時候進行處理收集。
在源碼中的src/compiler/parser
中的processAttrs
函數中,有如下一段邏輯:
// 是否是指令 export const dirRE = process.env.VBIND_PROP_SHORTHAND ? /^v-|^@|^:|^.|^#/ : /^v-|^@|^:|^#/ // v-on及其簡寫的正則 export const onRE = /^@|^v-on:/ // v-bind及其簡寫的正則 export const bindRE = /^:|^.|^v-bind:/ // 處理屬性 function processAttrs (el) { const list = el.attrsList let i, l, name, rawName, value, modifiers, syncGen, isDynamic for (i = 0, l = list.length; i < l; i++) { name = rawName = list[i].name value = list[i].value // 是否是指令屬性 if (dirRE.test(name)) { // ... // v-bind 處理 if (bindRE.test(name)) { // ... // 常規v-on 處理 } else if (onRE.test(name)) { // ... 參考上面提到的文章,本文重點不在這裡 // v-on動態綁定多事件比較特殊,會按照通用指令來處理 } else { name = name.replace(dirRE, '') // parse arg const argMatch = name.match(argRE) let arg = argMatch && argMatch[1] isDynamic = false if (arg) { name = name.slice(0, -(arg.length + 1)) if (dynamicArgRE.test(arg)) { arg = arg.slice(1, -1) isDynamic = true } } // *** 重點在這裡 *** addDirective(el, name, rawName, value, arg, isDynamic, modifiers, list[i]) // ... } } else { // 常規屬性處理邏輯 } } }
如上程式碼,通過v-on
動態綁定多事件時,在 Vue 的處理邏輯中,是被當做一般指令來處理的,最後會調用addDirective
方法。此時value
的值仍是對象字面量的字元串。
3.2.2 on 指令的邏輯
調用addDirective
之後,會把v-on="{...}"
這一用法當做普通指令,我們找到src/compiler/directives/on.js
。其程式碼如下:
export default function on (el: ASTElement, dir: ASTDirective) { // 不可以使用修飾符,否則會有如下警告: if (process.env.NODE_ENV !== 'production' && dir.modifiers) { warn(`v-on without argument does not support modifiers.`) } el.wrapListeners = (code: string) => `_g(${code},${dir.value})` }
核心內容是_g
函數,所以我們再次找到_g
對應的函數bindObjectListeners
(在src/core/instance/render-helpers/index.js
中有對應關係),其內部具體邏輯如下:
export function bindObjectListeners (data: any, value: any): VNodeData { // 這時value已經被轉成對象字面量了,而不是字元串了。 if (value) { // 如果不是對象字面量會報錯 if (!isPlainObject(value)) { process.env.NODE_ENV !== 'production' && warn( 'v-on without argument expects an Object value', this ) } else { // 處理對象,將其加入到data.on中記錄下來 const on = data.on = data.on ? extend({}, data.on) : {} for (const key in value) { const existing = on[key] const ours = value[key] on[key] = existing ? [].concat(existing, ours) : ours } } } return data }
3.2.3 updateListeners
上一步中,收集到的data.on
,最後會在 VNode 的生命周期中被updateListeners
消費,該函數的核心邏輯如下:
export function updateListeners ( on: Object, oldOn: Object, add: Function, remove: Function, createOnceHandler: Function, vm: Component ) { let name, def, cur, old, event for (name in on) { def = cur = on[name] old = oldOn[name] event = normalizeEvent(name) // 如果處理函數未定義,則警告 if (isUndef(cur)) { process.env.NODE_ENV !== 'production' && warn( `Invalid handler for event "${event.name}": got ` + String(cur), vm ) // 如果不存在舊的處理函數 } else if (isUndef(old)) { if (isUndef(cur.fns)) { cur = on[name] = createFnInvoker(cur, vm) } if (isTrue(event.once)) { cur = on[name] = createOnceHandler(event.name, cur, event.capture) } add(event.name, cur, event.capture, event.passive, event.params) // 如果存在舊的處理函數的處理邏輯 } else if (cur !== old) { old.fns = cur on[name] = old } } for (name in oldOn) { if (isUndef(on[name])) { event = normalizeEvent(name) remove(event.name, oldOn[name], event.capture) } } }
函數中有一個normalizeEvent
需要關注一下,該方法會通過名稱解析出來部分修飾符,分別是passive/once/capture
。為什麼會只有這幾個修飾符呢,應該是因為這幾個修飾符是在處理函數中通過程式碼無法實現的。
下面我們看下具體的函數邏輯:
const normalizeEvent = cached((name: string): { name: string, once: boolean, capture: boolean, passive: boolean, handler?: Function, params?: Array<any> } => { const passive = name.charAt(0) === '&' name = passive ? name.slice(1) : name const once = name.charAt(0) === '~' // Prefixed last, checked first name = once ? name.slice(1) : name const capture = name.charAt(0) === '!' name = capture ? name.slice(1) : name return { name, once, capture, passive } })
從程式碼可以看出,passive
是事件名前加&
,once
是事件名前加~
,capture
是事件名前加!
,並且三個值會有如上的順序關係。
如果我們需要添加這三個修飾符,可以通過類似這樣的方式添加v-on="{'!click': addTodo, focus: addTodo}"
。至於其他的stop/prevent
等其他修飾符,則需要在處理函數內部進行實現。
最後說下原生事件和自定義事件的問題,常規的v-on:event
用法是會處理native
修飾符的,這時候會維護兩個事件數組events
和nativeEvents
(源碼中應該是on
和nativeOn
),最後用於綁定原生事件和自定義事件,而v-on={...}
用法不會處理native
修飾符,最後只會根據元素類型來綁定事件,所以** 該方式用在 DOM 原生元素上時,只支援原生事件;用在組件上時,只支援自定義事件**。
四、總結
今天我們討論了如何在 Vue 中動態綁定多個事件。主要使用以下兩種方式:
- 通過
vm.$on
實例方法進行實現:通過forEach
可以實現不同事件不同函數的綁定;通過數組參數可以實現不同事件同一函數,並且數組可以是多維數組。該方式有一個局限,即只能支援組件的自定義事件。 此外,$on/$off/$emit/$once
介面返回值仍為 vm 實例,所以可以鏈式調用 - 通過
v-on="{...}"
實現,該方式用在 DOM 原生元素上時,只支援原生事件;用在組件上時,只支援自定義事件。可以通過「`passive`是事件名前加`&`,`once `是事件名前加`~`,
capture
是事件名前加!
」的方式支援passive/once/capture
(有順序要求),其他修飾符需要在處理函數內手動實現。
以上就是我們今天要講的兩種動態綁定事件的方式,其中第二種方式已經能夠滿足我們的大部分使用需求。
如果仍舊覺得不滿足需求,可以試試用自定義指令來實現,筆者有空也會再來一篇。
參考資料
[1]「初學 vue,請問怎麼在元素上綁定多個事件」: https://github.com/vuejs/vue/issues/1050
[2]示例 1: https://jsbin.com/wegomutele/1/edit?html,js,output
[3]示例 2: https://jsbin.com/juvowisedi/1/edit?html,js,output
[4]示例 3: https://jsbin.com/vewafuxeya/1/edit?html,js,output
[5]文檔: https://cn.vuejs.org/v2/guide/syntax.html#%E5%8A%A8%E6%80%81%E5%8F%82%E6%95%B0
[6]示例 4: https://jsbin.com/nayiyomayi/edit?html,js,output
[7]示例 5: https://jsbin.com/yevejofasa/6/edit?html,js,output
[8]示例 6: https://jsbin.com/zoporitugu/1/edit?html,js,output
[9]韭菜: https://juejin.im/user/5865c0921b69e6006b3145a1
[10]《深入剖析 Vue 源碼 – 揭秘 Vue 的事件機制》: https://juejin.im/post/5d5a5dbd6fb9a06acc0084dd