Vue源码-event

Vue允许我们在模版上用v-on@为元素添加DOM事件,并且可以为组件元素添加自定义的事件。现在通过源码角度看看Vue是怎么处理事件的绑定和执行的。

模版的事件编译

先通过一个例子看看事件的基本用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import Vue from 'vue';

const Child = {
template: '<button @click="handleClick">click</button>',
methods: {
handleClick() {
console.log('child click');
this.$emit('select');
}
}
};

new Vue({
el: '#app',
template: `
<div>
<Child @select="handleSelect" @click.native="handleClick"></Child>
</div>
`,
methods: {
handleClick() {
console.log('parent click');
},
handleSelect() {
console.log('parent select');
}
},
components: { Child }
});

上面例子利用模版的形式给对应的元素和组件绑定事件。首先,Vue会编译模版,会把元素的事件和组件的自定义事件都放在on对象上,把组件的原生事件放在nativeOn对象上。所以,上面的例子编译后的render函数大概如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// child 的render
with (this) {
return _c('button', { on: { click: handleClick } }, [_v('click')]);
}

// Vue 实例的render
with (this) {
return _c(
'div',
[
_c('Child', {
on: { select: handleSelect },
nativeOn: {
click: function($event) {
return handleClick($event);
}
}
})
],
1
);
}

_c方法是Vue实例的内置方法,它用来创建一个虚拟节点,和createElement方法基本一样。

DOM事件

我们是通过把组件生成的虚拟节点进行patch后更新DOM的,所以对于DOM事件的绑定就在该过程处理的。在patch的过程中会调用createElm生成vnode的真实DOM,在该方法有一段代码:

1
2
3
4
5
6
7
8
// 递归创建vnode的children对应的dom节点,并插入到vnode.elm
createChildren(vnode, children, insertedVnodeQueue);
if (isDef(data)) {
// 处理data属性
invokeCreateHooks(vnode, insertedVnodeQueue);
}
// 把vnode创建的node插入到真实的dom
insert(parentElm, vnode.elm, refElm);

在处理完子节点的创建后,会调用invokeCreateHooks方法触发自身和模块的create钩子:

1
2
3
4
5
6
7
8
9
10
function invokeCreateHooks (vnode, insertedVnodeQueue) {
for (let i = 0; i < cbs.create.length; ++i) {
cbs.create[i](emptyNode, vnode)
}
i = vnode.data.hook // Reuse variable
if (isDef(i)) {
if (isDef(i.create)) i.create(emptyNode, vnode)
if (isDef(i.insert)) insertedVnodeQueue.push(vnode)
}
}

在两个节点进行patch过程会调用一系列的钩子函数,比如在生成DOM的时候我们要处理样式,属性,事件等这些都是在模块的create钩子进行的,我们来看下模块对应事件钩子的处理,它定义在src/platforms/web/runtime/modules/events.js:

1
2
3
4
export default {
create: updateDOMListeners,
update: updateDOMListeners
}

createupdate阶段都会调用updateDOMListeners方法:

1
2
3
4
5
6
7
8
9
10
11
function updateDOMListeners (oldVnode: VNodeWithData, vnode: VNodeWithData) {
if (isUndef(oldVnode.data.on) && isUndef(vnode.data.on)) {
return
}
const on = vnode.data.on || {}
const oldOn = oldVnode.data.on || {}
target = vnode.elm
normalizeEvents(on)
updateListeners(on, oldOn, add, remove, createOnceHandler, vnode.context)
target = undefined
}

这个方法先拿出新旧节点的事件对象onoldOn,把操作对象target设置成vnode.elm也就是当前vnode对应的真实DOM,addremove是对元素的事件绑定和移除:

1
2
3
4
5
6
7
function add(name: string, handler: Function, capture: boolean, passive: boolean) {
target.addEventListener(name, handler, supportsPassive ? { capture, passive } : capture);
}

function remove(name: string, handler: Function, capture: boolean, _target?: HTMLElement) {
(_target || target).removeEventListener(name, handler._wrapper || handler, capture);
}

最后调用updateListeners方法进行事件的绑定,这个方法定义在src/core/vdom/helpers/update-listeners.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
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)
/* istanbul ignore if */
if (__WEEX__ && isPlainObject(def)) {
cur = def.handler
event.params = def.params
}
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) {
// 这里只要修改回调函数的引用即可,不用操作DOM
old.fns = cur
on[name] = old
}
}
// 卸载的事件
for (name in oldOn) {
if (isUndef(on[name])) {
event = normalizeEvent(name)
remove(event.name, oldOn[name], event.capture)
}
}
}

这个方法先循环on中的每一个方法,如果这个方法不存在oldOn表示是一个新增的方法,然后用createFnInvoker方法创建对应事件的回调函数,参数是我们用户绑定的回调函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export function createFnInvoker (fns: Function | Array<Function>, vm: ?Component): Function {
function invoker () {
const fns = invoker.fns
if (Array.isArray(fns)) {
const cloned = fns.slice()
for (let i = 0; i < cloned.length; i++) {
invokeWithErrorHandling(cloned[i], null, arguments, vm, `v-on handler`)
}
} else {
// return handler return value for single handlers
return invokeWithErrorHandling(fns, null, arguments, vm, `v-on handler`)
}
}
invoker.fns = fns
return invoker
}

因为我们可以为事件绑定多个函数回调的,所以要考虑cur是一个回调函数数组的情况。createFnInvoker方法其实是对我们定义的回调的一个封装,并把这些回调存在返回结果的fns属性上。所以在Vue中事件触发的回调其实是执行invoker方法,在方法内部通过fns获取我们定义的方法并执行。

那Vue为什么直接绑定我们用户定义的回调呢?原因在下面一段处理:

1
2
3
4
5
else if (cur !== old) {
// 这里只要修改回调函数的引用即可,不用操作DOM
old.fns = cur
on[name] = old
}

当我们是更新状态从而触发事件的更新的话,直接修改invoker方法的fns指定的回调即可,免去操作真实的DOM去绑定或者移除事件监听。接着再循环oldOn的每个事件,如果不存在on中就代表移除这个事件的监听。

自定义事件

在组件上可以绑定原生和自定义的事件,对于原生的事件对象nativeOn会在组件构造阶段赋值给on,然后在create的钩子函数中和DOM事件的处理逻辑是一样的。在createComponent函数中,有这样一段逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
const listeners = data.on
data.on = data.nativeOn

//...

// 返回组件的虚拟节点
const vnode = new VNode(
`vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
data, undefined, undefined, undefined, context,
{ Ctor, propsData, listeners, tag, children },
asyncFactory
)

它会把自定义事件对象赋值给listeners作为虚拟节点的componentOptions属性。我们都知道在patch过程中会调用组件虚拟节点的init钩子并创建组件的实例。然后在实例创建入口vm._init()方对组件实例的配置进行处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 处理组件实例的配置
export function initInternalComponent (vm: Component, options: InternalComponentOptions) {
// 把组件构造函数的options合并到组件实例
const opts = vm.$options = Object.create(vm.constructor.options)
// doing this because it's faster than dynamic enumeration.
const parentVnode = options._parentVnode
opts.parent = options.parent
opts._parentVnode = parentVnode

const vnodeComponentOptions = parentVnode.componentOptions
opts.propsData = vnodeComponentOptions.propsData
opts._parentListeners = vnodeComponentOptions.listeners
opts._renderChildren = vnodeComponentOptions.children
opts._componentTag = vnodeComponentOptions.tag

if (options.render) {
opts.render = options.render
opts.staticRenderFns = options.staticRenderFns
}
}

很明显,Vue会把组件绑定的自定义事件对象赋值给配置对象的_parentListeners属性上。在接下来的事件初始化方法initEvents方法中,处理组件实例的事件:

1
2
3
4
5
6
7
8
9
10
// 在父组件模版中v-on绑定的事件注册到子组件的事件系统中
export function initEvents (vm: Component) {
vm._events = Object.create(null)
vm._hasHookEvent = false
// init parent attached events
const listeners = vm.$options._parentListeners
if (listeners) {
updateComponentListeners(vm, listeners)
}
}

这个方法先创建vm._events空对象来管理实例的事件,然后把用户绑定的自定义事件对象作为updateComponentListeners方法参数并调用:

1
2
3
4
5
6
7
8
9
export function updateComponentListeners (
vm: Component,
listeners: Object,
oldListeners: ?Object
) {
target = vm
updateListeners(listeners, oldListeners || {}, add, remove, createOnceHandler, vm)
target = undefined
}

该方法其实和DOM事件的处理逻辑一样都会updateListeners方法进行事件的绑定,不同的是addremovecreateOnceHandler方法的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function add (event, fn) {
target.$on(event, fn)
}

function remove (event, fn) {
target.$off(event, fn)
}

function createOnceHandler (event, fn) {
const _target = target
return function onceHandler () {
const res = fn.apply(null, arguments)
if (res !== null) {
_target.$off(event, onceHandler)
}
}
}

对于事件的绑定是调用vm.$on方法,事件的移除是调用vm.$off方法,这两个方式都是Vue提供给用户操作实例事件系统的,它在Vue入口的eventsMixin注入到原型对象上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
 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
}

Vue.prototype.$off = function (event?: string | Array<string>, fn?: Function): Component {
const vm: Component = this
// all
if (!arguments.length) {
vm._events = Object.create(null)
return vm
}
// array of events
if (Array.isArray(event)) {
for (let i = 0, l = event.length; i < l; i++) {
vm.$off(event[i], fn)
}
return vm
}
// specific event
const cbs = vm._events[event]
if (!cbs) {
return vm
}
if (!fn) {
vm._events[event] = null
return vm
}
// specific handler
let cb
let i = cbs.length
while (i--) {
cb = cbs[i]
if (cb === fn || cb.fn === fn) {
cbs.splice(i, 1)
break
}
}
return vm
}

对于$on方法,如果事件event参数是一个数组,则递归调用$on方法对每个方法进行绑定。否则执行:

1
(vm._events[event] || (vm._events[event] = [])).push(fn)

把事件对应的回调函数push到事件队列。对于$off方法移除事件,要考虑的是没传参数,event是数组和有传回调函数的特殊情况。在移除对应的回调的时候,注意循环是从后面开始的,这样就不会造成splice截取后下标的问题。

另外,我们可以通过vm.$emit方法触发实例对应事件的回调函数,来看下它的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
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
}

这个方法很简单,首先在vm._events上根据事件名获取对应的回调队列,然后循环队列,把传给$emit方法的剩余参数作为回调的参数进行调用。

总结

到现在,我们就了解Vue是如果处理事件系统的。对于DOM原生事件,会在patch过程的首次加载的create钩子和节点对比的update钩子进行处理。对于组件的自定义事件,会在创建实例的事件初始化initEvents方法进行处理。它们之间的区别就是addremove方法对事件的绑定和移除不同,前者是操作原生的事件系统,后者是操作Vue实例的事件管理对象_events

值得注意的是,我们平时开始利用自定义事件来进行父子组件的通行。会给我们一种错觉就是自定义事件的回调是存在父组件的实例中,其实通过源码分析知道回调函数是注入到子组件的事件系统,在子组件中通过$emit方法调用,只是回调函数定义在父组件,所以可以操作父组件的状态,从而达到父子组件的通行。