我们平时开发工作中,处理组件间的通讯,原生的交互,都离不开事件。对于一个组件元素,我们不仅仅可以绑定原生的 DOM 事件
,还可以绑定自定义事件
,非常灵活和方便。那么接下来我们从源码角度来看看它的实现原理。
为了更加直观,我们通过一个例子来分析它的实现:
1 | let Child = { |
1.编译
先从编译阶段开始看起,在 parse
阶段,会执行 processAttrs
方法,它的定义在 src/compiler/parser/index.js
中:
1 | export const onRE = /^@|^v-on:/ |
在对标签属性的处理过程中,判断如果是指令,首先通过 parseModifiers
解析出修饰符:
1 | const modifierRE = /\.[^.\]]+(?=[^\]]*$)/g |
回到 processAttrs
方法,接着判断如果事件的指令,则执行 addHandler(el, name, value, modifiers, false, warn)
方法,它的定义在 src/compiler/helpers.js
中:
1 | function addHandler( |
addHandler 函数看起来长,实际上就做了 3 件事情:
- 首先根据
modifier
修饰符对事件名name
做处理 - 接着根据
modifier.native
判断是一个纯原生事件还是普通事件
,分别对应el.nativeEvents
和el.events
- 最后按照
name
对事件做归类,并把回调函数的字符串保留到对应的事件中。
在我们的例子中,父组件的 · 节点生成的 el.events
和 el.nativeEvents
如下:
1 | el.events = { |
子组件的 button
节点生成的 el.events
如下:
1 | el.events = { |
然后在 codegen
的阶段,会在 genData
函数中根据 AST
元素节点上的 events
和 nativeEvents
生成 data 数据
,它的定义在 src/compiler/codegen/index.js
中:
1 | export function genData (el: ASTElement, state: CodegenState): string { |
对于这两个属性,会调用 genHandlers
函数,定义在 src/compiler/codegen/events.js
中:
1 | function genHandlers( |
1 | const fnExpRE = /^\s*([\w$_]+|\([^)]*?\))\s*=>|^function\s*\(/ |
genHandlers
方法遍历事件对象 events
,对同一个事件名称的事件调用 genHandler(name, events[name])
方法,它的内容看起来多:
- 首先先判断如果
handler
是一个数组,就遍历它然后递归调用genHandler
方法并拼接结果 - 然后判断
hanlder.value
是一个函数的调用路径
还是一个函数表达式
- 接着对
modifiers
做判断,对于没有modifiers
的情况,就根据handler.value
不同情况处理:要么直接返回,要么返回一个函数包裹的表达式 - 对于有
modifiers
的情况,则对各种不同的modifer
情况做不同处理,添加相应的代码串。
那么对于我们的例子而言,父组件生成的 data
串为:
1 | { |
子组件生成的 data
串为:
1 | { |
那么到这里,编译部分完了,接下来我们来看一下运行时部分是如何实现的。
其实 Vue 的事件有 2 种,一种是原生 DOM 事件
,一种是用户自定义事件
,我们分别来看。
2.DOM事件
还记得我们之前在 patch
的时候执行各种 module
的钩子函数吗,当时这部分是略过的,我们之前只分析了 DOM 是如何渲染的,而 DOM 元素相关的属性、样式、事件等都是通过这些 module
的钩子函数
完成设置的。
所有和 web 相关的 module
都定义在 src/platforms/web/runtime/modules
目录下,我们这次只关注目录下的 events.js
即可。
在 patch
过程中的创建阶段和更新阶段都会执行 updateDOMListeners
:
1 | function updateDOMListeners(oldVnode, vnode) { |
首先获取 vnode.data.on
,这就是我们之前的生成的 data
中对应的事件对象,target
是当前 vnode
对于的 DOM 对象`
,normalizeEvents主要是对
v-model相关的处理,我们之后分析
v-model的时候会介绍,接着调用
updateListeners(on, oldOn, add, remove, vnode.context)方法,它的定义在
src/core/vdom/helpers/update-listeners.js` 中:
1 | function updateListeners( |
updateListeners
的逻辑很简单,遍历 on
去添加事件监听
,遍历 oldOn
去移除事件监听
,关于监听和移除事件的方法都是外部传入的,因为它既处理原生 DOM 事件的添加删除,也处理自定义事件的添加删除。
对于 on 的遍历,首先获得每一个事件名,然后做 normalizeEvent
的处理:
1 | var normalizeEvent = cached(function (name) { |
根据我们的的事件名的一些特殊标识(之前在 addHandler
的时候添加上的)区分出这个事件是否有 once
、capture
、passive
等修饰符。
处理完事件名后,又对事件回调函数做处理,对于第一次,满足 isUndef(old)
并且 isUndef(cur.fns)
- 执行
cur = on[name] = createFnInvoker(cur)
方法去创建一个回调函数 - 执行
add(event.name, cur, event.once, event.capture, event.passive, event.params)
完成一次事件绑定。
创建一个回调函数
我们先看一下 createFnInvoker
的实现:
1 | function createFnInvoker(fns, vm) { |
这里定义了 invoker
方法并返回,由于一个事件可能会对应多个回调函数,所以这里做了数组的判断,多个回调函数就依次调用
。
注意最后的赋值逻辑, invoker.fns = fns
,每一次执行 invoker
函数都是从 invoker.fns
里取执行的回调函数,回到 updateListeners
,当我们第二次执行该函数的时候,判断如果 cur !== old
,那么只需要更改 old.fns = cur
把之前绑定的 involer.fns
赋值为新的回调函数即可,并且 通过 on[name] = old
保留引用关系,这样就保证了事件回调只添加一次,之后仅仅去修改它的回调函数的引用。
updateListeners
函数的最后遍历 oldOn
拿到事件名称,判断如果满足 isUndef(on[name])
,则执行 remove(event.name, oldOn[name], event.capture)
去移除事件回调。
事件绑定
了解了 updateListeners
的实现后,我们来看一下在原生 DOM 事件中真正添加回调
和移除回调函数
的实现,它们的定义都在 src/platforms/web/runtime/modules/event.js
中:
1 | var useMicrotaskFix = isUsingMicroTask && !(isFF && Number(isFF[1]) <= 53); |
add
和 remove
的逻辑很简单,就是实际上调用原生 addEventListener
和 removeEventListener
,并根据参数传递一些配置。
3.自定义事件
除了原生 DOM 事件,Vue 还支持了自定义事件,并且自定义事件只能作用在组件上,如果在组件上使用原生事件,需要加 .native
修饰符,普通元素上使用 .native
修饰符无效,接下来我们就来分析它的实现。
在 render
阶段,如果是一个组件节点
,则通过 createComponent
创建一个组件vnode
,我们再来回顾这个方法,定义在 src/core/vdom/create-component.js
中:
1 | export function createComponent ( |
我们只关注事件相关的逻辑,可以看到,它把 data.on
赋值给了 listeners
,把 data.nativeOn
赋值给了 data.on
,这样所有的原生 DOM 事件处理跟我们刚才介绍的一样,它是在当前组件环境中处理的。而对于自定义事件
,我们把 listeners
作为 vnode
的 componentOptions
传入,它是在子组件初始化阶段
中处理的,所以它的处理环境是子组件
。
然后在子组件的初始化
的时候,会执行 initInternalComponent
方法,它的定义在 src/core/instance/init.js
中:
1 | export function initInternalComponent (vm: Component, options: InternalComponentOptions) { |
这里拿到了父组件传入的 listeners
,然后在执行 initEvents
的过程中,会处理这个 listeners
,定义在 src/core/instance/events.js
中:
1 | export function initEvents (vm: Component) { |
拿到 listeners
后,执行 updateComponentListeners(vm, listeners)
方法:
1 | let target: any |
updateListeners
我们之前介绍过,所以对于自定义事件
和原生 DOM 事件
处理的差异就在事件添加
和删除
的实现上,来看一下自定义事件 add
和 remove
的实现:
1 | function add (event, fn, once) { |
实际上是利用 Vue 定义的事件中心
,简单分析一下它的实现:
1 | function initEvents(vm) { |
非常经典的事件中心的实现:
- 把所有的
事件
用vm._events
存储起来 - 当执行
vm.$on(event,fn)
的时候,按事件的名称event
把回调函数 fn
存储起来(vm._events[event].push(fn)
) - 当执行
vm.$emit(event)
的时候,根据事件名 event 找到所有的回调函数:(let cbs = vm._events[event]
),然后遍历执行所有的回调函数。 - 当执行
vm.$off(event,fn)
的时候会移除
指定事件名 event 和指定的 fn; - 当执行
vm.$once(event,fn)
的时候,内部就是执行vm.$on
,并且当回调函数执行一次后再通过vm.$off
移除事件的回调,这样就确保了回调函数只执行一次。
所以对于用户自定义的事件添加和删除就是利用了这几个事件中心的 API。需要注意的事一点,vm.$emit
是给当前的vm上
派发的实例,之所以我们常用它做父子组件通讯
,是因为它的回调函数
的定义是在父组件中
。
对于我们这个例子而言,当子组件的 button 被点击了,它通过 this.$emit('select')
派发事件,那么子组件的实例就监听到了这个 select 事件,并执行它的回调函数 —— 定义在父组件中的 selectHandler
方法,这样就相当于完成了一次父子组件的通讯。
总结:
那么至此我们对 Vue 的事件实现有了进一步的了解。
Vue 支持 2 种事件类型,原生 DOM 事件
和自定义事件
,它们主要的区别在于添加
和删除事件
的方式不一样,并且自定义事件的派发是往当前实例上派发,但是可以利用在父组件环境定义回调函数来实现父子组件的通讯。
另外:
- 组件节点:
- 能添加添加自定义事件。
- 添加
原生 DOM 事件
需要使用native 修饰符
;
- 普通元素:
- 只能添加原生 DOM 事件
- 使用
.native
修饰符没有作用