『 Vue小Case 』- 如何動態綁定多個事件(內附源碼解析)

本文閱讀時間約為 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-event1my-event2my-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 中$onv-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修飾符的,這時候會維護兩個事件數組eventsnativeEvents(源碼中應該是onnativeOn),最後用於綁定原生事件和自定義事件,而v-on={...}用法不會處理native修飾符,最後只會根據元素類型來綁定事件,所以** 該方式用在 DOM 原生元素上時,只支援原生事件;用在組件上時,只支援自定義事件**。

四、總結

今天我們討論了如何在 Vue 中動態綁定多個事件。主要使用以下兩種方式:

  1. 通過vm.$on實例方法進行實現:通過forEach可以實現不同事件不同函數的綁定;通過數組參數可以實現不同事件同一函數,並且數組可以是多維數組。該方式有一個局限,即只能支援組件的自定義事件。 此外,$on/$off/$emit/$once介面返回值仍為 vm 實例,所以可以鏈式調用
  2. 通過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