Vue 源码(七)事件机制

1,316 阅读6分钟

前言

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

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

钩子函数

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

events也是通过这些钩子初始化绑定的,定义在src/platforms/web/runtime/modules/events.js

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函数

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

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

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变量的值为

{
  name: 'click',
  once: false,
  capture: false,
  passive: false
}

回到updateListeners,对于第一次创建,如果定义的事件没有fns属性,则调用createFnInvoker函数创建一个回调函数

if (isUndef(cur.fns)) {
  cur = on[name] = createFnInvoker(cur, vm)
}
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 保留引用关系,这样就保证了事件回调只添加一次,之后仅仅去修改它的回调函数的引用。

else if (cur !== old) { // 更新时触发, 条件是 cur 和 old 不同
  old.fns = cur
  on[name] = old
}

回到updateListeners,对于第一次创建会调用传入的add函数,并通过addEventListener给DOM绑定事件。

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 >= attachedTimestampe.timeStamp打开页面到执行事件回调的时间),如果成立说明这期间没有更新组件,从而继续执行回调

attachedTimestamp是一个时间戳,在触发组件更新时获取的。如果组件更新了attachedTimestamp肯定大于e.timeStamp

绑定完事件后,在更新阶段,还会执行下面的逻辑

for (name in oldOn) {
  if (isUndef(on[name])) {
    event = normalizeEvent(name)
    // 这里的目的是删除 name 对应的方法
    remove(event.name, oldOn[name], event.capture)
  }
}

遍历oldVnode的所有事件,如果新事件中没有当前事件名,则通过remove删除之前绑定的事件

function remove (
  name: string,
  handler: Function,
  capture: boolean,
  _target?: HTMLElement
) {
  (_target || target).removeEventListener(
    name,
    handler._wrapper || handler,
    capture
  )
}

.once修饰符

updateListeners中如果设置了.once修饰符,会执行传入的createOnceHandler函数

if (isTrue(event.once)) {
  cur = on[name] = createOnceHandler(event.name, cur, event.capture)
}

createOnceHandler会返回一个函数,函数内部就是调用定义的回调,如果返回值不为null,取消监听,也就是说如果定义的回调返回的是null的话,.once会失效

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函数

  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时,有这样一段逻辑

export function createComponent (
  Ctor: Class<Component> | Function | Object | void,
  data: ?VNodeData,
  context: Component,
  children: ?Array<VNode>,
  tag?: string
): VNode | Array<VNode> | 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去创建组件实例;

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函数

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钩子函数

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去更新自定义事件

export function updateChildComponent (
  vm: Component, // 子组件实例
  propsData: ?Object,
  listeners: ?Object,
  parentVnode: MountedComponentVNode, // 组件 vnode
  renderChildren: ?Array<VNode>
) {
  // ...
  
  // update listeners
  listeners = listeners || emptyObject
  // 获取上一次绑定的自定义事件
  const oldListeners = vm.$options._parentListeners
  // 将此次的自定义事件赋值给 _parentListeners
  vm.$options._parentListeners = listeners
  updateComponentListeners(vm, listeners, oldListeners)
  
  // ...
}

首先会获取上一次绑定的自定义事件,然后将此次的自定义事件赋值给_parentListeners;调用updateComponentListeners,并将新老自定义事件传入

updateComponentListeners

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方法

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)删除事件

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中还有一个逻辑

if (isTrue(event.once)) {
  cur = on[name] = createOnceHandler(event.name, cur, event.capture)
}

就是对于有.once修饰符的自定义事件,调用的是Vue.prototype.$once方法

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方法

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

<!DOCTYPE html>
<html lang="en">
    <head>
        <title>Document</title>
        <style>
            .i {
                width: 100px;
                height: 100px;
                background-color: #f00;
            }
        </style>
    </head>
    <body>
        <div id="div1">
            <div class="i">asdfasdfsadf</div>
        </div>
        <script>
            const oDiv = document.querySelector('#div1')
            const oI = document.querySelector('.i')
            oDiv.addEventListener('click', () => {
                console.log('点击了 div')
            })

            oI.addEventListener('click', () => {
                console.log('点击了 i')
                new Promise((resolve) => {
                    resolve()
                }).then(() => {
                    console.log('微任务触发')
                })
            })
        </script>
    </body>
</html>

总结

原生事件原理

有两种原生事件,一种是普通标签上的事件、另一种是组件标签上有.native修饰符的原生事件,这两种的逻辑相同都是通过addEventListener绑定给真实元素的。

自定义事件原理

Vue 实例上有一个_events属性,当创建组件实例时,将自定义事件通过$on添加到_events属性中。当通过$emit触发事件时,从_events属性上查找事件名对应的回调。

$on$emit 是基于发布订阅模式的,维护一个事件中心,$on的时候将事件按名称存在事件中心(vm._events)里,称之为订阅者,然后$emit将对应的事件进行发布,去执行事件中心里的对应的回调

事件修饰符原理

常见的修饰符如下

.stop # 阻止冒泡
.prevent # 阻止默认事件
.self # 只当在 event.target 是当前元素自身时触发处理函数
.capture # 从设置该修饰符元素开始捕获,然后再冒泡
.passive # 告诉浏览器不想阻止事件的默认行为
.once # 只执行一次
.native # 指定该事件为原生事件

他们的原理也不同

编译时处理

stop: '$event.stopPropagation();'
prevent: '$event.preventDefault();'
self: ' if($event.target !== $event.currentTarget) return null;'

对于上述的.stop.prevent.self都是在编译时处理,原理就是创建一个接收$event参数的新函数。新函数内注入修饰符对应的代码并返回回调函数的执行结果。

如果标签上的回调函数没有括号,则会将$event传给回调函数

const Child = {
  template: '<button @click.stop="clickHandle">click me</button>',
  methods: {
    clickHandle (e) {
      console.log(e) // 打印 button 对象
    }
  }
}
// 编译过程创建的新函数
function($event){
  $event.stopPropagation();
  return clickHandle($event)
}

如果标签上的回调函数有括号,不会将$event传给回调函数

const Child = {
  template: '<button @click="clickHandle()">click me</button>',
  methods: {
    clickHandle (e) {
      console.log(e) // 打印 undefined
    }
  }
}
// 编译过程创建的新函数
function($event){
  return clickHandle()
}
.native修饰符

在编译阶段,如果有.native修饰符,会将这个事件添加到data.nativeOn

绑定时处理

.capture
.passive
.once

这三个都是在绑定的时候处理的

target.addEventListener(
  name,
  handler,
  supportsPassive
  ? { capture, passive }
  : capture
)

通过addEventListener绑定事件时,如果有.capture.passive修饰符,则对应的变量为true,会将其设置到addEventListener上。 addEventListener 中的 passive 选项

.once修饰符

普通标签的.once修饰符,创建一个函数,函数内执行回调,执行完后通过removeEventListener删除监听。原生事件

组件标签上的.once修饰符和普通标签的类似,只不过使用的是$once方法。自定义事件

vm.$on( event, callback )原理

  • 参数
    • {string | Array<string>} event (数组只在 2.2.0+ 中支持)
    • {Function} callback

event添加到当前Vue实例的_events中,属性值是callback数组

vm.$emit( eventName, […args] )

  • 参数

    • {string} eventName
    • [...args]

    触发当前实例上的事件。附加参数都会传给监听器回调

根据传入的事件名从当前Vue实例的_events属性上查找对应回调;将参数传入回调并执行

vm.$once( event, callback )

  • 参数
    • {string} event
    • {Function} callback

创建一个新函数,将这个新函数添加到当前Vue实例的_events中。当执行这个新函数时,先调用vm.$off(事件名, 新函数)将事件从vm._events上删除;然后执行这个回调。

vm.$off( [event, callback] )

  • 参数
    • {string | Array<string>} event (只在 2.2.2+ 支持数组)
    • {Function} [callback]

Vue.prototype.$off流程如下

  • 如果没有参数,清空vm._events,并返回实例
  • 如果传入的event是数组,对数组每个元素调用Vue.prototype.$off,并返回实例
  • 如果传入的event是字符串,根据event获取回调函数
    • 如果回调函数不为空并且没有传入fn参数,则将 eventvm._events清除,vm._events[event] = null,并返回实例
    • 如果传入了fn参数,遍历回调函数数组,如果传入的fn参数和回调函数数组中某元素或某元素的fn属性相同,则将这个元素从数组中删除