『 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-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">
动态绑定多个事件
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',
} 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),
// 如果不存在旧的处理函数
} 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