highlight: tomorrow-night-eighties
theme: cyanosis
前言
通过这篇文章可以了解如下内容
- 原生事件原理
- 事件修饰符原理
- 自定义事件原理
$emit
原理$on
原理$off
原理$once
原理
钩子函数
执行patch
函数之前,会注册一些钩子函数。用于设置DOM元素相关的属性、样式、事件、指令等。这些钩子函数在 patch 不同时机执行,比如create
、update
events
也是通过这些钩子初始化绑定的,定义在src/platforms/web/runtime/modules/events.js
中
javascript export default { create: updateDOMListeners, update: updateDOMListeners }
可以看到,events
会抛出两个钩子,一个是create
,另一个是update
。这俩钩子都是执行updateDOMListeners
这个方法
在 patch 过程中,有三个地方会执行create
钩子函数
- 通过
createElm
创建 DOM 元素时,执行create
钩子并将当前VNode传入;cbs.create[i](emptyNode, vnode)
- 其次是在更新过程中,如果组件的新老根元素不同,当组件的渲染VNode更新完成后会更新组件占位符VNode 的
elm
属性,此时会执行create
钩子并将当前组件占位符VNode传入 - 通过
createComponent
创建完组件的渲染VNode后,执行create
钩子并将当前组件占位符VNode传入
只有一个地方会执行update
钩子函数
- 在
patchVnode
函数中,更新子组件后、比对新老子节点前会执行update
钩子,传入新老节点cbs.update[i](oldVnode, vnode)
普通VNode
原生事件
对于事件来说,它的create
和update
钩子就是执行updateDOMListeners
函数
javascript function updateDOMListeners (oldVnode: VNodeWithData, vnode: VNodeWithData) { if (isUndef(oldVnode.data.on) && isUndef(vnode.data.on)) { return } const on = vnode.data.on || {} const oldOn = oldVnode.data.on || {} target = vnode.elm normalizeEvents(on) updateListeners(on, oldOn, add, remove, createOnceHandler, vnode.context) target = undefined }
create
钩子执行updateDOMListeners
时,oldVnode
始终为空VNode,只传入了第二个参数VNode
首先会判断oldVnode.data.on
和vnode.data.on
是不是为空;然后调用normalizeEvents
函数,normalizeEvents
主要是对 v-model
相关的处理;接下来会执行updateListeners
函数
updateListeners
函数定义在src/core/vdom/helpers/update-listeners.js
中
javascript export 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)) { 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) { // 更新时触发, 条件是 cur 和 old 不同 old.fns = cur on[name] = old } } // 遍历 oldOn,如果事件中没有 名为 name 的事件,则删除 oldOn 中的事件 for (name in oldOn) { if (isUndef(on[name])) { event = normalizeEvent(name) // 这里的目的是删除 name 对应的方法 remove(event.name, oldOn[name], event.capture) } } }
updateListeners
函数会遍历新VNode中所有的事件,获取事件名,并调用normalizeEvent
javascript const normalizeEvent = cached((name: string): { name: string, once: boolean, capture: boolean, passive: boolean, handler?: Function, params?: Array<any> } => { const passive = name.charAt(0) === '&' name = passive ? name.slice(1) : name const once = name.charAt(0) === '~' // Prefixed last, checked first name = once ? name.slice(1) : name const capture = name.charAt(0) === '!' name = capture ? name.slice(1) : name return { name, once, capture, passive } })
normalizeEvent
函数根据事件名中一些特殊标识,区分出这个事件是否有 once
、capture
、passive
等修饰符;最终返回一个对象赋值给event
变量,即 updateListeners
函数中的event
变量的值为
javascript { name: 'click', once: false, capture: false, passive: false }
回到updateListeners
,对于第一次创建,如果定义的事件没有fns
属性,则调用createFnInvoker
函数创建一个回调函数
javascript if (isUndef(cur.fns)) { cur = on[name] = createFnInvoker(cur, vm) }
javascript export function createFnInvoker (fns: Function | Array<Function>, vm: ?Component): Function { function invoker () {} invoker.fns = fns return invoker }
createFnInvoker
函数定义了一个invoker
函数,给invoker
函数添加了一个属性fns
,属性值就是定义事件的回调函数;最终返回invoker
函数并赋值给on[name]
。
更新时,再次执行updateListeners
函数,判断如果 cur !== old
,那么只需要更改 old.fns = cur
把之前绑定的 involer.fns
赋值为新的回调函数即可,并且通过 on[name] = old
保留引用关系,这样就保证了事件回调只添加一次,之后仅仅去修改它的回调函数的引用。
javascript else if (cur !== old) { // 更新时触发, 条件是 cur 和 old 不同 old.fns = cur on[name] = old }
回到updateListeners
,对于第一次创建会调用传入的add
函数,并通过addEventListener
给DOM绑定事件。
javascript function add ( name: string, handler: Function, capture: boolean, passive: boolean ) { if (useMicrotaskFix) { const attachedTimestamp = currentFlushTimestamp const original = handler handler = original._wrapper = function (e) { if ( e.target === e.currentTarget || e.timeStamp >= attachedTimestamp || e.timeStamp <= 0 || e.target.ownerDocument !== document ) { return original.apply(this, arguments) } } } target.addEventListener( name, handler, supportsPassive ? { capture, passive } : capture ) }
至于上面的逻辑是解决一个在微任务下 事件冒泡的bug,将 Vue 版本改成2.4.0
,会发现每次点击,A、B变量都会增加,并且文字不变。这个原因其实就是因为点击父元素触发组件更新,因为更新是在微任务中,导致执行顺序优先于事件冒泡机制。也就是说当组件更新完成之后事件冒泡机制继续执行,发现div
标签上也有点击事件,接着触发点击事件导致上面的bug。在最后我会放一个 Demo,感兴趣的可以试下
这个的解决方案首先如果e.target === e.currentTarget
成立说明是当前元素的回调被触发,会执行这个回调。反之,说明当前阶段是事件冒泡阶段,判断e.timeStamp >= attachedTimestamp
(e.timeStamp
打开页面到执行事件回调的时间),如果成立说明这期间没有更新组件,从而继续执行回调
attachedTimestamp
是一个时间戳,在触发组件更新时获取的。如果组件更新了attachedTimestamp
肯定大于e.timeStamp
。
绑定完事件后,在更新阶段,还会执行下面的逻辑
javascript for (name in oldOn) { if (isUndef(on[name])) { event = normalizeEvent(name) // 这里的目的是删除 name 对应的方法 remove(event.name, oldOn[name], event.capture) } }
遍历oldVnode
的所有事件,如果新事件中没有当前事件名,则通过remove
删除之前绑定的事件
javascript function remove ( name: string, handler: Function, capture: boolean, _target?: HTMLElement ) { (_target || target).removeEventListener( name, handler._wrapper || handler, capture ) }
.once
修饰符
在updateListeners
中如果设置了.once
修饰符,会执行传入的createOnceHandler
函数
javascript if (isTrue(event.once)) { cur = on[name] = createOnceHandler(event.name, cur, event.capture) }
createOnceHandler
会返回一个函数,函数内部就是调用定义的回调,如果返回值不为null
,取消监听,也就是说如果定义的回调返回的是null
的话,.once
会失效
javascript function createOnceHandler (event, handler, capture) { const _target = target return function onceHandler () { const res = handler.apply(null, arguments) if (res !== null) { remove(event, onceHandler, capture, _target) } } }
触发执行
以点击为例,当用户点击时,触发回调执行invoker
函数 javascript function invoker () { const fns = invoker.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
属性,并通过invokeWithErrorHandling
去执行定义的回调
小结
总流程如下
在patch过程中,会触发create
和update
钩子函数,遍历所有的事件,获取事件名和使用到的修饰符;如果是第一次创建,会创建一个invoker
方法,并给这个方法绑定一个属性fns
用于存储定义的回调函数;然后通过addEventListener
绑定事件。如果是更新过程并且事件名对应的回调函数和之前的不同,则会修改绑定属性fns
的值;触发的回调就是fns`的属值;这样做的好处是不需要二次绑定,只绑定一次就行。最后,如果新事件中没有对应事件名,会取消事件监听。
组件占位符VNode
接下来看下组件占位符VNode的事件;分为两种,一种是原生事件,一种是自定义事件
原生事件
通过createComponent
创建组件VNode时,有这样一段逻辑 ```javascript export function createComponent ( Ctor: Class
| Function | Object | void, data: ?VNodeData, context: Component, children: ?Array
, tag?: string ): VNode | Array
| void {
data = data || {} // 拿到自定义事件 const listeners = data.on // 将 nativeOn 赋值给 data.on data.on = data.nativeOn
const vnode = new VNode( vue-component-${Ctor.cid}${name ?
-${name}: ''}
, data, undefined, undefined, undefined, context, { Ctor, propsData, listeners, tag, children }, asyncFactory )
return vnode } ```
将原生事件添加到data.on
中,而自定义事件添加到组件VNode 的componentOptions.listeners
中;接下来进入patch过程,对于组件VNode会执行createComponent
创建组件实例和组件的DOM树,创建完成后,修改组件VNode的elm
属性并执行cbs.create
中所有的钩子函数,剩下逻辑就已经和普通DOM的事件绑定流程一样了。
需要注意的是,对组件占位符VNode调用cbs.create
时,会将组件标签上的原生事件挂载到vnode.elm
上,也就是组件的根元素上
自定义事件
上面代码中,自定义事件添加到了组件VNode 的componentOptions.listeners
中;接下来分别说下创建和更新两个过程是怎么处理自定义事件的
创建过程
在patch过程中,如果VNode是组件占位符VNode,会调用createComponent
函数,createComponent
函数内又调用init
钩子函数,而init
钩子函数内会调用createComponentInstanceForVnode
去创建组件实例;
javascript export function createComponentInstanceForVnode ( vnode: any, // we know it's MountedComponentVNode but flow doesn't parent: any, // activeInstance in lifecycle state ): Component { // 给组件vue实例的 options 添加 _isComponent、_parentVnode、parent 属性 const options: InternalComponentOptions = { _isComponent: true, _parentVnode: vnode, parent } return new vnode.componentOptions.Ctor(options) }
在创建实例的过程中调用initEvents
函数
javascript export function initEvents (vm: Component) { vm._events = Object.create(null) vm._hasHookEvent = false // 获取自定义事件,合并 options 时添加的 _parentListeners 属性 const listeners = vm.$options._parentListeners if (listeners) { updateComponentListeners(vm, listeners) } }
initEvents
函数内如果有自定义事件,会调用updateComponentListeners
函数;更新过程也会调用updateComponentListeners
函数,下面一起说
更新过程
在patch过程中,如果VNode是组件VNode,会调用prepatch
钩子函数
javascript prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) { const options = vnode.componentOptions const child = vnode.componentInstance = oldVnode.componentInstance updateChildComponent( child, options.propsData, // updated props 传入子组件的最新的 props 值 options.listeners, // updated listeners 自定义事件 vnode, // new parent vnode options.children // new children ) },
prepatch
钩子函数内又调用updateChildComponent
去更新自定义事件
```javascript export function updateChildComponent ( vm: Component, // 子组件实例 propsData: ?Object, listeners: ?Object, parentVnode: MountedComponentVNode, // 组件 vnode renderChildren: ?Array ) { // ...
// update listeners listeners = listeners || emptyObject // 获取上一次绑定的自定义事件 const oldListeners = vm.$options.parentListeners // 将此次的自定义事件赋值给 _parentListeners vm.$options.parentListeners = listeners updateComponentListeners(vm, listeners, oldListeners)
// ... } ```
首先会获取上一次绑定的自定义事件,然后将此次的自定义事件赋值给_parentListeners
;调用updateComponentListeners
,并将新老自定义事件传入
updateComponentListeners
javascript export function updateComponentListeners ( vm: Component, listeners: Object, oldListeners: ?Object ) { target = vm updateListeners(listeners, oldListeners || {}, add, remove, createOnceHandler, vm) target = undefined }
updateComponentListeners
函数内调用updateListeners
函数;上面已经说过,在首次创建时,会创建一个invoker
方法,并给这个方法添加一个属性fns
用于存放定义的回调。然后就是调用传入的add
函数。
对于自定义事件的add
函数就是调用Vue.prototype.$on
方法
javascript Vue.prototype.$on = function (event: string | Array<string>, fn: Function): Component { const vm: Component = this if (Array.isArray(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 }
当执行 vm.$on(event, fn)
时,根据事件名称 event
把回调函数 fn
存到当前实例的_events
属性中,vm._events[event].push(fn)
;注意vm._events[event]
是一个数组
回到updateListeners
,如果是更新流程,并且新老回调不同,则修改fns
属性值。最后会遍历oldVnode
的所有自定义事件,如果在新VNode中没有相同事件名,则通过vm.$off(event, fn)
删除事件
javascript 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] // cb.fn === fn:看 $once 的实现 if (cb === fn || cb.fn === fn) { cbs.splice(i, 1) break } } return vm }
Vue.prototype.$off
流程如下
- 如果没有参数,清空
vm._events
,并返回实例 - 如果传入的
event
是数组,对数组每个元素调用Vue.prototype.$off
,并返回实例 - 如果传入的
event
是字符串,根据event
获取回调函数- 如果回调函数不为空并且没有传入
fn
参数,则将event
从vm._events
清除,vm._events[event] = null
,并返回实例 - 如果传入了
fn
参数,遍历回调函数数组,如果传入的fn
参数和回调函数数组中某元素或某元素的fn
属性相同,则将这个元素从数组中删除
- 如果回调函数不为空并且没有传入
.once修饰符
在updateListeners
中还有一个逻辑
javascript if (isTrue(event.once)) { cur = on[name] = createOnceHandler(event.name, cur, event.capture) }
就是对于有.once
修饰符的自定义事件,调用的是Vue.prototype.$once
方法
javascript Vue.prototype.$once = function (event: string, fn: Function): Component { const vm: Component = this // 包装了一层,用于执行完之后删除对应方法 function on () { vm.$off(event, on) fn.apply(vm, arguments) } on.fn = fn vm.$on(event, on) return vm }
Vue.prototype.$once
方法就是将fn
包装了一层,当触发回调时,先将当前fn
从vm._events
中删除,然后再执行。
触发回调
触发方式就是通过this.$emit
方法
javascript Vue.prototype.$emit = function (event: string): Component { const vm: Component = this if (process.env.NODE_ENV !== 'production') { const lowerCaseEvent = event.toLowerCase() if (lowerCaseEvent !== event && vm._events[lowerCaseEvent]) { tip() } } 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 }
Vue.prototype.$emit
方法就是根据传入的event
从vm._events
中获取对应的回调,然后执行回调
小结
组件VNode有两种事件一种是有.native
修饰符的原生事件,另一种是自定义事件
对于原生事件:在创建组件VNode时,将有native
修饰符的事件添加到data.on
中;接着进入patch过程 - 创建阶段,会创建组件VNode的Vue实例和组件的DOM树;创建完成后执行create
钩子函数;create
钩子函数中会获取组件根元素(elm
),接下来会创建一个invoker
方法并给这个方法添加一个属性值fns
用于存放定义的回调函数;最后通过addEventListener
对 组件的根元素 添加事件监听 - 更新过程就是调用update
钩子函数,如果没有老事件,就创建一个invoker
方法,如果新老事件的回调函数不同,修改invoker
方法的fns
属性;最后取消监听新节点中没有的事件。
对于自定义事件: - 创建阶段:在创建组件Vue实例时,为自定义事件创建invoker
方法并对方法设置fns
属性;收集这些自定义事件到vm._events
中;当调用this.$emit
时,会从vm._events
中拿到对应回调函数并触发。 - 更新过程:遍历新VNode的自定义事件,如果老VNode中没有相同事件名,则创建invoker
方法并对方法设置fns
属性,将invoker
方法通过Vue.prototype.$on
添加到vm._events
中;如果在老VNode中有相同事件名的自定义事件,并且新老回调不同,会修改invoker
方法的fns
属性,并将invoker
方法赋值给新事件。最后将通过Vue.protorype.$off
将新自定义事件中没有的事件名从vm._events
中删除
Demo
```html