Vue 的事件绑定原理

技术 3月 16, 2021

Vue 里的事件主要有两种,第一种是绑定在原生 DOM 上的事件,第二种是绑定在组件上的自定义事件。文章会详细对两者的相同点和不同点展开讲解。

基本使用


在 template 中使用 v-on 或者其语法糖 `` 可以快速的在节点上添加事件。
同时可以添加修饰符来对事件触发方式和其副作用进行快速的设定。

<div id="app">
  <button v-on:click="console.log('DOM Listener')">button</button>
  <EventComponentExample @click="ListenerComponentEvent" />
</div>

原理

AST


我们先查看 template 对应生成 AST 是怎么样的。
由于 AST 阶段无法判断节点是原生 DOM 还是组件,所以在这个阶段节点事件的编译是没有区分的。

[
        {
            "type": 1,
            "tag": "button",
            "attrsList": [
                {
                    "name": "v-on:click",
                    "value": "console.log('DOM Listener')"
                }
            ],
            "attrsMap": {
                "v-on:click": "console.log('DOM Listener')"
            },
            "children": [
                {
                    "type": 3,
                    "text": "button",
                    "static": true
                }
            ],
            "hasBindings": true,
            "events": {
                "click": {
                    "value": "console.log('DOM Listener')",
                    "dynamic": false
                }
            }
        },
        {
            "type": 1,
            "tag": "EventComponentExample",
            "attrsList": [
                {
                    "name": "@click",
                    "value": "ListenerComponentEvent"
                }
            ],
            "attrsMap": {
                "@click": "ListenerComponentEvent"
            },
            "hasBindings": true,
            "events": {
                "click": {
                    "value": "ListenerComponentEvent",
                    "dynamic": false
                }
            }
        }
    ]

我们可以发现,对于其他普通的节点来说,主要有两个 AST 属性发生了变化,分别是 hasBinding(是否是有数值或者事件绑定), events(具体绑定的事件的信息,以 key-value 的形式存储着),我们来研究一下其编译过程。

// 只有 Vue 的指令或者属性才能进入
if (dirRE.test(name)) {
  // 确定绑定了内容
  el.hasBindings = true;
  // 匹配可能存在的修饰符
  modifiers = parseModifiers(name.replace(dirRE, ""));
  // 还原真实 key 值
 	name = name.replace(modifierRE, "");
  // 是否是事件绑定的指令 v-on/@
  if (onRE.test(name)) { // v-on
    // 除去指令前缀
    name = name.replace(onRE, '')
    // 判断是否为动态指令
    isDynamic = dynamicArgRE.test(name)
    // 如果是动态,去除两边的方括号,得到真实 name
    if (isDynamic) {
      name = name.slice(1, -1)
    }
    // 在节点 events 上添加事件信息
    addHandler(el, name, value, modifiers, false, warn, list[i], isDynamic)
  }
}

CodeGen


AST 后面紧接着就是生成 render 函数,直接看结果

with (this) {
  return _c(
    "div",
    { attrs: { id: "app" } },
    [
      _c(
        "button",
        {
          on: {
            click: function ($event) {
              return console.log("DOM Listener");
            },
          },
        },
        [_v("button")]
      ),
      _c("EventComponentExample", { on: { click: ListenerComponentEvent } }),
    ],
    1
  );
}

很明显,这里原生 DOM 事件和组件事件还没有区分,render 函数与其他 render 函数的区别也只体现在属性上,多了一个 on 属性,但是 **内联处理器 **却生成了一个生成的函数,让我们看看源码,Vue 是如何做到的。

function genHandler (handler: ASTElementHandler | Array<ASTElementHandler>): string {
  // 如果入参为空,直接返回空函数
  if (!handler) {return 'function(){}'}

  // 如果是数组则递归生成对应事件集合
  if (Array.isArray(handler)) {
    return `[${handler.map(handler => genHandler(handler)).join(',')}]`
  }

  // 仅仅是函数变量名
  const isMethodPath = simplePathRE.test(handler.value)
  // 是否是在行内定义函数
  const isFunctionExpression = fnExpRE.test(handler.value)
  // 是否是在行内直接调用函数
  const isFunctionInvocation = simplePathRE.test(handler.value.replace(fnInvokeRE, ''))

  // 是否有修饰符
  if (!handler.modifiers) {
    // 如果是变量名或者直接在和行内定义的函数,则直接返回
    if (isMethodPath || isFunctionExpression) {
      return handler.value
    }
    // 如果是行内立即调用则封装一个函数直接返回,如果是内联处理器,则在新的而函数内直接执行
    return `function($event){${
      isFunctionInvocation ? `return ${handler.value}` : handler.value
    }}` // inline statement
  } else {
    // 如果有修饰符,则根据具体修饰返回具体的变形代码
  }
}

VNode


在生成 VNode 的时候,原生 DOM 事件和自定义组件事件变换发生区别,原生 DOM 的事件依旧在 VNode.data.on 上面,而自定义组件的事件,则会转移到 VNode.componentOptions.listeners 上。
我们可以看一下自定义组件的事件是怎么转移的

// _createElement
function _createElement(context, tag, data, children, normalizationType){
  //...
  
  } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
    // 创建组件的 VNode
    vnode = createComponent(Ctor, data, context, children, tag)
  } else {
    
  //...
}
  
 // createComponent
export function createComponent (Ctor, data, context, children, tag): VNode | Array<VNode> | void {
  //...
  
  const listeners = 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
  )
}

原生 DOM 事件绑定过程

createEle

function createElm (vnode, insertedVnodeQueue, parentElm, refElm, nested, ownerArray, index) {
  if (isDef(vnode.elm) && isDef(ownerArray)) {
    // 防止引用污染问题,导致判断出错,克隆一边 vnode 进行操作
    vnode = ownerArray[index] = cloneVNode(vnode)
  }

  vnode.isRootInsert = !nested // for transition enter check
  // 如果是组件则会在 createComponent 创建成功,并结束
  if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
    return
  }

  // 非组件的节点会走到此处
  // 节点属性
  const data = vnode.data
  // 节点的子节点
  const children = vnode.children
  // tag 名
  const tag = vnode.tag
  if (isDef(tag)) {
    // 创建真实 DOM,有命名空间的节点会特殊处理
    vnode.elm = vnode.ns
      ? nodeOps.createElementNS(vnode.ns, tag)
    	: nodeOps.createElement(tag, vnode)
    // 设置 style scope
    setScope(vnode)
    
    // 创建子节点
    createChildren(vnode, children, insertedVnodeQueue)
    // 如果节点有属性,则调用一系列创建函数,来更新节点属性
    if (isDef(data)) {
      invokeCreateHooks(vnode, insertedVnodeQueue)
    }
    // 插入父节点
    insert(parentElm, vnode.elm, refElm)
  } else if (isTrue(vnode.isComment)) {
    // 创建注释节点并插入
  } else {
    // 创建文本节点并插入
  }
}

createComponent & initComponent

function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
  let i = vnode.data
  if (isDef(i)) {
    // 是否需是重新激活的
    const isReactivated = isDef(vnode.componentInstance) && i.keepAlive
    // 调用 init 函数创建组件实例
    if (isDef(i = i.hook) && isDef(i = i.init)) {
      i(vnode, false /* hydrating */)
    }
    // 如果创建成功
    if (isDef(vnode.componentInstance)) {
      // 初始化节点的属性!!!
      initComponent(vnode, insertedVnodeQueue)
      // 插入真实 DOM
      insert(parentElm, vnode.elm, refElm)
      if (isTrue(isReactivated)) {
      	// 重新激活
        reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
      }
      return true
    }
  }
}

function initComponent (vnode, insertedVnodeQueue) {
  if (isDef(vnode.data.pendingInsert)) {
    insertedVnodeQueue.push.apply(insertedVnodeQueue, vnode.data.pendingInsert)
    vnode.data.pendingInsert = null
  }
  vnode.elm = vnode.componentInstance.$el
  if (isPatchable(vnode)) {
    // 这里与真实 DOM 创建的收尾流程一样,所以关键就在 invokeCreataHooks 这个函数里面
    invokeCreateHooks(vnode, insertedVnodeQueue)
    setScope(vnode)
  } else {
    // empty component root.
    // skip all element-related modules except for ref (#3455)
    registerRef(vnode)
    // make sure to invoke the insert hook
    insertedVnodeQueue.push(vnode)
  }
}

invokeCreateHooks & updateDOMListeners

function invokeCreateHooks (vnode, insertedVnodeQueue) {
  // 调用创建相关函数,其中有 "updateAttrs", "updateClass", "updateDOMListeners", "updateDOMProps", "updateStyle, ....
  // 其中跟事件有关的核心函数就 updateDOMListeners
  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)
  }
}

function updateDOMListeners (oldVnode: VNodeWithData, vnode: VNodeWithData) {
  // 如果没有新旧 VNode 都没有绑定事件则跳过
  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)
  // 通过 add, remove 更新事件
  updateListeners(on, oldOn, add, remove, createOnceHandler, vnode.context)
  target = undefined
}

add

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
  )
}

自定义组件事件绑定及触发过程


前文已经提到,所有绑定在组件上的事件会绑定到 VNode.componentOptions.listeners 上。
在初始化中,在合并选项的时候,在 initInternalComponent 里将值赋值到 options._parentsListeners
并在 initEvent 中调用 updateComponentListeners 使用先前在原生 DOM 事件绑定中的提到的 updateListeners ,只不过有区别的是 add 函数不再是 addEventListener 而是 $onremove$off
initRender 的时候 this.$listeners 的代理会指向 options._parentsListeners


所以事件绑定和触发的过程主要是 $on 和 $event

$on


$on 负责将监听事件们 push 到 _events

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
}

$emit


$emit 则是负责从 _events 中取出对应的事件,并调用触发

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
}

总结


原生 DOM 事件和自定义组件的事件在生成 虚拟DOM 之前没有什么分别,但再生成虚拟 DOM 后,前者依旧再 VNode.data.on 上,而后者则直接到 VNode.componentOptions.listeners。前者会通过 addEventListener 添加到 DOM 上,后者会在组件初始化的时候通过中通过 $on 以键值对的形式存放到 _events 中,并可以通过 $emit 触发指定的监听事件。

Pengsha Ying

逝者如斯,故不舍昼夜