以下代码为例,走一遍源码过程:
let Child = {
template: '<button @click="clickHandler($event)">' +
'click me' +
'</button>',
methods: {
clickHandler(e) {
console.log('Button clicked!', e)
this.$emit('select')
}
}
}
let vm = new Vue({
el: '#app',
template: '<div>' +
'<child @select="selectHandler" @click.native.prevent="clickHandler"></child>' +
'</div>',
methods: {
clickHandler() {
console.log('Child clicked!')
},
selectHandler() {
console.log('Child select!')
}
},
components: {
Child
}
})
首先会进入到 parse 阶段,这个阶段会将事件描述添加到 AST 树中:
接着进入到 codegen 将 AST 树生成运行代码,上篇文章说到,跟事件相关的部分会进入到 genHandlers 逻辑:
对于 @click="clickHandler($event)" 这种写法就会 return `function($event){${handler.value}}`,可以看到返回的是外层包裹了一个函数,将 $event 作为参数传入,所以我们可以使用 $event 将子组件中的数据传递给父组件,既然知道了 $event 是如何实现的,那么如果我们想传递多个参数给父组件呢?当然我们可以将 $event 写成一个对象或者是数组,如果想传递一个以上的参数,在父组件中就可以这样写:(a, b) => example(a,b,9) 这种写法在外层包裹一个函数,可以让子组件传递多个参数给父组件,并且父组件还可以自己定义参数,对于多参数而言,这样写就很灵活了,对于复杂的逻辑,只依赖 $event 是不够用的。
对于我们的例子而言,父组件生成的 res 对象:
{
on: {"select": selectHandler},
nativeOn: {"click": function($event) {
$event.preventDefault();
return clickHandler($event)
}
}
}
子组件生成的 res 对象:
{
on: {"click": function($event) {
clickHandler($event)
}
}
}
编译结束后,在运行阶段又是如何为 DOM 元素添加原生事件和自定义事件的呢?
这是在 patch 阶段中,将 vnode 转换成 DOM 节点时,会调用所有 module 的钩子函数,目的是设置 DOM 元素相关的属性、样式、事件等,那么只关注与事件相关的 module 钩子,只有 create 钩子和 update 钩子,当调用这两个钩子时,即 DOM 节点创建阶段调用 create 钩子,DOM 节点更新节点调用 update 钩子,它们都会调用与之对应的 updateDomListeners 函数,实际上就是调用 updatelisteners(on, oldOn, add, remove, vnode.context)
updateListeners 的逻辑是:遍历 on 去添加事件监听,遍历 oldOn 去移除事件监听,关于添加和移除事件的方法都是外部传入的,因为它既处理原生 DOM 事件的添加删除,也处理自定义事件的添加删除。
对于原生 DOM 事件就是使用了 addEventListener 和 removeEventListener 来添加和移除事件,而对于自定义事件就是使用了几个 Vue 自定义的事件中心,它们分别是:$on $off $emit $once,逻辑比较简单,就是将该组件实例的所有事件都存储到 vm._event 对象中,以事件名为 key,以事件名对应的一个或多个回调函数构成的数组为值,当执行 $on 时,就是将对应的回调函数 push 到 vm._event[name] 中,而 $off 就是将对应的回调函数 从 vm._event[name] 中删除,如果 $off 没有第二个参数,即指定的回调函数名,那么会删除该事件名所有的回调函数,$emit 就是执行对应的回调函数,而 $once 就是一次性绑定,先执行 $on(传入一个自定义的回调函数),这个自定义的回调函数会先将对应的回调函数执行一次后,再执行 $off,保证该函数只执行一次。
我们经常使用 $emit 进行父子组件通讯,但要注意,$emit 是往当前子组件实例上派发事件,而不是子组件往父组件派发事件,只是因为回调函数定义在了父组件环境中,在子组件创建阶段,就会将定义在父组件的回调函数作为 listeners 参数传递给子组件,因此就添加到了子组件实例中,所以就可以触发定义在父组件的回调函数。也正因为 Vue 这种事件中心的设计模式(需要一个对象保留所有事件),我们也利用了 bus 总线机制达到非父子组件的通讯效果。
接着学习下 createFnInvoke 这个函数:
export function createFnInvoker (fns: Function | Array<Function>): Function {
function invoker () {
const fns = invoker.fns
if (Array.isArray(fns)) {
const cloned = fns.slice()
for (let i = 0; i < cloned.length; i++) {
cloned[i].apply(null, arguments)
}
} else {
return fns.apply(null, arguments)
}
}
invoker.fns = fns
return invoker
}
这个函数返回一个 invoker 回调函数,它会作为参数传给 add 方法,其中 fns 是从 invoker.fns 中拿到的,为什么要这样设计呢?这是因为当第二次执行这个回调函数时,判断如果 cur != old,那么只需要更改 old.fns = cur 把之前绑定的 invoker.fns 赋值为新的回调函数即可,并且 通过 on[name] = old 保留引用关系,这样就保证了事件回调只添加一次,之后仅仅去修改它的回调函数的引用。
最后为什么 .native 修饰符对应的是原生事件呢?这是因为将 data.nativeOn 赋值给了 data.on,所以组件的根节点 vnode 就有这个 data.on,因此给组件上绑定原生事件时,实际上就是给子组件的根节点绑定原生事件,正是这个原因,组件才有自定义事件和原生事件两种事件类型,如果组件上没有 .native 修饰符,那就是自定义事件。
那如果要监听子组件非根元素的事件呢,可以利用 $listeners 属性来达到目的,它包含了作用在该子组件的所有自定义事件(在该子组件上监听了哪些自定义事件,就包含了哪些自定义事件),通过在子组件的非根元素上写 v-on="$listeners"(如此就在子元素中绑定了定义在父组件的自定义事件),这样子组件的非根元素绑定的事件向外触发事件时,就可以触发到定义在父组件的自定义事件啦!当然 $listeners 还有跨组件通信的作用。另外通过 inheritAttrs: false 和 $attrs,我们就可以手动决定哪些非 props 特性会被传递到子组件中了,这两种方式再加上 bus 总线机制都可以在不使用 vuex 时实现跨组件的通信了。当然为了代码的可维护性,我们应该尽可能地只使用一种方式来进行数据的通信,否则代码越来越臃肿时,我们自己写代码的人都会迷糊一个数据是从哪里传入的,那么就更别说合作的伙伴了。
学习 vue 源码确实受益良多,知其然并知其所以然,还能举一反三,不仅可以学习 vue 的设计模式,还可以在遇到 bug 时快速定位,写代码也更加灵活,所以想将框架学好,最好的方式就是学习源码,并且用聪明地方式去学习,而不是从头看到尾。