Vue 源码(七)事件机制


highlight: tomorrow-night-eighties

theme: cyanosis

前言

通过这篇文章可以了解如下内容

  • 原生事件原理
  • 事件修饰符原理
  • 自定义事件原理
  • $emit原理
  • $on原理
  • $off原理
  • $once原理

钩子函数

执行patch函数之前,会注册一些钩子函数。用于设置DOM元素相关的属性、样式、事件、指令等。这些钩子函数在 patch 不同时机执行,比如createupdate

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

原生事件

对于事件来说,它的createupdate钩子就是执行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.onvnode.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函数根据事件名中一些特殊标识,区分出这个事件是否有 oncecapturepassive 等修饰符;最终返回一个对象赋值给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去执行定义的回调

小结

总流程如下

events.jpg

在patch过程中,会触发createupdate钩子函数,遍历所有的事件,获取事件名和使用到的修饰符;如果是第一次创建,会创建一个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参数,则将 eventvm._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包装了一层,当触发回调时,先将当前fnvm._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方法就是根据传入的eventvm._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

Document
asdfasdfsadf
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值