开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 10 天,点击查看活动详情
事件绑定
前面已经分析了模版上的事件在构建 AST 时的处理过程,并且根据构建的 AST 树得到相应的 render 渲染函数,但是真正的事件绑定还需要绑定注册,这一步发生在组件挂载阶段,事件需要绑定注册到真实 DOM 上才能够被触发。
有了 AST 树之后,接下来会遍历虚拟 DOM 递归调用 createElm 方法为每个字节点创建真实的 DOM , 由于 Vnode 有 data 属性,在创建真实 DOM 时会进行注册相关钩子的过程,其中一个就是注册事件的相关处理。
function createElm(params) {
if (isDef(data)) {
invokeCreateHooks(vnode, insertedVnodeQueue)
}
}
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)
}
}
在开发过程中, 我们经常会定义 v-on 事件 、 v-bind 动态属性等,这些和 v-on 指令一样,都会在编译阶段和 Vnode 生成阶段创建 data 属性,因此 invokeCreateHooks 就是一个模版指令处理的任务,分别针对不同的指令为真实阶段创建不同的任务。针对事件,这里会调用 updateDOMListener 对真实 DOM 节点注册事件任务。
function updateDOMListeners (oldVnode: VNodeWithData, vnode: VNodeWithData) {
// on 是事件指令的标志
if (isUndef(oldVnode.data.on) && isUndef(vnode.data.on)) {
return
}
// 新旧节点不同的事件绑定解绑
const on = vnode.data.on || {}
const oldOn = oldVnode.data.on || {}
// 拿到需要注册事件的真实 DOM
target = vnode.elm
// 对事件的兼容性进行处理
normalizeEvents(on)
updateListeners(on, oldOn, add, remove, createOnceHandler, vnode.context)
target = undefined
}
normalizeEvents 主要是针对 v-model 的处理,例如在 IE 下不支持 change 事件,就需要用 input 事件代替
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)
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)) {
// createFunInvoker 返回事件最终执行的回调函数
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) {
old.fns = cur
on[name] = old
}
}
// 解除旧节点上的事件
for (name in oldOn) {
if (isUndef(on[name])) {
event = normalizeEvent(name)
remove(event.name, oldOn[name], event.capture)
}
}
}
在第一次构建实例时,旧节点是不存在的,此时会调用 createFnInvoker 函数对事件回调函数做一层封装,由于单个事件的回调可以有多个,因此 createFnInvoker 的作用是对单个、多个回调事件统一封装处理,返回一个当事件触发是真正执行的匿名函数
function createFnInvoker (fns: Function | Array<Function>, vm: ?Component): Function {
function invoker () {
const fns = invoker.fns
// 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
}
invokeWithErrorHandling 会执行定义好的回调函数,这里做了同步一步回调的错误处理, try-catch 用于同步回调捕获异常, Promise.catch 用于捕获异步任务返回错误
function invokeWithErrorHandling (
handler: Function,
context: any,
args: null | any[],
vm: any,
info: string
) {
let res
try {
res = args ? handler.apply(context, args) : handler.call(context)
if (res && !res._isVue && isPromise(res) && !res._handled) {
// 当返回的是一个 promise 对象时,捕获到错误时,对错误进行包装并返回
res.catch(e => handleError(e, vm, info + ` (Promise/async)`))
// issue #9511
// avoid catch triggering multiple times when nested calls
res._handled = true
}
} catch (e) {
handleError(e, vm, info)
}
return res
}
如果对事件使用了 once 修饰符,那么事件只会触发一次,则调用 createOnceHandler 进行封装函数
function createOnceHandler (event, handler, capture) {
const _target = target // save current target element in closure
return function onceHandler () {
// 调用事件回调
const res = handler.apply(null, arguments)
if (res !== null) {
// 移除事件绑定
remove(event, onceHandler, capture, _target)
}
}
}
add 和 remove 是真正在 DOM 上绑定事件和移除事件的过程,原理也是使用 DOM 的 addEventListener 和 removeEventListener 方法。
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
)
}
自定义事件
前面已经分析过 Vue 在处理原生 DOM 事件的基本流程,然而在实际开发过程,自定义事件是我们常用的一个机制,例如可以通过自定义事件实现父子组件通信,子组件通过 vm.$emit 像父组件分发事件,父组件通过 v-on:(event) 接收信息并处理回调。先来看下简单的自定义事件的例子。
var child = {
template: `<div @click="emitToParent">点击传递信息给父组件</div>`,
methods: {
emitToParent() {
this.$emit('myevent', 1)
}
}
}
new Vue({
el: '#app',
components: {
child
},
template: `<div id="app"><child @myevent="myevent" @click.native="nativeClick"></child></div>`,
methods: {
myevent(num) {
console.log(num)
},
nativeClick() {
console.log('nativeClick')
}
}
})
在 Vue 中,普通的元素节点上只能使用原生 DOM 事件,而组件上却可以使用自定义事件和原生 DOM 事件,并且可以通过 native 修饰符区分。接下来分析一下 Vue 对自定义事件的处理过程。
模版编译
在进行模版编译生成 AST 树过程中, addHandler 方法会对事件修饰符做不同的处理,当遇到 native 修饰符时,事件相关属性会添加到 nativeEvents 属性中,来看下 child 生成的 AST
代码生成
不管是组件还是普通标签,事件处理代码都在 genData 的过程中,和之前分析原生事件一致, genHandlers 用来处理事件对象并拼接成字符窜
if (el.events) {
data += `${genHandlers(el.events, false)},`
}
if (el.nativeEvents) {
data += `${genHandlers(el.nativeEvents, true)},`
}
生成 render 函数之后,接下来会根据 render 函数创建虚拟 DOM ,在遇到组件占位符节点时,会创建子组件的虚拟 DOM , 此时为 on 、 nativeOn 做了一层转换,将 on 赋值给 listener , 将 nativeOn 赋值给 on , 在创建子组件虚拟 DOM 时,作为组件配置 componentOptions 传入
const listeners = data.on
data.on = data.nativeOn
// 安装组件钩子函数
installComponentHooks(data)
// return a placeholder vnode
const name = Ctor.options.name || tag
const vnode = new VNode(
`vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
data, undefined, undefined, undefined, context,
{ Ctor, propsData, listeners, tag, children },
asyncFactory
)
return vnode
子组件实例
接下来在通过虚拟 DOM 生成真实 DOM 过程中, 遇到组件的虚拟 DOM 会实例化子组件。实例化子组件的过程又回到了之前分析的初始化选项配置的过程,其中针对事件的处理过程关键如下。
Vue.prototype._init = function (options?: Object) {
if (options && options._isComponent) {
// 在初始化子组件时,合并子组件的 options
initInternalComponent(vm, options)
} else {
// 外部调用 new Vue 时合并 options
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
}
// 事件中心初始化
initEvents(vm)
}
function initInternalComponent (vm: Component, options: InternalComponentOptions) {
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
}
}
在进行选项合并过程中,对于组件类型的节点,会调用 initInternalComponent , 在 initInternalComponent 方法中,子组件拿到了在父组件中占位符节点中定义的事件。在接下来的事件初始化过程中,会通过 vm.$options._parentListeners 拿到在父组件自定义的事件,带有自定义事件的组件会执行 updateComponentListeners 方法
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)
}
}
之后又回到了之前分析的 updateListener 过程,与原生 DOM 事件不同的是,自定义事件的添加移除的方法不同
let target: any
function add (event, fn) {
target.$on(event, fn)
}
function remove (event, fn) {
target.$off(event, fn)
}
function updateComponentListeners (
vm: Component,
listeners: Object,
oldListeners: ?Object
) {
target = vm
updateListeners(listeners, oldListeners || {}, add, remove, createOnceHandler, vm)
target = undefined
}
自定义事件是将事件在 Vue 实例上进行添加和移除,而原生 DOM 事件是在真实 DOM 元素上进行的
事件 API
现在回过头来看看在 Vue 引入阶段,对事件的处理做了那些初始化操作。 Vue 实例用一个 _events 属性存储管理事件的派发和更新,暴露出 $on $once $off $emit 方法给外部管理事件和派发执行事件。
export function eventsMixin (Vue: Class<Component>) {
const hookRE = /^hook:/
// $on 方法用来添加事件监听,执行回调
Vue.prototype.$on = function (event: string | Array<string>, fn: Function): Component {
const vm: Component = this
if (Array.isArray(event)) {
// 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
}
// $once 方法,用来注册一次性事件,事件被触发一次之后就会注销事件
Vue.prototype.$once = function (event: string, fn: Function): Component {
const vm: Component = this
function on () {
// 对 fn 做一层封装,先解除绑定在执行事件
vm.$off(event, on)
fn.apply(vm, arguments)
}
on.fn = fn
vm.$on(event, on)
return vm
}
// $off 方法用来注销事件监听
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
}
// 触发回调
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
}
}
export function invokeWithErrorHandling (
handler: Function,
context: any,
args: null | any[],
vm: any,
info: string
) {
let res
try {
res = args ? handler.apply(context, args) : handler.call(context)
if (res && !res._isVue && isPromise(res) && !res._handled) {
res.catch(e => handleError(e, vm, info + ` (Promise/async)`))
// issue #9511
// avoid catch triggering multiple times when nested calls
res._handled = true
}
} catch (e) {
handleError(e, vm, info)
}
return res
}
组件通过 this.$emit 在组件实例中派发了事件,而在这之前,组件已经将需要监听的事件以及回调添加到实例的 _events 属性中,触发事件是便可以直接执行监听事件的回调。
在使用自定义事件进行父子组件通信时,组件自定义事件的触发和监听实际上都是在当前组件实例中进行,之所以能进行父子组件通信,是因为事件监听的回调是在父组件中定义的。