Vue.js源码学习 —— 事件机制

如果您是刚开始准备阅读Vue.js的源码,建议先看一下本系列的Vue.js源码学习 —— 起步,相信会对您后面的阅读有很大帮助。

今天我们来看一下 Vue 中的事件机制,代码位于文件core/instance/events.js中。其实 Vue 的事件机制就是实现了 EventBus,从官方提供的API也可以看出这一点:vm.$onvm.$emitvm.$off。EventBus有时也叫 事件总线,是对发布/订阅模式的实现。第一次听到这个词还挺新鲜的,仔细琢磨后发现其实跟 iOS 中的 NSNotification 是一样的,你可以发送一个事件给 EventBus,但并不需要关心谁会来处理这个事件,也不用关心有多少个人会来处理这个事件。同样地,你也可以监听 EventBus 中的某个事件,也不需要关心是谁发过来的,收到消息后处理就完事儿了。

我们完全可以将一个 Vue 实例只当作 EventBus 来使用,来看个例子,下面的代码放在一个html中即可测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<div id="publish-view">
<input v-model="location" />
<input v-model="caption" />
<button v-on:click="sendData">Send</button>
</div>

<div id="subscribe-view">
<p>location: {{location}}</p>
<p>caption: {{caption}}</p>
</div>

<p>
<!-- 销毁 Subscribe -->
<button onclick="destroy()">Destroy Subscribe</button>
</p>
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
// 定义事件总线
const EventBus = new Vue()

const publish = new Vue({
el: '#publish-view',
data () {
return {
location: '',
caption: ''
}
},
methods: {
sendData () {
let payload = {
location: this.location,
caption: this.caption
}
EventBus.$emit('DATA_PUBLISHED', payload)
}
}
})

const subscribe = new Vue({
el: '#subscribe-view',
data () {
return {
location: '',
caption: ''
}
},
mounted () {
EventBus.$on('DATA_PUBLISHED', (payload) => {
this.location = payload.location,
this.caption = payload.caption
})
},
destroyed () {
// 之后再也收不到 DATA_PUBLISHED 消息
EventBus.$off('DATA_PUBLISHED')
}
})

function destroy() {
subscribe.$destroy()
}

可以看到,使用一个第三方的EventBus 使得数据可以在完全没有关系的两个 Vue 实例之间传递。接下来我们就看一下 Vue 中是如何实现 EventBus 的。

EventBus的实现

我们先来看一下initEvents函数:

1
2
3
4
5
6
7
8
9
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:注册到 EventBus 的事件实际上都放在vm._events中,这是一个哈希表,键是事件名称,值是一个回调函数的数组。

  • vm._hasHookEvent:标识 EventBus 中是否有以hook:开头的事件,顾名思义这是专门用于钩子事件的发送和监听的。我们找到文件core/instance/lifecycle.js中的callHook方法,可以看到在这里判断vm._hasHookEvent如果是true就可以发送事件了,而不用再去查vm._events,所以这也是一种优化手段。

    1
    2
    3
    4
    5
    6
    7
    8
    export function callHook (vm: Component, hook: string) {
    pushTarget()
    // ...
    if (vm._hasHookEvent) {
    vm.$emit('hook:' + hook)
    }
    popTarget()
    }

    搜索整个项目会发现在实现异步组件时,有监听hook:destroyed事件:

    1
    ;(owner: any).$on('hook:destroyed', () => remove(owners, owner))
  • updateComponentListeners:在初始化时这个函数的作用是向组件实例的 EventBus 中注册事件监听器。更新组件时也会调用到这个函数,此时是更新组件实例的 EventBus 中的事件监听器。在组件间的通信一节我们将看到这个函数是如何被使用的。

EventBus 中的几个关键方法定义在eventsMixin中,在core/instance/init.js中在初始化 Vue 实例之后,就调用了各种**Mixin函数,其中就包括eventsMixin

vm.$on

vm.$on方法的主要内容如下:

1
2
3
4
5
6
7
8
9
10
11
Vue.prototype.$on = function (event: string | Array<string>, fn: Function): Component {
const vm: Component = this
if (Array.isArray(event)) {
// 循环 event,再依次调用 vm.$on
} else {
(vm._events[event] || (vm._events[event] = [])).push(fn)
if (hookRE.test(event)) {
vm._hasHookEvent = true
}
}
}

可以看到vm.$on方法的主要作用就是向 vm._events 中添加事件和对应的回调函数。如果event是数组,说明开发者想让多个事件共用同一个事件回调函数,此时会循环调用vm.$on依次向vm._events中添加一次。如果是字符串,直接以事件名为key,回调函数为value放入vm._events中。此时也会判断事件名称是否以hook:开头,如果是的话将vm._hasHookEvent置为true

vm.$emit

去掉开发环境下的提示,vm.$emit方法的主要内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Vue.prototype.$emit = function (event: string): Component {
const vm: Component = this
if (process.env.NODE_ENV !== 'production') {
// ...
}
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.$emit方法主要用来触发vm._events中的事件回调函数的执行。首先会取出event对应的回调函数,得到的可能是一个数组,遍历这个数组依次执行回调函数。同时我们注意到,vm.$emit方法中第1个参数是事件名称,剩余的参数都会作为回调函数的参数给传过去。

上面举的例子也可以将payload拆开传:

1
2
3
4
5
6
7
8
// publish
EventBus.$emit('DATA_PUBLISHED', this.location, this.caption)

// subscribe
EventBus.$on('DATA_PUBLISHED', (location, caption) => {
this.location = location
this.caption = caption
}

使用invokeWithErrorHandling函数是为了在事件回调函数执行发生异常时能被捕获到,并将错误信息按照 Vue 的错误传播规则进行处理。关于 Vue 的错误传播规则,可以查阅官方文档#errorCaptured中的说明。

因为在 HTML 的属性是不区分大小写的,所以在开发环境下比如一次传了data_published,一次又传了DATA_PUBLISHED则会给出告警提示。

vm.$off

1
2
3
Vue.prototype.$off = function (event?: string | Array<string>, fn?: Function): Component {
// ...
}

vm.$off方法主要用来从vm._events中移除自定义事件监听器,从参数的注解中可以看到eventfn都是可以为空的。所以vm.$off方法中会分几种情况来处理:

  • eventfn都没有提供时,会直接重置vm._events,也就是移除所有的事件监听器。

    1
    2
    3
    4
    5
    // all
    if (!arguments.length) {
    vm._events = Object.create(null)
    return vm
    }
  • 如果event是数组时,遍历这个数组依次调用vm.$off方法。

    1
    2
    3
    4
    5
    6
    7
    // array of events
    if (Array.isArray(event)) {
    for (let i = 0, l = event.length; i < l; i++) {
    vm.$off(event[i], fn)
    }
    return vm
    }
  • 只提供了event,而是是字符串时,会移除该事件的所有回调函数。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // specific event
    const cbs = vm._events[event]
    if (!cbs) {
    return vm
    }
    if (!fn) {
    vm._events[event] = null
    return vm
    }
  • 同时提供了eventfn,会遍历event的回调函数数组找到fn并将它从数组中移除。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // specific handler
    let cb
    let i = cbs.length
    while (i--) {
    cb = cbs[i]
    if (cb === fn || cb.fn === fn) {
    cbs.splice(i, 1)
    break
    }
    }

vm.$once

vm.$once方法如下:

1
2
3
4
5
6
7
8
9
10
11
Vue.prototype.$once = function (event: string, fn: Function): Component {
const vm: Component = this
function on () {
vm.$off(event, on)
fn.apply(vm, arguments)
}
// 所以上面判断了 cb.fn === fn
on.fn = fn
vm.$on(event, on)
return vm
}

vm.$once方法也是用来监听一个自定义事件的,但是只会被触发一次。一旦触发之后,事件监听器就会被移除。在vm.$once方法中重新定义了事件的回调函数,在该函数中会先调用vm.$off方法将事件监听器移除,然后执行开发者传入的fn

这几个方法都还比较容易看懂,Vue 实现了 EventBus 不仅仅是想提供给开发者用,最重要的是用来实现组件间的通信。

组件间的通信

咱们从updateComponentListeners函数开始看,这个函数主要是用来更新组件实例的 EventBus 中的事件监听器的,在初始化events时调用了它:

1
2
3
4
5
6
7
export function initEvents (vm: Component) {
// init parent attached events
const listeners = vm.$options._parentListeners
if (listeners) {
updateComponentListeners(vm, listeners)
}
}

updateChildComponent函数也调用过一次:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export function updateChildComponent (
vm: Component,
propsData: ?Object,
listeners: ?Object,
parentVnode: MountedComponentVNode,
renderChildren: ?Array<VNode>
) {
// ...

// update listeners
listeners = listeners || emptyObject
const oldListeners = vm.$options._parentListeners
vm.$options._parentListeners = listeners
updateComponentListeners(vm, listeners, oldListeners)

// ...
}

可见listeners来自于vm.$options._parentListeners,在讲初始化 Vue 内部创建的组件时曾遇到过:

1
2
3
4
5
export function initInternalComponent (vm: Component, options: InternalComponentOptions) {
// ...
opts._parentListeners = vnodeComponentOptions.listeners
// ...
}

既然是与内部创建组件有关,那一定是在createComponent函数中有向外传listeners这个参数,打开文件core/vdom/create-component.js找到createComponent函数,与listeners有关的代码如下:

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
export function createComponent (
Ctor: Class<Component> | Function | Object | void,
data: ?VNodeData,
context: Component,
children: ?Array<VNode>,
tag?: string
): VNode | Array<VNode> | void {
// ...

// extract listeners, since these needs to be treated as
// child component listeners instead of DOM listeners
const listeners = data.on
// replace with listeners with .native modifier
// so it gets processed during parent component patch.
data.on = data.nativeOn

// ...

// return a placeholder vnode
const name = Ctor.options.name || tag
const vnode = new VNode(
`vue-component-${Ctor.cid}${name ? `-${name}` : ''}`, // tag
data, // data
undefined, // children
undefined, // text
undefined, // elm
context, // context
{ Ctor, propsData, listeners, tag, children }, // componentOptions
asyncFactory // asyncFactory
)

// ...

return vnode
}

关于createComponent函数的实现可以看一下本系列的Vue.js源码学习 —— createComponent,了解之后你就会知道createComponent函数的传参都来自我们创建 Vue 实例时的render函数,data.ondata.nativeOn自然也是的。

先来看一下data.ondata.nativeOn的作用:

  • data.on:即可以用于普通 DOM 元素,也可以用于组件实例。

    当用于普通 DOM 元素时,data.on中来自 DOM 元素的原生事件会被触发,而来自this.$emit的自定义事件无法被触发。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    h('button', {
    on: {
    click: () => {
    this.$emit('btnClick')
    },
    btnClick: () => {
    // 不会被触发
    }
    }
    }, 'Button')

当用于组件实例时,data.on中来自this.$emit的自定义事件会被触发,而来自 DOM 元素的原生事件无法被触发。

1
2
3
4
5
6
7
8
9
h(childComponent, {
on: {
btnClick: () => {
// 会收到组件内提交的 btnClick消息
},
click: () => {
// 不会被触发
}
}

为什么会这样呢?因为对于普通DOM元素来说,在打补丁的过程中data.on会最终由platforms/web/runtime/modules/events.js中的updateDOMListeners函数来处理,可以看到在这个函数中调用该文件中的add函数是通过addEventListener注册到 DOM 元素中。

1
2
3
4
5
6
target.addEventListener(
name,
handler,
supportsPassive
? { capture, passive }
: capture

而对于组件实例来说,在打补丁的过程中会去创建组件实例,data.on最终会传到我们今天分析的这个文件中的updateComponentListeners函数,最后由当前文件中提供的add函数来给组件实例添加事件监听器。

1
2
3
function add (event, fn) {
target.$on(event, fn)
}
  • data.nativeOn:只能用于组件实例,只有来自 DOM 元素的原生事件会被触发。它的用途主要是用来在父组件中向子组件的根元素传递原生事件监听器。下面举一个完成的例子看一下:

    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
    const child = {
    name: 'child',
    render (h) {
    return h('div',
    [
    h('button', {
    on: {
    click: () => {
    this.$emit('btnClick')
    }
    }
    }, 'Button'),
    'Click Me'
    ])
    }
    }

    const vm = new Vue({
    el: '#app',
    render (h) {
    return h('div',
    [
    h(child, {
    on: {
    btnClick: () => {
    console.log('receive child btnClick event')
    }
    },
    nativeOn: {
    click: () => {
    console.log('child div clicked')
    }
    }
    })
    ])
    }
    })

    在创建组件时,通过data.on = data.nativeOn这句代码使得在没有传data.nativeOndata.on也会是undefined。这样在后续处理过程中,如果有data.on会将事件监听器添加到 DOM 元素中,没有也会处理。

在平时的开发中可能很少直接接触到data.ondata.nativeOn,这些隐藏在了模板被编译后render函数中,我们分别举例来一下。

<my-component>组件在点击按钮时触发my-event事件,在vm中使用了<my-component>组件,并给它传了my-event的事件回调函数,这在平时的开发中是很常见的场景了:

1
2
3
<div id="app">
<my-component v-on:my-event="doSomething"></my-component>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
Vue.component('my-component', {
template: `<button v-on:click="$emit('my-event')">Click Me</button>`
})

const vm = new Vue({
el: '#app',
methods: {
doSomething () {
console.log('received my-component event')
}
}
})

这段代码经过编译后<my-component>vmrender函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
// <my-component>
(function anonymous() {
with (this) {
return _c('button', {
on: { // data.on 用在DOM元素中 监听原生事件
"click": function($event) {
return $emit('my-event')
}
}
}, [_v("Click Me")])
}
}
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// vm
(function anonymous() {
with (this) {
return _c('div', {
attrs: {
"id": "app"
}
}, [_c('my-component', {
on: { // data.on 用在组件实例中,监听 $emit 发出的消息
"my-event": doSomething
}
})], 1)
}
}
)

如果看编译后的render函数内容上一篇文章有介绍了,好奇的同学可以过去看一下。

如果事件用.native修饰,会编译成data.nativeOn。现在修改一下给<my-component>传递事件的方式:

1
2
3
<div id="app">
<my-component v-on:click.native="doSomething"></my-component>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
Vue.component('my-component', {
template: `<button>Click Me</button>`
})

new Vue({
el: '#app',
methods: {
doSomething () {
console.log('received my-component event')
}
}
})

此时经过编译后vmrender函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// vm
(function anonymous() {
with (this) {
return _c('div', {
attrs: {
"id": "app"
}
}, [_c('my-component', {
nativeOn: { // data.nativeOn 用于组件实例中,监听DOM原生事件
"click": function($event) {
return doSomething($event)
}
}
})], 1)
}
}
)

一个 Vue 实例可以当做一个 EventBus,事件监听器都放在vm._events中,所以跨 Vue 实例是无法互相通信的。除非有一个第三者充当中介,比如使用一个全局的 EventBus,或者使用 Vuex

本文作者:意林
本文链接:http://shinancao.cn/2020/02/15/Vue-Source-Code-14/
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!