『 Vue小Case 』- 动态绑定多个事件(内附源码解析)

2,606 阅读10分钟

本文阅读时间约为 16 分钟,其中有一段苦涩的代码,如懒得看的话,可直接跳至最后一部分查收总结。

最近遇到这样一个需求,需要在抽象出来的组件上绑定用户传入的事件及其处理函数,并且事件名、数量不定,也就是动态绑定多个事件。印象中,文档中没有提到过类似的用法。所以 Google 一下。

然后就遇到了下面这样一个可爱的故事。

一、“可爱”的故事

在搜索的过程中,看到了这样一条结果“初学 vue,请问怎么在元素上绑定多个事件”,并且还是 Vue 的 Issue,那我当然得优先看看了。Issue 中具体的内容如下:

透过屏幕感受到了尤雨溪大佬的一丝丝严厉。心疼小哥 3 秒,不知道会不会因此想过放弃 Vue,放弃前端 😂。

不过大佬就是要这么有威严不是嘛。严厉的同时还不忘给我们指一条“明路”。

我们可以按照图中的方式试一下(示例 1),会发现好像并不可行。这是为什么呢?当然不是说大佬给我们“瞎指路”,这其实应该是某个版本迭代中支持的功能,只不过在现在的版本中不支持了(示例中试了 1.0,2.0 好像也不行),现在的版本中会有新的写法,具体内容下面会详述。

好了,可爱的故事到此结束,下面我们一起讨论下如何实现动态绑定多个事件。

二、如何动态绑定多个事件

2.1 使用vm.$on实现

vm.$on大家一定都用过,其用法如下:vm.$on( event, callback ),其中event参数不仅可以是个字符串,还可以是个事件名称组成的数组

所以借助vm.$on,我们可以通过如下的方式(示例 2)实现动态绑定多个事件。

new Vue({
  el: '#container',
  mounted: function() {
    const eventMaps = {
      'my-event1': this.eventHandler,
      'my-event2': this.eventHandler,
    }
<span class="hljs-comment" style="color: #007400; line-height: 26px;">// 通过 forEach 遍历绑定多个事件</span>
<span class="hljs-built_in" style="color: #5c2699; line-height: 26px;">Object</span>.keys(eventMaps).forEach(<span class="hljs-function" style="line-height: 26px;">(<span class="hljs-params" style="color: #5c2699; line-height: 26px;">event</span>) =&gt;</span> {
  <span class="hljs-keyword" style="color: #aa0d91; line-height: 26px;">this</span>.$on(event, eventMaps[event])
})

<span class="hljs-comment" style="color: #007400; line-height: 26px;">// vm.$on 传递数组,绑定多个事件</span>
<span class="hljs-keyword" style="color: #aa0d91; line-height: 26px;">this</span>.$on([<span class="hljs-string" style="color: #c41a16; line-height: 26px;">'my-event3'</span>, <span class="hljs-string" style="color: #c41a16; line-height: 26px;">'my-event4'</span>], <span class="hljs-keyword" style="color: #aa0d91; line-height: 26px;">this</span>.eventHandler)

<span class="hljs-keyword" style="color: #aa0d91; line-height: 26px;">this</span>.triggerEvents()

}, methods: { eventHandler(eventName) { console.log(eventName + ' 事件被触发!') }, // 不同时间间隔触发多个事件 triggerEvents() { setTimeout(() => { this.$emit('my-event1', 'my-event1') }, 1000)

  setTimeout(<span class="hljs-function" style="line-height: 26px;"><span class="hljs-params" style="color: #5c2699; line-height: 26px;">()</span> =&gt;</span> {
    <span class="hljs-keyword" style="color: #aa0d91; line-height: 26px;">this</span>.$emit(<span class="hljs-string" style="color: #c41a16; line-height: 26px;">'my-event2'</span>, <span class="hljs-string" style="color: #c41a16; line-height: 26px;">'my-event2'</span>)
  }, <span class="hljs-number" style="color: #1c00cf; line-height: 26px;">2000</span>)

  setTimeout(<span class="hljs-function" style="line-height: 26px;"><span class="hljs-params" style="color: #5c2699; line-height: 26px;">()</span> =&gt;</span> {
    <span class="hljs-keyword" style="color: #aa0d91; line-height: 26px;">this</span>.$emit(<span class="hljs-string" style="color: #c41a16; line-height: 26px;">'my-event3'</span>, <span class="hljs-string" style="color: #c41a16; line-height: 26px;">'my-event3'</span>)
    <span class="hljs-keyword" style="color: #aa0d91; line-height: 26px;">this</span>.$emit(<span class="hljs-string" style="color: #c41a16; line-height: 26px;">'my-event4'</span>, <span class="hljs-string" style="color: #c41a16; line-height: 26px;">'my-event4'</span>)
  }, <span class="hljs-number" style="color: #1c00cf; line-height: 26px;">3000</span>)
}

} })

上述代码中,我们可以通过forEach的方式循环遍历来绑定多个不同的事件及处理函数

此外在 Vue 2.2.0+版本,还可以通过给vm.$on传递数组参数为多个不同的事件绑定同一个处理函数。注意, 这种方式有个限制,只能绑定同一个处理函数

运行上述代码,会依次(1s/2s/3s)触发my-event1my-event2my-event3/my-event4事件。

最后有一点需要注意,这一方式有一个局限,即该方式只能用于绑定自定义事件,不支持原生的 DOM 事件。如果你想眼见为实的话,那就点一下试试吧(示例 3),你会发现通过this.$on(['click', 'mouseover'], this.eventHandler)并不会被触发。

文档里有提到vm.$on不支持原生事件,这主要是因为$on/$off/$emit这一套接口,是 Vue 本身实现的事件处理机制,只能用来处理组件的自定义事件。第三部分我也会带领大家看一下源码中关于这一部分的实现。

2.2 使用v-on指令实现

如果只是实现动态绑定事件,大家应该都知道,文档里也有提到。从 Vue 2.6.0 开始,可以通过如下的方式<a v-on:[eventName]="doSomething"> ... </a>为一个动态的事件名绑定处理函数。

但是如果想要动态绑定多个事件及处理函数应该如何实现呢?

其实和v-bind绑定全部对象属性类似(只不过文档里没提到,不知道是为啥),我们可以通过如下方式v-on="{event1: callback, event2: callback, ...}"同时绑定多个事件及处理函数(与第一部分提到的“明路”类似)。示例代码如下(示例 4):

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),当是在自定义组件上绑定事件时,不支持原生事件。

到这里就比较尴尬了,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)。

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)
    }
    <span class="hljs-keyword" style="color: #aa0d91; line-height: 26px;">return</span> vm
  }
  <span class="hljs-comment" style="color: #007400; line-height: 26px;">// 以下情况为指定了特定事件的处理</span>
  <span class="hljs-keyword" style="color: #aa0d91; line-height: 26px;">const</span> cbs = vm._events[event]
  <span class="hljs-comment" style="color: #007400; line-height: 26px;">// 如果事件本身就没有处理函数,则直接返回</span>
  <span class="hljs-keyword" style="color: #aa0d91; line-height: 26px;">if</span> (!cbs) {
    <span class="hljs-keyword" style="color: #aa0d91; line-height: 26px;">return</span> vm
  }
  <span class="hljs-comment" style="color: #007400; line-height: 26px;">// 如果没有指定要移除的处理函数,则直接清空该事件的所有处理函数</span>
  <span class="hljs-keyword" style="color: #aa0d91; line-height: 26px;">if</span> (!fn) {
    vm._events[event] = <span class="hljs-literal" style="color: #aa0d91; line-height: 26px;">null</span>
    <span class="hljs-keyword" style="color: #aa0d91; line-height: 26px;">return</span> vm
  }
  <span class="hljs-comment" style="color: #007400; line-height: 26px;">// 如果指定了处理函数,则在事件对应的处理函数中找到该处理函数,移出数组</span>
  <span class="hljs-keyword" style="color: #aa0d91; line-height: 26px;">let</span> cb
  <span class="hljs-keyword" style="color: #aa0d91; line-height: 26px;">let</span> i = cbs.length
  <span class="hljs-keyword" style="color: #aa0d91; line-height: 26px;">while</span> (i--) {
    cb = cbs[i]
    <span class="hljs-comment" style="color: #007400; line-height: 26px;">// 这里的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用法是两个不同的逻辑分支,本文只讨论多事件的逻辑。如果对于常规用法感兴趣的话,可以参考一下韭菜《深入剖析 Vue 源码 - 揭秘 Vue 的事件机制》一文。

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

<span class="hljs-comment" style="color: #007400; line-height: 26px;">// 是否是指令属性</span>
<span class="hljs-keyword" style="color: #aa0d91; line-height: 26px;">if</span> (dirRE.test(name)) {
  <span class="hljs-comment" style="color: #007400; line-height: 26px;">// ...</span>

  <span class="hljs-comment" style="color: #007400; line-height: 26px;">// v-bind 处理</span>
  <span class="hljs-keyword" style="color: #aa0d91; line-height: 26px;">if</span> (bindRE.test(name)) {
    <span class="hljs-comment" style="color: #007400; line-height: 26px;">// ...</span>
  <span class="hljs-comment" style="color: #007400; line-height: 26px;">// 常规v-on 处理</span>
  } <span class="hljs-keyword" style="color: #aa0d91; line-height: 26px;">else</span> <span class="hljs-keyword" style="color: #aa0d91; line-height: 26px;">if</span> (onRE.test(name)) {
    <span class="hljs-comment" style="color: #007400; line-height: 26px;">// ... 参考上面提到的文章,本文重点不在这里</span>
  <span class="hljs-comment" style="color: #007400; line-height: 26px;">// v-on动态绑定多事件比较特殊,会按照通用指令来处理</span>
  } <span class="hljs-keyword" style="color: #aa0d91; line-height: 26px;">else</span> {
    name = name.replace(dirRE, <span class="hljs-string" style="color: #c41a16; line-height: 26px;">''</span>)
    <span class="hljs-comment" style="color: #007400; line-height: 26px;">// parse arg</span>
    <span class="hljs-keyword" style="color: #aa0d91; line-height: 26px;">const</span> argMatch = name.match(argRE)
    <span class="hljs-keyword" style="color: #aa0d91; line-height: 26px;">let</span> arg = argMatch &amp;&amp; argMatch[<span class="hljs-number" style="color: #1c00cf; line-height: 26px;">1</span>]
    isDynamic = <span class="hljs-literal" style="color: #aa0d91; line-height: 26px;">false</span>
    <span class="hljs-keyword" style="color: #aa0d91; line-height: 26px;">if</span> (arg) {
      name = name.slice(<span class="hljs-number" style="color: #1c00cf; line-height: 26px;">0</span>, -(arg.length + <span class="hljs-number" style="color: #1c00cf; line-height: 26px;">1</span>))
      <span class="hljs-keyword" style="color: #aa0d91; line-height: 26px;">if</span> (dynamicArgRE.test(arg)) {
        arg = arg.slice(<span class="hljs-number" style="color: #1c00cf; line-height: 26px;">1</span>, <span class="hljs-number" style="color: #1c00cf; line-height: 26px;">-1</span>)
        isDynamic = <span class="hljs-literal" style="color: #aa0d91; line-height: 26px;">true</span>
      }
    }
	<span class="hljs-comment" style="color: #007400; line-height: 26px;">// *** 重点在这里 ***</span>
    addDirective(el, name, rawName, value, arg, isDynamic, modifiers, list[i])
	<span class="hljs-comment" style="color: #007400; line-height: 26px;">// ...</span>
 }
} <span class="hljs-keyword" style="color: #aa0d91; line-height: 26px;">else</span> {
  <span class="hljs-comment" style="color: #007400; line-height: 26px;">// 常规属性处理逻辑</span>
}

} }

如上代码,通过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(有顺序要求),其他修饰符需要在处理函数内手动实现。

以上就是我们今天要讲的两种动态绑定事件的方式,其中第二种方式已经能够满足我们的大部分使用需求。

如果仍旧觉得不满足需求,可以试试用自定义指令来实现,笔者有空也会再来一篇。