event即事件系统,这部分源码让人看得头皮发麻。但是理解之后发现这部分的逻辑是很重要的,在这里试着用几句话概括流程。
事件系统分为“DOM 事件”和“自定义事件”,分别为两套处理流程,先有个大概的印象即可。
首先会有编译的过程,按照惯例其源码就略过了。
// 父组件生成的 `data` 串为:
{
on: {"select": selectHandler}, // 针对组件的自定义事件
nativeOn: {"click": function($event) { // 原生事件
$event.preventDefault();
return clickHandler($event)
}
}
}
// 子组件生成的 `data` 串为:
{
on: {"click": function($event) { // 因为渲染节点根节点是原生dom,所以这个也是原生事件
clickHandler($event)
}
}
}
所谓父组件,其实就是“占位符节点”。而子组件,就是该组件.vue
文件中所声明的组件。
DOM 事件
**DOM 事件(也就是相对而言的原生事件绑定):在 patch过程中的创建阶段和更新阶段执行 updateDOMListeners 注册 DOM 事件。**代码例子如:
- '<child @select=“selectHandler” @click.native.prevent=“clickHandler”>`
<button @click="clickHandler($event)">' + 'click me' + '</button>
in child component
无论是对于占位符节点和 渲染节点,它们其实事件都是绑定到同一个真实的dom上。因为渲染节点的逻辑先执行,所以它的会先绑定。 在例子中也就是<button>
的@click
+ <child>
的@click.native
这个机制的代码流程的处理真的太恶心了,就不贴出来了。大概用文字总结一下,在debug的时候大概知道是属于哪里的问题。
- 在 patch过程中的创建阶段和更新阶段执行 updateDOMListeners 注册 DOM 事件。
- 遍历
on
去添加事件监听,遍历oldOn
去移除事件监听 - event = normalizeEvent(name):根据我们的的事件名的一些特殊标识(之前在
addHandler
的时候添加上的)区分出这个事件是否有once
、capture
、passive
等修饰符。 - 按照计算结果更新所绑定的回调。这里vue有一个巧妙的机制,能够减小绑定机制的性能消耗,并且防止内存溢出。
- 遍历
vue优化js DOM事件绑定的机制
- 事件回调在第一次只添加一次,之后仅仅去修改它的回调函数的引用指向。其实就是invoke函数创建好了,知道它每次用到invoke.fns,如果更新则更新invoke.fns就好了
- 【对于第一次执行】执行
cur = on[name] = createFnInvoker(cur)
方法去创建一个回调函数,然后在执行add(event.name, cur, event.once, event.capture, event.passive, event.params)
完成一次事件绑定 - createFnInvoker:返回一个函数,它持有一个fns属性,并且它本身的逻辑就是遍历执行这些函数
- 【对于第一次之后执行】:只需要更改
old.fns = cur
把之前绑定的involer.fns
赋值为这一回新的回调函数即可,并且 通过on[name] = old
保留引用关系 - 根据相关预设移除事件回调:
updateListeners
函数的最后遍历oldOn
拿到事件名称,判断如果满足isUndef(on[name])
,则执行remove(event.name, oldOn[name], event.capture)
去移除旧vnode相关的事件回调。 - (createFnInvoker源码那里有clone一下invoker.fns的,因为invoker.fns它们不是马上执行,后面还会篡改invoker.fns属性再用于设置回调函数,所以先转移到另外一个变量来持有)
- 【对于第一次执行】执行
源码就不贴了,这段逻辑可以简化为示例代码:
// on: {'click': invoker持有fns, 'mouseover': invoker持有fns }
for (name in on) {
if ('初始化组件的事件') {
cur = on[name] = /* 新创建的invoke并持有fns。 createFnInvoker 在这里执行 */
domAddEventListener(event.name, cur, event.capture, event.passive, event.params); // [name]作为绑定到dom上的事件回调,是会调用到 它上面的fns的。
} else if (cur !== old) { // else if 更新组件的事件
old.fns = cur;
on[name] = old; // 不重新addEventListener到DOM上,通过这2行更新fns来更新要执行的回调
}
}
withMacroTask技法
这个技法其实在vue源码中出现了多次。类似的还有withCommit之类的,都是高阶函数。
既然是事件回调的处理,肯定有封装对原生api的调用。add
和 remove
的逻辑很简单,就是实际上调用原生 addEventListener
和 removeEventListener
,并根据参数传递一些配置,注意这里的 hanlder
会用 withMacroTask(hanlder)
包裹一下,它的定义在 src/core/util/next-tick.js
中:
export function withMacroTask (fn) {
return fn._withTask || (fn._withTask = function () {
useMacroTask = true // 这个变量在对应nextTick相关的闭包里面
const res = fn.apply(null, arguments)
useMacroTask = false
return res
})
}
实际上就是强制:如果在 DOM 事件的回调函数执行期间如果修改了数据,那么这些数据更改推入的队列会被当做 macroTask
在 nextTick
后执行。
注意原生dom事件本来就是属于宏任务。
自定义事件
“DOM 事件” vs “自定义事件”。一开就明白了对立的关系。
自定义事件的具体工作流程好像没挖出什么东西,所以就不贴流程逻辑了。
function add (event, fn, once) {
if (once) {
target.$once(event, fn) // 即Vue.prototype.$once
} else {
target.$on(event, fn) // 即Vue.prototype.$on
}
}
function remove (event, fn) {
target.$off(event, fn) // 即Vue.prototype.$off
}
到了vue3,vue的事件中心这部分代码被从vue核心模块中抽出。
自定义事件实际上是利用 Vue 定义的事件中心,简单分析一下它的实现: Vue.prototype.$on/$once/$off/$emit
熟悉发布订阅设计模式的朋友,看函数签名,大概就知道其内部是怎么实现的了。对于一个vm
,用vm._events
存储即可。
- 举一反三:父子组件 emit on通信的原理:本质还是利用了事件中心发布订阅 例子说明
vm.$emit
是给当前的vm
上派发的实例- 之所以我们常用它做父子组件通讯,是因为它的回调函数的定义是在父组件中
- 对于我们这个例子而言,当子组件的
button
被点击了,它通过this.$emit('select')
派发事件,
- 那么子组件的实例就监听到了这个
select
事件,并执行它的回调函数——定义在父组件中的selectHandler
方法,这样就相当于完成了一次父子组件的通讯。
总结
- Vue 支持 2 种事件类型,原生 DOM 事件和自定义事件,它们主要的区别在于添加和删除事件的方式不一样
- 自定义事件的派发是往当前组件实例上派发,但是可以利用在父组件环境定义回调函数来实现父子组件的通讯。
- 另外要注意一点,只有组件节点才可以添加自定义事件,并且添加原生 DOM 事件需要使用
native
修饰符;而普通元素使用.native
修饰符是没有作用的,也只能添加原生 DOM 事件。 - 跟 react 事件系统还是有挺多不一样的地方,比如 react会有事件池的概念,回收利用event对象。