Vue之事件相关

  • Post author:
  • Post category:vue

前言

本篇文章带来Vue.js的事件机制,具体的分析点如下:

  • $emit、$on、$off、$once背后的处理逻辑
  • @click形式背后的处理逻辑

具体逻辑梳理

实际上在 Vue初始化 这篇文章中就提及了事件相关实例方法的创建,这里就在具体说下。

在Vue.js文件加载执行,其中会执行eventsMixin函数,该函数的作用就是:

创建事件相关的的原型方法,即$on、$once、$off、$emit

主要源码如下:

function eventsMixin (Vue) {
    var hookRE = /^hook:/;
    Vue.prototype.$on = function(event, fn) { // codes };
    Vue.prototype.$once = function(event, fn) { // codes };
    Vue.prototype.$off = function(event, fn) { // codes };
    Vue.prototype.$emit = function(event) { // codes };
}

下面就来具体看看每个事件方法背后的处理逻辑(每个方法的处理逻辑不是很复杂,这里会把源码贴出来)。

$emit

该方法用于事件的触发操作

Vue.prototype.$emit = function (event) {
    var vm = this;
    {
      var lowerCaseEvent = event.toLowerCase();
      // 如果事件名是大写并且该事件存在
      if (lowerCaseEvent !== event && vm._events[lowerCaseEvent]) {
        // 输出提示
      }
    }
    // 支持事件对应多个处理函数
    var cbs = vm._events[event];
    if (cbs) {
      cbs = cbs.length > 1 ? toArray(cbs) : cbs;
      // $emit支持传参
      var args = toArray(arguments, 1);
      for (var i = 0, l = cbs.length; i < l; i++) {
        try {
          // 执行对应的事件处理程序
          cbs[i].apply(vm, args);
        } catch (e) {
          handleError(e, vm, ("event handler for \"" + event + "\""));
        }
      }
    }
    return vm
  };

从上面的源码中可以知道三件事:

  • 非小写事件名会有提示
  • $emit支持传递多个参数
  • 事件的注册中心就是Vue实例的_events变量,该变量保存中当前Vue实例的所有事件

疑问1: _events是如何收集事件的以及在哪里收集的呢?

$on

注册事件

  Vue.prototype.$on = function (event, fn) {
    var this$1 = this;

    var vm = this;
    // 支持批量注册事件
    if (Array.isArray(event)) {
      for (var i = 0, l = event.length; i < l; i++) {
        this$1.$on(event[i], fn);
      }
    } else {
      // 注册事件到_events中,并且知道_events是个对象
      (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
      // 判断是否是hook:开头的事件,这类事件会触发hook:开头的生命周期事件
      if (hookRE.test(event)) {
        vm._hasHookEvent = true;
      }
    }
    return vm
  };

通过$on方法的逻辑可以知道:

  • 事件注册是通过

    o

    n

    on来实现的(解答了上面

    onemit的疑问)

  • events是一个对象,实际上再准确点,vm._events是在init方法中initEvents函数中定义的

    vm._events = Object.create(null);
    vm._hasHookEvent = false;

  • 针对hook:开头的事件,实际上在callHook中调用

这里展开下callHook方法的,该方法主要执行生命周期函数,例如beforeCreate、created、mounted等。
callHook中涉及到hook:事件

  if (vm._hasHookEvent) {
    /*
    	请注意这里hook就是生命周期函数的名称,$emit会触发它们
    	hook:beforeCreate
    	hook:created
    	...
    */
    vm.$emit('hook:' + hook);
  }

疑问2:hook:开头的生命周期对应的事件是用来做什么的

如果这里使用者自定义事件名与Vue的生命周期同名,就有意思了,例如:

created() {
    this.$on('hook:created', () => {})
}

那实际上按照上面的逻辑,因为callHook中$emit触发这里会自动执行。

$once和$off

只响应一次事件处理

  Vue.prototype.$once = function (event, fn) {
    var vm = this;
    function on () {
      vm.$off(event, on);
      fn.apply(vm, arguments);
    }
    on.fn = fn;
    vm.$on(event, on);
    return vm
  };

$off方法的处理就不贴代码了,主要就是调用数组的splice来删除保存的事件处理函数

@click或v-on:click的相关处理

以下面实例为主,分析关于事件的处理逻辑:

<button @click="handleClick">
    点击
</button>

这里按照Vue流程阶段来具体说明:init阶段、render函数创建阶段、render函数执行阶段、patch阶段。实际上对应init阶段这里就不在细说,基本上都是相关属性的定义而已。

render函数构建阶段

通过之前的文章render函数生成细节Vnode生成实际上可知道,对于template会通过HTML解释器完成ast生成和编译最终生成对应的code,上面实例生成的render函数内容如下:

with(this){
    return _c('div',{
        attrs:{"id":"app"}
    },[
        _c('button',{
            on:{"click":handleClick}}
          )
    ])
}

实际上就是Vue官方JSX那边的格式(详情可点击查看 )。如下几点关键信息需要注意:

  • Vue中无论是自定义组件还是HTML原生标签、SVG原生标签,在render函数中都是调用_c实例方法来调用的
  • 数据对象nativeOn是用于DOM原生事件,数据对象on存储所有除了DOM原生事件的

这个阶段主要就是生成render函数,相应事件存储在对应对象中。

render函数执行

虽然原生标签和组件都是调用_c实例方法来生成对应的虚拟对象VNode,但实际上它们的处理是存在很大的区别的。

_c实例方法中关于这边的处理差异逻辑具体如下:

if (config.isReservedTag(tag)) {
	// platform built-in elements
    vnode = new VNode(
    	config.parsePlatformTagName(tag), data, children,
        undefined, undefined, context
    );
} else if (isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      // component
      vnode = createComponent(Ctor, data, context, children, tag);
}

对于浏览器原生标签,直接就创建虚拟对象VNode,而对于组件需要调用createComponent做进一步的处理。而createComponent的对于事件的具体处理可以查看文章Vue之v-model

这里简单概括下:

  • 对于原生标签,实例中button的click事件保存在数据对象on中,作为了虚拟对象VNode的data属性值
  • 对于组件,组件非native事件都保存在虚拟对象VNode的componentOptions中listeners属性中,其data属性中数据对象on实际上native事件

对于数据对象nativeOn有一个非常重要的说明:

原生标签使用.native修饰符的事件,是没有任何效果的,.native修饰符修饰的事件仅仅作用于组件

对于原生标签只关心数据对象on中事件就行,而组件需要关注数据对象on和componentOptions中listeners,其数据对象nativeOn没有参入任何逻辑。

组件componentOptions中listeners是render函数生成阶段数据对象on的内容,即非.native修饰符修饰的事件,其相关注册逻辑查看文章Vue之v-model,其事件注册会在patch阶段Vue实例创建时通过updateComponentListeners来处理

render执行阶段实际上就是创建虚拟对象VNode,VNode中数据对象on中相关事件还未注册到events中心,而实际注册逻辑是在patch阶段。

patch阶段

patch阶段主要是使用diff算法进行DOM复用、删除和创建等工作,具体逻辑可查看文章Vue之patch

在patch阶段除了子组件生成Vue实例外,还有一个create hooks执行逻辑,这两个逻辑是组件非native事件、组件native事件以及原生标签非native事件注册的关键逻辑。

Vue源码中对于数据对象的类别定义有相关对象,对象有一组hooks,例如:

// 处理attrs
var attrs = {
  create: updateAttrs,
  update: updateAttrs
}

// 处理class
var klass = {
  create: updateClass,
  update: updateClass
}

// 处理事件
var events = {
  create: updateDOMListeners,
  update: updateDOMListeners
}

// 处理style
var style = {
  create: updateStyle,
  update: updateStyle
}

还有其他的定义,这里就不一一列举。在patch阶段会对新的节点添加新的事件、class等,对旧的节点会删除事件、class等,Vue中通过一组组对象hooks让逻辑和结构更加清晰。

实际上patch阶段有很多hooks的执行,比如VNode的init、prefetch等来创建Vue实例、更新组件等操作,这里都暂不关心。这里主要关心updateDOMListeners方法,而该方法中主要逻辑如下:

function updateDOMListeners (oldVnode, vnode) {
  // 新旧vnode都不存在事件相关
  if (isUndef(oldVnode.data.on) && isUndef(vnode.data.on)) {
    return
  }
  var on = vnode.data.on || {};
  var oldOn = oldVnode.data.on || {};
  target$1 = vnode.elm;
  normalizeEvents(on);
  // 更新事件监听
  updateListeners(on, oldOn, add$1, remove$2, vnode.context);
  target$1 = undefined;
}

从上面可知,内部实际上是调用了updateListeners方法,具体看看updateListeners的处理逻辑,流程逻辑如下:
在这里插入图片描述
从上面的逻辑中需要关注两个步骤的处理逻辑,这里就具体暂开:

  • 相关处理步骤
  • add$1函数的具体处理逻辑
updateListener中的相关处理
if (isUndef(cur)) {
    // 报错提示
} else if (isUndef(old)) {
    // 旧vnode没有事件
    // 新vnode事件处理函数没有fns属性
	if (isUndef(cur.fns)) {
    	cur = on[name] = createFnInvoker(cur);
    }
    // 调用add(即add$1)函数
    add(event.name, cur, event.once, event.capture, event.passive, event.params);
} else if (cur !== old) {
	old.fns = cur;
    on[name] = old;
}
createFnInvoker函数
function createFnInvoker (fns) {
  function invoker () {
    var arguments$1 = arguments;

    var fns = invoker.fns;
    // 可知vue中html中函数定义支持多个函数公共处理
    if (Array.isArray(fns)) {
      var cloned = fns.slice();
      for (var i = 0; i < cloned.length; i++) {
        cloned[i].apply(null, arguments$1);
      }
    } else {
      // return handler return value for single handlers
      return fns.apply(null, arguments)
    }
  }
  // 定义fns属性,实际上fns就是事件处理函数
  invoker.fns = fns;
  return invoker
}
add$1函数

核心函数,实现函数的注册

function add$1 (
  event,
  handler,
  once$$1,
  capture,
  passive
) {
  handler = withMacroTask(handler);
  if (once$$1) { handler = createOnceHandler(handler, event, capture); }
  target$1.addEventListener(
    event,
    handler,
    supportsPassive
      ? { capture: capture, passive: passive }
      : capture
  );
}

从add$1函数的逻辑中,很清晰的知道下面三点信息:

  • 使用addEventListener来实现事件绑定
  • withMacroTask函数处理了事件处理处理
  • 事件只响应一次调用createOnceHandler函数做了特殊处理
function withMacroTask (fn) {
  return fn._withTask || (fn._withTask = function () {
    useMacroTask = true;
    var res = fn.apply(null, arguments);
    useMacroTask = false;
    return res
  })
}

从上面源码可知,就是定义了_withTask方法,而该方法中在事件处理之前设置了全局useMacroTask为true。该全局属性只会在$nextTick函数中使用到,实际上就是用来控制是使用macroTask API还是microTask API。用来解决一些场景下事件的问题(具体可看Vue Github对应的issue:#4521、 #6690、#6566)。

createOnceHandler函数
function createOnceHandler (handler, event, capture) {
  var _target = target$1; // save current target element in closure
  return function onceHandler () {
    var res = handler.apply(null, arguments);
    if (res !== null) {
      // 从上面add$1可知,该函数必然调用了removeEventListener
      remove$2(event, onceHandler, capture, _target);
    }
  }
}

总结

结合之前文章v-model和本文总结事件注册细节点:

  • _events中保存的当前实例对象的所有的事件定义

  • 对于事件支持多个事件处理函数,也支持一次响应

  • hook:开头的对应的生命周期名称事件会被自动执行,例如:hook:created

  • 原生标签不支持.native修饰的,对于原生标签的事件:

    render创建时仅仅获取事件和事件处理程序还未注册,在patch阶段在调用events create hook即updateDOMListener,底层使用addEventListener来实现事件绑定

  • 组件支持.native修饰符修饰的事件以及非.native修饰符修饰的事件,二者的处理不同

    • .native修饰符修饰的事件,跟原生标签的事件处理逻辑是是相同的(组件的.native修饰的事件在render执行期间从数据对象nativeOn赋值给了数据对象on,数据对象on中保存的非.native修饰的事件保存在componentOptions中listeners中)
    • 非.native修饰符修饰的事件,在其组件Vue实例化时调用updateComponentListeners函数注册到events中心了

实际上无论是组件事件还是原生事件背后最后都是调用updateListenrs函数,只不过是最后注册逻辑不同(原生标签使用addEventListenr注册,而组件非.native修饰的事件注册到events中心)。


版权声明:本文为s1879046原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。