Vue2源码【8-1】event

event即事件系统,这部分源码让人看得头皮发麻。但是理解之后发现这部分的逻辑是很重要的,在这里试着用几句话概括流程。

事件系统分为“DOM 事件”和“自定义事件”,分别为两套处理流程,先有个大概的印象即可。

首先会有编译的过程,按照惯例其源码就略过了。

// 父组件生成的 `data` 串为:
{
  on: {"select": selectHandler}, // 针对组件的自定义事件
  nativeOn: {"click": function($event) { // 原生事件
      $event.preventDefault();
      return clickHandler($event)
    }
  }
}
// 子组件生成的 `data` 串为:
{
  on: {"click": function($event) { // 因为渲染节点根节点是原生dom,所以这个也是原生事件
      clickHandler($event)
    }
  }
}

所谓父组件,其实就是“占位符节点”。而子组件,就是该组件.vue文件中所声明的组件。

DOM 事件

**DOM 事件(也就是相对而言的原生事件绑定):在 patch过程中的创建阶段和更新阶段执行 updateDOMListeners 注册 DOM 事件。**代码例子如:

  • '<child @select=“selectHandler” @click.native.prevent=“clickHandler”>`
  • <button @click="clickHandler($event)">' + 'click me' + '</button> in child component

无论是对于占位符节点和 渲染节点,它们其实事件都是绑定到同一个真实的dom上。因为渲染节点的逻辑先执行,所以它的会先绑定。 在例子中也就是<button>@click + <child>@click.native

这个机制的代码流程的处理真的太恶心了,就不贴出来了。大概用文字总结一下,在debug的时候大概知道是属于哪里的问题。

  • 在 patch过程中的创建阶段和更新阶段执行 updateDOMListeners 注册 DOM 事件。
    • 遍历 on 去添加事件监听,遍历 oldOn 去移除事件监听
    • event = normalizeEvent(name):根据我们的的事件名的一些特殊标识(之前在 addHandler 的时候添加上的)区分出这个事件是否有 oncecapturepassive 等修饰符。
    • 按照计算结果更新所绑定的回调。这里vue有一个巧妙的机制,能够减小绑定机制的性能消耗,并且防止内存溢出。

vue优化js DOM事件绑定的机制

  • 事件回调在第一次只添加一次,之后仅仅去修改它的回调函数的引用指向。其实就是invoke函数创建好了,知道它每次用到invoke.fns,如果更新则更新invoke.fns就好了
    • 【对于第一次执行】执行 cur = on[name] = createFnInvoker(cur) 方法去创建一个回调函数,然后在执行 add(event.name, cur, event.once, event.capture, event.passive, event.params) 完成一次事件绑定
    • createFnInvoker:返回一个函数,它持有一个fns属性,并且它本身的逻辑就是遍历执行这些函数
    • 【对于第一次之后执行】:只需要更改 old.fns = cur 把之前绑定的 involer.fns 赋值为这一回新的回调函数即可,并且 通过 on[name] = old 保留引用关系
    • 根据相关预设移除事件回调:updateListeners 函数的最后遍历 oldOn 拿到事件名称,判断如果满足 isUndef(on[name]),则执行 remove(event.name, oldOn[name], event.capture) 去移除旧vnode相关的事件回调。
    • (createFnInvoker源码那里有clone一下invoker.fns的,因为invoker.fns它们不是马上执行,后面还会篡改invoker.fns属性再用于设置回调函数,所以先转移到另外一个变量来持有)

源码就不贴了,这段逻辑可以简化为示例代码:

  // on: {'click': invoker持有fns, 'mouseover': invoker持有fns }
  for (name in on) {
    if ('初始化组件的事件') {
    cur = on[name] = /* 新创建的invoke并持有fns。 createFnInvoker 在这里执行 */
      domAddEventListener(event.name, cur, event.capture, event.passive, event.params); // [name]作为绑定到dom上的事件回调,是会调用到 它上面的fns的。
    } else if (cur !== old) { // else if 更新组件的事件
      old.fns = cur;
      on[name] = old; // 不重新addEventListener到DOM上,通过这2行更新fns来更新要执行的回调
    }
  }

withMacroTask技法

这个技法其实在vue源码中出现了多次。类似的还有withCommit之类的,都是高阶函数。

既然是事件回调的处理,肯定有封装对原生api的调用。addremove 的逻辑很简单,就是实际上调用原生 addEventListenerremoveEventListener,并根据参数传递一些配置,注意这里的 hanlder 会用 withMacroTask(hanlder) 包裹一下,它的定义在 src/core/util/next-tick.js 中:

export function withMacroTask (fn) {
  return fn._withTask || (fn._withTask = function () {
    useMacroTask = true // 这个变量在对应nextTick相关的闭包里面
    const res = fn.apply(null, arguments)
    useMacroTask = false
    return res
  })
}

实际上就是强制:如果在 DOM 事件的回调函数执行期间如果修改了数据,那么这些数据更改推入的队列会被当做 macroTasknextTick 后执行。

注意原生dom事件本来就是属于宏任务。

自定义事件

“DOM 事件” vs “自定义事件”。一开就明白了对立的关系。

自定义事件的具体工作流程好像没挖出什么东西,所以就不贴流程逻辑了。

function add (event, fn, once) {
  if (once) {
    target.$once(event, fn) // 即Vue.prototype.$once
  } else {
    target.$on(event, fn) // 即Vue.prototype.$on
  }
}

function remove (event, fn) {
  target.$off(event, fn) // 即Vue.prototype.$off
}

到了vue3,vue的事件中心这部分代码被从vue核心模块中抽出。

自定义事件实际上是利用 Vue 定义的事件中心,简单分析一下它的实现: Vue.prototype.$on/$once/$off/$emit

熟悉发布订阅设计模式的朋友,看函数签名,大概就知道其内部是怎么实现的了。对于一个vm,用vm._events存储即可。

  • 举一反三:父子组件 emit on通信的原理:本质还是利用了事件中心发布订阅 例子说明
    • vm.$emit 是给当前的 vm 上派发的实例
      • 之所以我们常用它做父子组件通讯,是因为它的回调函数的定义是在父组件中
      • 对于我们这个例子而言,当子组件的 button 被点击了,它通过 this.$emit('select') 派发事件,
    • 那么子组件的实例就监听到了这个 select 事件,并执行它的回调函数——定义在父组件中的 selectHandler 方法,这样就相当于完成了一次父子组件的通讯。

总结

  • Vue 支持 2 种事件类型,原生 DOM 事件和自定义事件,它们主要的区别在于添加和删除事件的方式不一样
  • 自定义事件的派发是往当前组件实例上派发,但是可以利用在父组件环境定义回调函数来实现父子组件的通讯。
  • 另外要注意一点,只有组件节点才可以添加自定义事件,并且添加原生 DOM 事件需要使用 native 修饰符;而普通元素使用 .native 修饰符是没有作用的,也只能添加原生 DOM 事件。
  • 跟 react 事件系统还是有挺多不一样的地方,比如 react会有事件池的概念,回收利用event对象。
文章目录
  1. DOM 事件
    1. vue优化js DOM事件绑定的机制
    2. withMacroTask技法
  2. 自定义事件
  3. 总结