編者按:本文作者高峰,360奇舞團前端工程師,W3C性能工作組/WOT工作組成員。
本文閱讀時間約為 16 分鐘,其中有一段苦澀的代碼,如懶得看的話,可直接跳至最後一部分查收總結。
最近遇到這樣一個需求,需要在抽象出來的元件上綁定使用者傳入的事件及其處理函數,并且事件名、數量不定,也就是動态綁定多個事件。印象中,文檔中沒有提到過類似的用法。是以 Google 一下。
然後就遇到了下面這樣一個可愛的故事。
一、“可愛”的故事
在搜尋的過程中,看到了這樣一條結果“初學 vue,請問怎麼在元素上綁定多個事件”[1],并且還是 Vue 的 Issue,那我當然得優先看看了。Issue 中具體的内容如下:
透過螢幕感受到了尤雨溪大佬的一絲絲嚴厲。心疼小哥 3 秒,不知道會不會是以想過放棄 Vue,放棄前端 ?。
不過大佬就是要這麼有威嚴不是嘛。嚴厲的同時還不忘給我們指一條“明路”。
我們可以按照圖中的方式試一下(示例 1[2]),會發現好像并不可行。這是為什麼呢?當然不是說大佬給我們“瞎指路”,這其實應該是某個版本疊代中支援的功能,隻不過在現在的版本中不支援了(示例中試了 1.0,2.0 好像也不行),現在的版本中會有新的寫法,具體内容下面會詳述。
好了,可愛的故事到此結束,下面我們一起讨論下如何實作動态綁定多個事件。
二、如何動态綁定多個事件
2.1 使用 vm.$on
實作
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
指令實作
v-on
如果隻是實作動态綁定事件,大家應該都知道,文檔[5]裡也有提到。從 Vue 2.6.0 開始,可以通過如下的方式
...
為一個動态的事件名綁定處理函數。
但是如果想要動态綁定多個事件及處理函數應該如何實作呢?
其實和
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
的實作
$on
v-on
3.1 $on
、 $emit
、 $off
以及 $once
的實作
$on
$emit
$off
$once
如果你對于 Node 中 EventEmitter 或者其他事件機制的實作邏輯有過了解,那麼對于這四個執行個體方法的實作一定不會陌生。它們就是基于常見的釋出訂閱模式實作的。下面我們分别看下它們的實作。
3.1.1 $on
的實作
$on
我們先來看 Vue 中
$on
的實作,部分代碼如下:
Vue.prototype.$on = function (event: string | Array, 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
傳回的都還是 vm 示例,是以還可以鍊式調用!
$once
3.1.2 $emit
的實作
$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
$off
的部分代碼如下:
這段代碼較長,解釋請直接看代碼裡的注釋
Vue.prototype.$off = function (event?: string | Array, 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
$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="{...}"
用法是兩個不同的邏輯分支,本文隻讨論多事件的邏輯。如果對于正常用法感興趣的話,可以參考一下韭菜[9]的《深入剖析 Vue 源碼 - 揭秘 Vue 的事件機制》[10]一文。
v-on:event
3.2.1 模闆編譯收集 v-on
指令
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
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
} => {const passive = name.charAt(0) === '&'
name = passive ? name.slice(1) : nameconst once = name.charAt(0) === '~' // Prefixed last, checked first
name = once ? name.slice(1) : nameconst capture = name.charAt(0) === '!'
name = capture ? name.slice(1) : namereturn {
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
可以實作不同僚件不同函數的綁定;通過數組參數可以實作不同僚件同一函數,并且數組可以是多元數組。該方式有一個局限,即隻能支援元件的自定義事件。
此外,
接口傳回值仍為 vm 執行個體,是以可以鍊式調用$on/$off/$emit/$once
- 通過
實作,該方式用在 DOM 原生元素上時,隻支援原生事件;用在元件上時,隻支援自定義事件。可以通過“v-on="{...}"
是事件名前加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