vue源码分析-事件机制

174 阅读3分钟

这个系列讲到这里,Vue基本核心的东西已经分析完,但是Vue之所以强大,离不开它提供给用户的一些实用功能,开发者可以更偏向于业务逻辑而非基本功能的实现。例如,在日常开发中,我们将@click=***用得飞起,但是我们是否思考,Vue如何在后面为我们的模板做事件相关的处理,并且我们经常利用组件的自定义事件去实现父子间的通信,那这个事件和和原生dom事件又有不同的地方吗,能够实现通信的原理又是什么,带着疑惑,我们深入源码展开分析。

9.1. 模板编译

Vue在挂载实例前,有相当多的工作是进行模板的编译,将template模板进行编译,解析成AST树,再转换成render函数,而有了render函数后才会进入实例挂载过程。对于事件而言,我们经常使用v-on或者@在模板上绑定事件。因此对事件的第一步处理,就是在编译阶段对事件指令做收集处理。

从一个简单的用法分析编译阶段收集的信息:

<div id="app">
    <div v-on:click.stop="doThis">点击</div>
    <span>{{count}}</span>
</div>
<script>
var vm = new Vue({    el: '#app',    data() {        return {            count: 1
        }    },    methods: {        doThis() {            ++this.count
        }    }})
</script>

我们之前在将模板编译的时候大致说过编译的流程,模板编译的入口是在var ast = parse(template.trim(), options);中,parse通过拆分模板字符串,将其解析为一个AST树,其中对于属性的处理,在processAttr中,由于分支较多,我们只分析例子中的流程。

var dirRE = /^v-|^@|^:/;

function processAttrs (el) {
    var list = el.attrsList;
    var i, l, name, rawName, value, modifiers, syncGen, isDynamic;
    for (i = 0, l = list.length; i < l; i++) {
      name = rawName = list[i].name; // v-on:click
      value = list[i].value; // doThis
      if (dirRE.test(name)) { // 匹配v-或者@开头的指令
        el.hasBindings = true;
        modifiers = parseModifiers(name.replace(dirRE, ''));// parseModifiers('on:click')
        if (modifiers) {
          name = name.replace(modifierRE, '');
        }
        if (bindRE.test(name)) { // v-bind分支
          // ...留到v-bind指令时分析
        } else if (onRE.test(name)) { // v-on分支
          name = name.replace(onRE, ''); // 拿到真正的事件click
          isDynamic = dynamicArgRE.test(name);// 动态事件绑定
          if (isDynamic) {
            name = name.slice(1, -1);
          }
          addHandler(el, name, value, modifiers, false, warn$2, list[i], isDynamic);
        } else { // normal directives
         // 其他指令相关逻辑
      } else {}
    }
  }

processAttrs的逻辑虽然较多,但是理解起来较为简单,var dirRE = /^v-|^@|^:/;是匹配事件相关的正则,命中匹配的记过会得到事件指令相关内容,包括事件本身,事件回调以及事件修饰符。最终通过addHandler方法,为AST树添加事件相关的属性。而addHandler还有一个重要功能是对事件修饰符进行特殊处理。

// el是当前解析的AST树
function addHandler (el,name,value,modifiers,important,warn,range,dynamic) {
    modifiers = modifiers || emptyObject;
    // passive 和 prevent不能同时使用,可以参照官方文档说明
    if (
      warn &&
      modifiers.prevent && modifiers.passive
    ) {
      warn(
        'passive and prevent can\'t be used together. ' +
        'Passive handler can\'t prevent default event.',
        range
      );
    }
    // 这部分的逻辑会对特殊的修饰符做字符串拼接的处理,以备后续的使用
    if (modifiers.right) {
      if (dynamic) {
        name = "(" + name + ")==='click'?'contextmenu':(" + name + ")";
      } else if (name === 'click') {
        name = 'contextmenu';
        delete modifiers.right;
      }
    } else if (modifiers.middle) {
      if (dynamic) {
        name = "(" + name + ")==='click'?'mouseup':(" + name + ")";
      } else if (name === 'click') {
        name = 'mouseup';
      }
    }
    if (modifiers.capture) {
      delete modifiers.capture;
      name = prependModifierMarker('!', name, dynamic);
    }
    if (modifiers.once) {
      delete modifiers.once;
      name = prependModifierMarker('~', name, dynamic);
    }
    /* istanbul ignore if */
    if (modifiers.passive) {
      delete modifiers.passive;
      name = prependModifierMarker('&', name, dynamic);
    }
    // events 用来记录绑定的事件
    var events;
    if (modifiers.native) {
      delete modifiers.native;
      events = el.nativeEvents || (el.nativeEvents = {});
    } else {
      events = el.events || (el.events = {});
    }

    var newHandler = rangeSetItem({ value: value.trim(), dynamic: dynamic }, range);
    if (modifiers !== emptyObject) {
      newHandler.modifiers = modifiers;
    }

    var handlers = events[name];
    /* istanbul ignore if */
    // 绑定的事件可以多个,回调也可以多个,最终会合并到数组中
    if (Array.isArray(handlers)) {
      important ? handlers.unshift(newHandler) : handlers.push(newHandler);
    } else if (handlers) {
      events[name] = important ? [newHandler, handlers] : [handlers, newHandler];
    } else {
      events[name] = newHandler;
    }
    el.plain = false;
  }

修饰符的处理会改变最终字符串的拼接结果,我们看最终转换的AST树:

9.2. 代码生成

模板编译的最后一步是根据解析完的AST树生成对应平台的渲染函数,也就是render函数的生成过程, 对应var code = generate(ast, options);

参考 Vue面试题详细解答

function generate (ast,options) {
    var state = new CodegenState(options);
    var code = ast ? genElement(ast, state) : '_c("div")';
    return {
      render: ("with(this){return " + code + "}"), // with函数
      staticRenderFns: state.staticRenderFns
    }
  }

其中核心处理在getElement中,getElement函数会根据不同指令类型处理不同的分支,对于普通模板的编译会进入genData函数中处理,同样分析只针对事件相关的处理,从前面解析出的AST树明显看出,AST树中多了events的属性,genHandlers函数会为event属性做逻辑处理。

function genData (el, state) {
    var data = '{';

    // directives first.
    // directives may mutate the el's other properties before they are generated.
    var dirs = genDirectives(el, state);
    if (dirs) { data += dirs + ','; }
    //其他处理
    ···

    // event handlers
    if (el.events) {
      data += (genHandlers(el.events, false)) + ",";
    }

    ···

    return data
  }

genHandlers的逻辑,会遍历解析好的AST树,拿到event对象属性,并根据属性上的事件对象拼接成字符串。

function genHandlers (events,isNative) {
    var prefix = isNative ? 'nativeOn:' : 'on:';
    var staticHandlers = "";
    var dynamicHandlers = "";
    // 遍历ast树解析好的event对象
    for (var name in events) {
      //genHandler本质上是将事件对象转换成可拼接的字符串
      var handlerCode = genHandler(events[name]);
      if (events[name] && events[name].dynamic) {
        dynamicHandlers += name + "," + handlerCode + ",";
      } else {
        staticHandlers += "\"" + name + "\":" + handlerCode + ",";
      }
    }
    staticHandlers = "{" + (staticHandlers.slice(0, -1)) + "}";
    if (dynamicHandlers) {
      return prefix + "_d(" + staticHandlers + ",[" + (dynamicHandlers.slice(0, -1)) + "])"
    } else {
      return prefix + staticHandlers
    }
  }
// 事件模板书写匹配
var isMethodPath = simplePathRE.test(handler.value); // doThis
var isFunctionExpression = fnExpRE.test(handler.value); // () => {} or function() {}
var isFunctionInvocation = simplePathRE.test(handler.value.replace(fnInvokeRE, '')); // doThis($event)


function genHandler (handler) {
    if (!handler) {
      return 'function(){}'
    }
    // 事件绑定可以多个,多个在解析ast树时会以数组的形式存在,如果有多个则会递归调用getHandler方法返回数组。
    if (Array.isArray(handler)) {
      return ("[" + (handler.map(function (handler) { return genHandler(handler); }).join(',')) + "]")
    }
    // value: doThis 可以有三种方式
    var isMethodPath = simplePathRE.test(handler.value); // doThis
    var isFunctionExpression = fnExpRE.test(handler.value); // () => {} or function() {}
    var isFunctionInvocation = simplePathRE.test(handler.value.replace(fnInvokeRE, '')); // doThis($event)

    // 没有任何修饰符
    if (!handler.modifiers) {
      // 符合函数定义规范,则直接返回调用函数名 doThis
      if (isMethodPath || isFunctionExpression) {
        return handler.value
      }
      // 不符合则通过function函数封装返回
      return ("function($event){" + (isFunctionInvocation ? ("return " + (handler.value)) : handler.value) + "}") // inline statement
    } else {
    // 包含修饰符的场景
    }
  }

模板中事件的写法有三种,分别对应上诉上个正则匹配的内容。

    1. <div @click="doThis"></div>
    1. <div @click="doThis($event)"></div>
    1. <div @click="()=>{}"></div> <div @click="function(){}"></div>

上述对事件对象的转换,如果事件不带任何修饰符,并且满足正确的模板写法,则直接返回调用事件名,如果不满足,则有可能是<div @click="console.log(11)"></div>的写法,此时会封装到function($event){}中。

包含修饰符的场景较多,我们单独列出分析。以上文中的例子说明,modifiers: { stop: true }会拿到stop对应需要添加的逻辑脚本'$event.stopPropagation();',并将它添加到函数字符串中返回。

function genHandler() {
  // ···
  } else {
    var code = '';
    var genModifierCode = '';
    var keys = [];
    // 遍历modifiers上记录的修饰符
    for (var key in handler.modifiers) {
      if (modifierCode[key]) {
        // 根据修饰符添加对应js的代码
        genModifierCode += modifierCode[key];
        // left/right
        if (keyCodes[key]) {
          keys.push(key);
        }
        // 针对exact的处理
      } else if (key === 'exact') {
        var modifiers = (handler.modifiers);
        genModifierCode += genGuard(
          ['ctrl', 'shift', 'alt', 'meta']
            .filter(function (keyModifier) { return !modifiers[keyModifier]; })
            .map(function (keyModifier) { return ("$event." + keyModifier + "Key"); })
            .join('||')
        );
      } else {
        keys.push(key);
      }
    }
    if (keys.length) {
      code += genKeyFilter(keys);
    }
    // Make sure modifiers like prevent and stop get executed after key filtering
    if (genModifierCode) {
      code += genModifierCode;
    }
    // 根据三种不同的书写模板返回不同的字符串
    var handlerCode = isMethodPath
      ? ("return " + (handler.value) + "($event)")
      : isFunctionExpression
        ? ("return (" + (handler.value) + ")($event)")
        : isFunctionInvocation
          ? ("return " + (handler.value))
          : handler.value;
    return ("function($event){" + code + handlerCode + "}")
  }
}
var modifierCode = {
  stop: '$event.stopPropagation();',
  prevent: '$event.preventDefault();',
  self: genGuard("$event.target !== $event.currentTarget"),
  ctrl: genGuard("!$event.ctrlKey"),
  shift: genGuard("!$event.shiftKey"),
  alt: genGuard("!$event.altKey"),
  meta: genGuard("!$event.metaKey"),
  left: genGuard("'button' in $event && $event.button !== 0"),
  middle: genGuard("'button' in $event && $event.button !== 1"),
  right: genGuard("'button' in $event && $event.button !== 2")
};

经过这一转换后,生成with封装的render函数如下:

"_c('div',{attrs:{"id":"app"}},[_c('div',{on:{"click":function($event){$event.stopPropagation();return doThis($event)}}},[_v("点击")]),_v(" "),_c('span',[_v(_s(count))])])"

9.3. 事件绑定

前面花了大量的篇幅介绍了模板上的事件标记在构建AST树上是怎么处理,并且如何根据构建的AST树返回正确的render渲染函数,但是真正事件绑定还是离不开绑定注册事件。这一个阶段就是发生在组件挂载的阶段。 有了render函数,自然可以生成实例挂载需要的Vnode树,并且会进行patchVnode的环节进行真实节点的构建,如果发现过程已经遗忘,可以回顾以往章节。 Vnode树的构建过程和之前介绍的内容没有明显的区别,所以这个过程就不做赘述,最终生成的vnode如下:

有了Vnode,接下来会遍历子节点递归调用createElm为每个子节点创建真实的DOM,由于Vnode中有data属性,在创建真实DOM时会进行注册相关钩子的过程,其中一个就是注册事件相关处理。

function createElm() {
  ···
  // 针对指令的处理
   if (isDef(data)) {
      invokeCreateHooks(vnode, insertedVnodeQueue);
    }
}


function invokeCreateHooks (vnode, insertedVnodeQueue) {
  for (var i$1 = 0; i$1 < cbs.create.length; ++i$1) {
    cbs.create[i$1](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); }
  }
}

var events = {
  create: updateDOMListeners,
  update: updateDOMListeners
};

我们经常会在template模板中定义v-on事件,v-bind动态属性,v-text动态指令等,和v-on事件指令一样,他们都会在编译阶段和Vnode生成阶段创建data属性,因此invokeCreateHooks就是一个模板指令处理的任务,他分别针对不同的指令为真实阶段创建不同的任务。针对事件,这里会调用updateDOMListeners对真实的DOM节点注册事件任务。

function updateDOMListeners (oldVnode, vnode) {
  // on是事件指令的标志
  if (isUndef(oldVnode.data.on) && isUndef(vnode.data.on)) {
    return
  }
  // 新旧节点不同的事件绑定解绑
  var on = vnode.data.on || {};
  var oldOn = oldVnode.data.on || {};
  // 拿到需要添加事件的真实DOM节点
  target$1 = vnode.elm;
  // normalizeEvents是对事件兼容性的处理
  normalizeEvents(on);
  updateListeners(on, oldOn, add$1, remove$2, createOnceHandler$1, vnode.context);
  target$1 = undefined;
}

其中normalizeEvents是针对v-model的处理,例如在IE下不支持change事件,只能用input事件代替。

updateListeners的逻辑也很简单,它会遍历on事件对新节点事件绑定注册事件,对旧节点移除事件监听,它即要处理原生DOM事件的添加和移除,也要处理自定义事件的添加和移除,关于自定义事件,后续内容再分析。

function updateListeners (on,oldOn,add,remove$$1,createOnceHandler,vm) {
    var name, def$$1, cur, old, event;
    // 遍历事件
    for (name in on) {
      def$$1 = cur = on[name];
      old = oldOn[name];
      event = normalizeEvent(name);
      if (isUndef(cur)) {
        // 事件名非法的报错处理
        warn(
          "Invalid handler for event \"" + (event.name) + "\": got " + String(cur),
          vm
        );
      } else if (isUndef(old)) {
        // 旧节点不存在
        if (isUndef(cur.fns)) {
          // createFunInvoker返回事件最终执行的回调函数
          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) {
        old.fns = cur;
        on[name] = old;
      }
    }
    // 旧节点存在,接触旧节点上的绑定事件
    for (name in oldOn) {
      if (isUndef(on[name])) {
        event = normalizeEvent(name);
        remove$$1(event.name, oldOn[name], event.capture);
      }
    }
  }

在初始构建实例时,旧节点是不存在的,此时会调用createFnInvoker函数对事件回调函数做一层封装,由于单个事件的回调可以有多个,因此createFnInvoker的作用是对单个,多个回调事件统一封装处理,返回一个当事件触发时真正执行的匿名函数。

function createFnInvoker (fns, vm) {
  // 当事件触发时,执行invoker方法,方法执行fns
  function invoker () {
    var arguments$1 = arguments;

    var fns = invoker.fns;
    // fns是多个回调函数组成的数组
    if (Array.isArray(fns)) {
      var cloned = fns.slice();
      for (var i = 0; i < cloned.length; i++) {
        // 遍历执行真正的回调函数
        invokeWithErrorHandling(cloned[i], null, arguments$1, vm, "v-on handler");
      }
    } else {
      // return handler return value for single handlers
      return invokeWithErrorHandling(fns, null, arguments, vm, "v-on handler")
    }
  }
  invoker.fns = fns;
  // 返回最终事件执行的回调函数
  return invoker
}

其中invokeWithErrorHandling会执行定义好的回调函数,这里做了同步异步回调的错误处理。try-catch用于同步回调捕获异常错误,Promise.catch用于捕获异步任务返回错误。

function invokeWithErrorHandling (handler,context,args,vm,info) {
    var res;
    try {
      res = args ? handler.apply(context, args) : handler.call(context);
      if (res && !res._isVue && isPromise(res)) {
        // issue #9511
        // reassign to res to avoid catch triggering multiple times when nested calls
        // 当生命周期钩子函数内部执行返回promise对象是,如果捕获异常,则会对异常信息做一层包装返回
        res = res.catch(function (e) { return handleError(e, vm, info + " (Promise/async)"); });
      }
    } catch (e) {
      handleError(e, vm, info);
    }
    return res
  }

如果事件只触发一次(即使用了once修饰符),则调用createOnceHandler匿名,在执行完回调之后,移除事件绑定。

function createOnceHandler (event, handler, capture) {
    var _target = target$1; 
    return function onceHandler () {
      //调用事件回调
      var res = handler.apply(null, arguments);
      if (res !== null) {
        // 移除事件绑定
        remove$2(event, onceHandler, capture, _target);
      }
    }
  }

addremove是真正在DOM上绑定事件和解绑事件的过程,它的实现也是利用了原生DOMaddEventListener,removeEventListener api

function add (name,handler,capture,passive){
  ···
  target$1.addEventListener(name,handler,
      supportsPassive
        ? { capture: capture, passive: passive }
        : capture);
}
function remove (name,handler,capture,_target) {
  (_target || target$1).removeEventListener(
    name,
    handler._wrapper || handler,
    capture
  );
}

另外事件的解绑除了发生在只触发一次的事件,也发生在组件更新patchVnode过程,具体不展开分析,可以参考之前介绍组件更新的内容研究updateListeners的过程。

9.4. 自定义事件

Vue如何处理原生的Dom事件基本流程已经讲完,然而针对事件还有一个重要的概念不可忽略,那就是组件的自定义事件。我们知道父子组件可以利用事件进行通信,子组件通过vm.$emit向父组件分发事件,父组件通过v-on:(event)接收信息并处理回调。因此针对自定义事件在源码中自然有不同的处理逻辑。我们先通过简单的例子展开。

<script>
    var child = {      template: `<div @click="emitToParent">点击传递信息给父组件</div>`,      methods: {        emitToParent() {          this.$emit('myevent', 1)        }      }    }    new Vue({      el: '#app',      components: {        child      },      template: `<div id="app"><child @myevent="myevent" @click.native="nativeClick"></child></div>`,      methods: {        myevent(num) {          console.log(num)        },        nativeClick() {          console.log('nativeClick')        }      }    })  </script>

从例子中可以看出,普通节点只能使用原生DOM事件,而组件上却可以使用自定义的事件和原生的DOM事件,并且通过native修饰符区分,有了原生DOM对于事件处理的基础,接下来我们看看自定义事件有什么特别之处。

9.4.1 模板编译

回过头来看看事件的模板编译,在生成AST树阶段,之前分析说过addHandler方法会对事件的修饰符做不同的处理,当遇到native修饰符时,事件相关属性方法会添加到nativeEvents属性中。 下图是child生成的AST树:

9.4.2 代码生成

不管是组件还是普通标签,事件处理代码都在genData的过程中,和之前分析原生事件一致,genHandlers用来处理事件对象并拼接成字符串。

function genData() {
  ···
  if (el.events) {
    data += (genHandlers(el.events, false)) + ",";
  }
  if (el.nativeEvents) {
    data += (genHandlers(el.nativeEvents, true)) + ",";
  }
}

getHandlers的逻辑前面已经讲过,处理组件原生事件和自定义事件的区别在isNative选项上,我们看最终生成的代码为:

with(this){return _c('div',{attrs:{"id":"app"}},[_c('child',{on:{"myevent":myevent},nativeOn:{"click":function($event){return nativeClick($event)}}})],1)}

有了render函数接下来会根据它创建Vnode实例,其中遇到组件占位符节点时会创建子组件Vnode, 此时为on,nativeOn做了一层特殊的转换,将nativeOn赋值给on,这样后续的处理方式和普通节点一致。另外,将on赋值给listeners,在创建VNode时以组件配置componentOptions传入。

 // 创建子组件过程
function createComponent (){
  ···
  var listeners = data.on;
  // replace with listeners with .native modifier
  // so it gets processed during parent component patch.
  data.on = data.nativeOn;
  ···

  var vnode = new VNode(
    ("vue-component-" + (Ctor.cid) + (name ? ("-" + name) : '')),
    data, undefined, undefined, undefined, context,
    { Ctor: Ctor, propsData: propsData, listeners: listeners, tag: tag, children: children },
    asyncFactory
  );

  return vnode
}
9.4.3 子组件实例

接下来是通过Vnode生成真实节点的过程,这个过程遇到子Vnode会实例化子组件实例。实例化子类构造器的过程又回到之前文章分析的初始化选项配置的过程,在系列最开始的时候分析Vue.prototype.init的过程,跳过了组件初始化的流程,其中针对自定义事件的处理的关键如下

Vue.prototype._init = function(options) {
  ···
  // 针对子组件的事件处理逻辑
  if (options && options._isComponent) {
    // 初始化内部组件
    initInternalComponent(vm, options);
  } else {
    // 选项合并,将合并后的选项赋值给实例的$options属性
    vm.$options = mergeOptions(
      resolveConstructorOptions(vm.constructor),
      options || {},
      vm
    );
  }
  // 初始化事件处理
  initEvents(vm);
}
function initInternalComponent (vm, options) {
  var opts = vm.$options = Object.create(vm.constructor.options);
  ···
  opts._parentListeners = vnodeComponentOptions.listeners;
  ···
}

此时,子组件拿到了父占位符节点定义的@myevent="myevent"事件。接下来进行子组件的初始化事件处理,此时vm.$options._parentListeners会拿到父组件自定义的事件。而带有自定义事件的组件会执行updateComponentListeners函数。

function initEvents (vm) {
  vm._events = Object.create(null);
  vm._hasHookEvent = false;
  // init parent attached events
  var listeners = vm.$options._parentListeners;
  if (listeners) {
    // 带有自定义事件属性的实例
    updateComponentListeners(vm, listeners);
  }
}

之后又回到了之前分析的updateListeners过程,和原生DOM事件不同的是,自定义事件的添加移除的方法不同。

var target = vm;

function add (event, fn) {
  target.$on(event, fn);
}

function remove$1 (event, fn) {
  target.$off(event, fn);
} 

function updateComponentListeners (vm,listeners,oldListeners) {
  target = vm;
  updateListeners(listeners, oldListeners || {}, add, remove$1, createOnceHandler, vm);
  target = undefined;
}
9.4.4 事件API

我们回头来看看Vue在引入阶段对事件的处理还做了哪些初始化操作。Vue在实例上用一个_events属性存贮管理事件的派发和更新,暴露出$on, $once, $off, $emit方法给外部管理事件和派发执行事件。

  eventsMixin(Vue); // 定义事件相关函数

  function eventsMixin (Vue) {
    var hookRE = /^hook:/;
    // $on方法用来监听事件,执行回调
    Vue.prototype.$on = function (event, fn) {
      var vm = this;
      // event支持数组形式。
      if (Array.isArray(event)) {
        for (var i = 0, l = event.length; i < l; i++) {
          vm.$on(event[i], fn);
        }
      } else {
        // _events数组中记录需要监听的事件以及事件触发的回调
        (vm._events[event] || (vm._events[event] = [])).push(fn);
        if (hookRE.test(event)) {
          vm._hasHookEvent = true;
        }
      }
      return vm
    };
    // $once方法用来监听一次事件,执行回调
    Vue.prototype.$once = function (event, fn) {
      var vm = this;
      // 对fn做一层包装,先解除绑定再执行fn回调
      function on () {
        vm.$off(event, on);
        fn.apply(vm, arguments);
      }
      on.fn = fn;
      vm.$on(event, on);
      return vm
    };
    // $off方法用来解除事件监听
    Vue.prototype.$off = function (event, fn) {
      var vm = this;
      // 如果$off方法没有传递任何参数时,将_events属性清空。
      if (!arguments.length) {
        vm._events = Object.create(null);
        return vm
      }
      // 数组处理
      if (Array.isArray(event)) {
        for (var i$1 = 0, l = event.length; i$1 < l; i$1++) {
          vm.$off(event[i$1], fn);
        }
        return vm
      }
      var cbs = vm._events[event];
      if (!cbs) {
        return vm
      }
      if (!fn) {
        vm._events[event] = null;
        return vm
      }
      // specific handler
      var cb;
      var i = cbs.length;
      while (i--) {
        cb = cbs[i];
        if (cb === fn || cb.fn === fn) {
          // 将监听的事件回调移除
          cbs.splice(i, 1);
          break
        }
      }
      return vm
    };
    // $emit方法用来触发事件,执行回调
    Vue.prototype.$emit = function (event) {
      var vm = this;
      {
        var lowerCaseEvent = event.toLowerCase();
        if (lowerCaseEvent !== event && vm._events[lowerCaseEvent]) {
          tip(
            "Event \"" + lowerCaseEvent + "\" is emitted in component " +
            (formatComponentName(vm)) + " but the handler is registered for \"" + event + "\". " +
            "Note that HTML attributes are case-insensitive and you cannot use " +
            "v-on to listen to camelCase events when using in-DOM templates. " +
            "You should probably use \"" + (hyphenate(event)) + "\" instead of \"" + event + "\"."
          );
        }
      }
      var cbs = vm._events[event];
      // 找到已经监听事件的回调,执行
      if (cbs) {
        cbs = cbs.length > 1 ? toArray(cbs) : cbs;
        var args = toArray(arguments, 1);
        var info = "event handler for \"" + event + "\"";
        for (var i = 0, l = cbs.length; i < l; i++) {
          invokeWithErrorHandling(cbs[i], vm, args, vm, info);
        }
      }
      return vm
    };
  }

有了这些事件api,自定义事件的添加移除理解起来也简单很多。组件通过this.$emit在组件实例中派发了事件,而在这之前,组件已经将需要监听的事件以及回调添加到实例的_events属性中,触发事件时便可以直接执行监听事件的回调。

最后,我们换一个角度理解父子组件通信,组件自定义事件的触发和监听本质上都是在当前的组件实例中进行,之所以能产生父子组件通信的效果是因为事件监听的回调函数写在了父组件中。

9.5 小结

事件是我们日常开发中必不可少的功能点,Vue在应用层暴露了@,v-on的指令供开发者在模板中绑定事件。事件指令在模板编译阶段会以属性的形式存在,而在真实节点渲染阶段会根据事件属性去绑定相关的事件。对于组件的事件而言,我们可以利用事件进行子父组件间的通信,他本质上是在同个子组件内部维护了一个事件总线,从分析结果可以看出,之所以有子父组件通信的效果,原因仅仅是因为回调函数写在了父组件中。 双向数据绑定这个概念或者大家并不陌生,视图影响数据,数据同样影响视图,两者间有双向依赖的关系。在响应式系统构建的上,中,下篇我已经对数据影响视图的原理详细阐述清楚了。而如何完成视图影响数据这一关联?这就是本节讨论的重点:指令v-model

由于v-model和前面介绍的插槽,事件一致,都属于vue提供的指令,所以我们对v-model的分析方式和以往大同小异。分析会围绕模板的编译,render函数的生成,到最后真实节点的挂载顺序执行。最终我们依然会得到一个结论,v-model无论什么使用场景,本质上都是一个语法糖

11.1 表单绑定

11.1.1 基础使用

v-model和表单脱离不了关系,之所以视图能影响数据,本质上这个视图需要可交互的,因此表单是实现这一交互的前提。表单的使用以<input > <textarea> <select>为核心,更细的划分结合v-model的使用如下:

// 普通输入框
<input type="text" v-model="value1">

// 多行文本框
<textarea v-model="value2" cols="30" rows="10"></textarea>

// 单选框
<div class="group">
  <input type="radio" value="one" v-model="value3"> one
  <input type="radio" value="two" v-model="value3"> two
</div> 

// 原生单选框的写法 注:原生单选框的写法需要通过name绑定一组单选,两个radio的name属性相同,才能表现为互斥
<div class="group">
  <input type="radio" name="number" value="one">one
  <input type="radio" name="number" value="two">two
</div>


// 多选框  (原始值: value4: [])
<div class="group">
  <input type="checkbox" value="jack" v-model="value4">jack
  <input type="checkbox" value="lili" v-model="value4">lili
</div>

// 下拉选项
<select name="" id="" v-model="value5">
  <option value="apple">apple</option>
  <option value="banana">banana</option>
  <option value="bear">bear</option>
</select>

接下来的分析,我们以普通输入框为例

<div id="app">
  <input type="text" v-model="value1">
</div>

new Vue({
  el: '#app',
  data() {
    return {
      value1: ''
    }
  }
})

进入正文前先回顾一下模板到真实节点的过程。

    1. 模板解析成AST树;
    1. AST树生成可执行的render函数;
    1. render函数转换为Vnode对象;
    1. 根据Vnode对象生成真实的Dom节点。

接下来,我们先看看模板解析为AST树的过程。

11.1.2 AST树的解析

模板的编译阶段,会调用var ast = parse(template.trim(), options)生成AST树,parse函数的其他细节这里不展开分析,前面的文章或多或少都涉及过,我们还是把关注点放在模板属性上的解析,也就是processAttrs函数上。

使用过vue写模板的都知道,vue模板属性由两部分组成,一部分是指令,另一部分是普通html标签属性。z这也是属性处理的两大分支。而在指令的细分领域,又将v-on,v-bind做特殊的处理,其他的普通分支会执行addDirective过程。

// 处理模板属性
function processAttrs(el) {
  var list = el.attrsList;
  var i, l, name, rawName, value, modifiers, syncGen, isDynamic;
  for (i = 0, l = list.length; i < l; i++) {
    name = rawName = list[i].name; // v-on:click
    value = list[i].value; // doThis
    if (dirRE.test(name)) { // 1.针对指令的属性处理
      ···
      if (bindRE.test(name)) { // v-bind分支
        ···
      } else if(onRE.test(name)) { // v-on分支
        ···
      } else { // 除了v-bind,v-on之外的普通指令
        ···
        // 普通指令会在AST树上添加directives属性
        addDirective(el, name, rawName, value, arg, isDynamic, modifiers, list[i]);
        if (name === 'model') {
          checkForAliasModel(el, value);
        }
      }
    } else {
      // 2. 普通html标签属性
    }

  }
}

在深入剖析Vue源码 - 揭秘Vue的事件机制这一节,我们介绍了AST产生阶段对事件指令v-on的处理是为AST树添加events属性。类似的,普通指令会在AST树上添加directives属性,具体看addDirective函数。

// 添加directives属性
function addDirective (el,name,rawName,value,arg,isDynamicArg,modifiers,range) {
    (el.directives || (el.directives = [])).push(rangeSetItem({
      name: name,
      rawName: rawName,
      value: value,
      arg: arg,
      isDynamicArg: isDynamicArg,
      modifiers: modifiers
    }, range));
    el.plain = false;
  }

最终AST树多了一个属性对象,其中modifiers代表模板中添加的修饰符,如:.lazy, .number, .trim

// AST
{
  directives: {
    {
      rawName: 'v-model',
      value: 'value',
      name: 'v-model',
      modifiers: undefined
    }
  }
}
11.1.3 render函数生成

render函数生成阶段,也就是前面分析了数次的generate逻辑,其中genData会对模板的诸多属性进行处理,最终返回拼接好的字符串模板,而对指令的处理会进入genDirectives流程。

function genData(el, state) {
  var data = '{';
  // 指令的处理
  var dirs = genDirectives(el, state);
  ··· // 其他属性,指令的处理
  // 针对组件的v-model处理,放到后面分析
  if (el.model) {
    data += "model:{value:" + (el.model.value) + ",callback:" + (el.model.callback) + ",expression:" + (el.model.expression) + "},";
  }
  return data
}

genDirectives逻辑并不复杂,他会拿到之前AST树中保留的directives对象,并遍历解析指令对象,最终以'directives:['包裹的字符串返回。

// directives render字符串的生成
  function genDirectives (el, state) {
    // 拿到指令对象
    var dirs = el.directives;
    if (!dirs) { return }
    // 字符串拼接
    var res = 'directives:[';
    var hasRuntime = false;
    var i, l, dir, needRuntime;
    for (i = 0, l = dirs.length; i < l; i++) {
      dir = dirs[i];
      needRuntime = true;
      // 对指令ast树的重新处理
      var gen = state.directives[dir.name];
      if (gen) {
        // compile-time directive that manipulates AST.
        // returns true if it also needs a runtime counterpart.
        needRuntime = !!gen(el, dir, state.warn);
      }
      if (needRuntime) {
        hasRuntime = true;
        res += "{name:\"" + (dir.name) + "\",rawName:\"" + (dir.rawName) + "\"" + (dir.value ? (",value:(" + (dir.value) + "),expression:" + (JSON.stringify(dir.value))) : '') + (dir.arg ? (",arg:" + (dir.isDynamicArg ? dir.arg : ("\"" + (dir.arg) + "\""))) : '') + (dir.modifiers ? (",modifiers:" + (JSON.stringify(dir.modifiers))) : '') + "},";
      }
    }
    if (hasRuntime) {
      return res.slice(0, -1) + ']'
    }
  }

参考 Vue面试题详细解答

这里有一句关键的代码var gen = state.directives[dir.name],为了了解其来龙去脉,我们回到Vue源码中的编译流程,在以往的文章中,我们完整的介绍过template模板的编译流程,这一部分的设计是非常复杂且巧妙的,其中大量运用了偏函数的思想,即分离了不同平台不同的编译过程,也为同一个平台每次提供相同的配置选项进行了合并处理,并很好的将配置进行了缓存。其中针对浏览器端有三个重要的指令选项。

var directive$1 = {
  model: model,
  text: text,
  html, html
}
var baseOptions = {
  ···
  // 指令选项
  directives: directives$1,
};
// 编译时传入选项配置
createCompiler(baseOptions)

而这个state.directives['model']也就是对应的model函数,所以我们先把焦点聚焦在model函数的逻辑。

function model (el,dir,_warn) {
    warn$1 = _warn;
    // 绑定的值
    var value = dir.value;
    var modifiers = dir.modifiers;
    var tag = el.tag;
    var type = el.attrsMap.type;
    {
      // 这里遇到type是file的html,如果还使用双向绑定会报出警告。
      // 因为File inputs是只读的
      if (tag === 'input' && type === 'file') {
        warn$1(
          "<" + (el.tag) + " v-model=\"" + value + "\" type=\"file\">:\n" +
          "File inputs are read only. Use a v-on:change listener instead.",
          el.rawAttrsMap['v-model']
        );
      }
    }
    //组件上v-model的处理
    if (el.component) {
      genComponentModel(el, value, modifiers);
      // component v-model doesn't need extra runtime
      return false
    } else if (tag === 'select') {
      // select表单
      genSelect(el, value, modifiers);
    } else if (tag === 'input' && type === 'checkbox') {
      // checkbox表单
      genCheckboxModel(el, value, modifiers);
    } else if (tag === 'input' && type === 'radio') {
      // radio表单
      genRadioModel(el, value, modifiers);
    } else if (tag === 'input' || tag === 'textarea') {
      // 普通input,如 text, textarea
      genDefaultModel(el, value, modifiers);
    } else if (!config.isReservedTag(tag)) {
      genComponentModel(el, value, modifiers);
      // component v-model doesn't need extra runtime
      return false
    } else {
      // 如果不是表单使用v-model,同样会报出警告,双向绑定只针对表单控件。
      warn$1(
        "<" + (el.tag) + " v-model=\"" + value + "\">: " +
        "v-model is not supported on this element type. " +
        'If you are working with contenteditable, it\'s recommended to ' +
        'wrap a library dedicated for that purpose inside a custom component.',
        el.rawAttrsMap['v-model']
      );
    }
    // ensure runtime directive metadata
    // 
    return true
  }

显然,model会对表单控件的AST树做进一步的处理,在上面的基础用法中,我们知道表单有不同的类型,每种类型对应的事件处理响应机制也不同。因此我们需要针对不同的表单控件生成不同的render函数,因此需要产生不同的AST属性。model针对不同类型的表单控件有不同的处理分支。我们重点分析普通input标签的处理,genDefaultModel分支,其他类型的分支,可以仿照下面的分析过程。

function genDefaultModel (el,value,modifiers) {
    var type = el.attrsMap.type;

    // v-model和v-bind值相同值,有冲突会报错
    {
      var value$1 = el.attrsMap['v-bind:value'] || el.attrsMap[':value'];
      var typeBinding = el.attrsMap['v-bind:type'] || el.attrsMap[':type'];
      if (value$1 && !typeBinding) {
        var binding = el.attrsMap['v-bind:value'] ? 'v-bind:value' : ':value';
        warn$1(
          binding + "=\"" + value$1 + "\" conflicts with v-model on the same element " +
          'because the latter already expands to a value binding internally',
          el.rawAttrsMap[binding]
        );
      }
    }
    // modifiers存贮的是v-model的修饰符。
    var ref = modifiers || {};
    // lazy,trim,number是可供v-model使用的修饰符
    var lazy = ref.lazy;
    var number = ref.number;
    var trim = ref.trim;
    var needCompositionGuard = !lazy && type !== 'range';
    // lazy修饰符将触发同步的事件从input改为change
    var event = lazy ? 'change' : type === 'range' ? RANGE_TOKEN : 'input';

    var valueExpression = '$event.target.value';
    // 过滤用户输入的首尾空白符
    if (trim) {
      valueExpression = "$event.target.value.trim()";
    }
    // 将用户输入转为数值类型
    if (number) {
      valueExpression = "_n(" + valueExpression + ")";
    }
    // genAssignmentCode函数是为了处理v-model的格式,允许使用以下的形式: v-model="a.b" v-model="a[b]"
    var code = genAssignmentCode(value, valueExpression);
    if (needCompositionGuard) {
      //  保证了不会在输入法组合文字过程中得到更新
      code = "if($event.target.composing)return;" + code;
    }
    //  添加value属性
    addProp(el, 'value', ("(" + value + ")"));
    // 绑定事件
    addHandler(el, event, code, null, true);
    if (trim || number) {
      addHandler(el, 'blur', '$forceUpdate()');
    }
  }

function genAssignmentCode (value,assignment) {
  // 处理v-model的格式,v-model="a.b" v-model="a[b]"
  var res = parseModel(value);
  if (res.key === null) {
    // 普通情形
    return (value + "=" + assignment)
  } else {
    // 对象形式
    return ("$set(" + (res.exp) + ", " + (res.key) + ", " + assignment + ")")
  }
}

genDefaultModel的逻辑有两部分,一部分是针对修饰符产生不同的事件处理字符串,二是为v-model产生的AST树添加属性和事件相关的属性。其中最重要的两行代码是

//  添加value属性
addProp(el, 'value', ("(" + value + ")"));
// 绑定事件属性
addHandler(el, event, code, null, true);

addHandler在之前介绍事件时分析过,他会为AST树添加事件相关的属性,同样的addProp也会为AST树添加props属性。最终AST树新增了两个属性:

回到genData,通过genDirectives处理后,原先的AST树新增了两个属性,因此在字符串生成阶段同样需要处理propsevents的分支。

function genData$2 (el, state) {
  var data = '{';
  // 已经分析过的genDirectives
  var dirs = genDirectives(el, state);
  // 处理props
  if (el.props) {
    data += "domProps:" + (genProps(el.props)) + ",";
  }
  // 处理事件
  if (el.events) {
    data += (genHandlers(el.events, false)) + ",";
  }
}

最终render函数的结果为:

"_c('input',{directives:[{name:"model",rawName:"v-model",value:(message),expression:"message"}],attrs:{"type":"text"},domProps:{"value":(message)},on:{"input":function($event){if($event.target.composing)return;message=$event.target.value}}})"

<input type="text" v-model="value"> 如果觉得上面的流程分析啰嗦,可以直接看下面的结论,对比模板和生成的render函数,我们可以得到:

    1. input标签所有属性,包括指令相关的内容都是以data属性的形式作为参数的整体传入_c(即:createElement)函数。
    1. input type的类型,在data属性中,以attrs键值对存在。
    1. v-model会有对应的directives属性描述指令的相关信息。
    1. 为什么说v-model是一个语法糖,从render函数的最终结果可以看出,它最终以两部分形式存在于input标签中,一个是将value1props的形式存在(domProps)中,另一个是以事件的形式存储input事件,并保留在on属性中。
    1. 重要的一个关键,事件用$event.target.composing属性来保证不会在输入法组合文字过程中更新数据,这点我们后面会再次提到。
11.1.4 patch真实节点

patch之前还有一个生成vnode的过程,这个过程没有什么特别之处,所有的包括指令,属性会以data属性的形式传递到构造函数Vnode中,最终的Vnode拥有directives,domProps,on属性:

有了Vnode之后紧接着会执行patchVnode,patchVnode过程是一个真实节点创建的过程,其中的关键是createElm方法,这个方法我们在不同的场合也分析过,前面的源码得到指令相关的信息也会保留在vnodedata属性里,所以对属性的处理也会走invokeCreateHooks逻辑。

function createElm() {
  ···
  // 针对指令的处理
   if (isDef(data)) {
      invokeCreateHooks(vnode, insertedVnodeQueue);
    }
}

invokeCreateHooks会调用定义好的钩子函数,对vnode上定义的属性,指令,事件等进行真实DOM的处理,步骤包括以下(不包含全部):

    1. updateDOMProps会利用vnode data上的domProps更新input标签的value值;
    1. updateAttrs会利用vnode data上的attrs属性更新节点的属性值;
    1. updateDomListeners利用vnode data上的on属性添加事件监听。

因此v-model语法糖最终反应的结果,是通过监听表单控件自身的input事件(其他类型有不同的监听事件类型),去影响自身的value。如果没有v-model的语法糖,我们可以这样写: <input type="text" :value="message" @input="(e) => { this.message = e.target.value }" >

11.1.5 语法糖的背后

然而v-model仅仅是起到合并语法,创建一个新的语法糖的意义吗? **显然答案是否定的,对于需要使用输入法 (如中文、日文、韩文等) 的语言,你会发现 v-model 不会在输入法组合文字过程中得到更新。**这就是v-model的一个重要的特点。它会在事件处理这一层添加新的事件监听compositionstart,compositionend,他们会分别在语言输入的开始和结束时监听到变化,只要借助$event.target.composing,就可以设计出只会在输入法组合文字的结束阶段才更新数据,这有利于提高用户的使用体验。这一部分我想借助脱离框架的表单来帮助理解。

脱离框架的一个视图响应数据的实现(效果类似于v-model):

// html
<input type="text" id="inputValue">
<span id="showValue"></span>

// js

<script>
    let input = document.getElementById('inputValue');
    let show = document.getElementById('showValue');
    input.value = 123;
    show.innerText = input.value

    function onCompositionStart(e) {
      e.target.composing = true;
    }

    function onCompositionEnd(e) {
      if (!e.target.composing) {
        return
      }
      e.target.composing = false;
      show.innerText = e.target.value
    }
    function onInputChange(e) {
      // e.target.composing表示是否还在输入中
      if(e.target.composing)return;
      show.innerText = e.target.value
    }
    input.addEventListener('input', onInputChange)
    input.addEventListener('compositionstart', onCompositionStart)// 组合输入开始
    input.addEventListener('compositionend', onCompositionEnd) // 组合输入结束
</script>

11.2 组件使用v-model

最后我们简单说说在父组件中使用v-model,可以先看结论,组件上使用v-model本质上是子父组件通信的语法糖。先看一个简单的使用例子。

 var child = {
    template: '<div><input type="text" :value="value" @input="emitEvent">{{value}}</div>',    methods: {      emitEvent(e) {        this.$emit('input', e.target.value)      }    },    props: ['value']  } new Vue({   data() {     return {       message: 'test'     }   },   components: {     child   },   template: '<div id="app"><child v-model="message"></child></div>',   el: '#app'
 })

父组件上使用v-model, 子组件默认会利用名为 valueprop 和名为 input 的事件,当然像select表单会以其他默认事件的形式存在。分析源码的过程也大致类似,这里只列举几个特别的地方。

AST生成阶段和普通表单控件的区别在于,当遇到child时,由于不是普通的html标签,会执行getComponentModel的过程,而getComponentModel的结果是在AST树上添加model的属性。

function model() {
  if (!config.isReservedTag(tag)) {
    genComponentModel(el, value, modifiers);
  }
}

function genComponentModel (el,value,modifiers) {
    var ref = modifiers || {};
    var number = ref.number;
    var trim = ref.trim;

    var baseValueExpression = '?v';
    var valueExpression = baseValueExpression;
    if (trim) {
      valueExpression =
        "(typeof " + baseValueExpression + " === 'string'" +        "? " + baseValueExpression + ".trim()" +        ": " + baseValueExpression + ")";    }    if (number) {      valueExpression = "_n(" + valueExpression + ")";    }    var assignment = genAssignmentCode(value, valueExpression);    // 在ast树上添加model属性,其中有value,expression,callback属性    el.model = {      value: ("(" + value + ")"),      expression: JSON.stringify(value),      callback: ("function (" + baseValueExpression + ") {" + assignment + "}")    };  }

最终AST树的结果:

{
  model: {
    callback: "function ($$v) {message=$$v}"
    expression: ""message""
    value: "(message)"
  }
}

经过对AST树的处理后,回到genData$2的流程,由于有了model属性,父组件拼接的字符串会做进一步处理。

function genData$2 (el, state) { 
  var data = '{';
  var dirs = genDirectives(el, state);
  ···
  // v-model组件的render函数处理
  if (el.model) {
    data += "model:{value:" + (el.model.value) + ",callback:" + (el.model.callback) + ",expression:" + (el.model.expression) + "},";
  }
  ···
  return data
}

因此,父组件最终的render函数表现为: "_c('child',{model:{value:(message),callback:function (?v) {message=?v},expression:"message"}})"

子组件的创建阶段照例会执行createComponent,其中针对model的逻辑需要特别说明。

function createComponent() {
  // transform component v-model data into props & events
  if (isDef(data.model)) {
    // 处理父组件的v-model指令对象
    transformModel(Ctor.options, data);
  }
}
function transformModel (options, data) {
  // prop默认取的是value,除非配置上有model的选项
  var prop = (options.model && options.model.prop) || 'value';

  // event默认取的是input,除非配置上有model的选项
  var event = (options.model && options.model.event) || 'input'
  // vnode上新增props的属性,值为value
  ;(data.attrs || (data.attrs = {}))[prop] = data.model.value;

  // vnode上新增on属性,标记事件
  var on = data.on || (data.on = {});
  var existing = on[event];
  var callback = data.model.callback;
  if (isDef(existing)) {
    if (
      Array.isArray(existing)
        ? existing.indexOf(callback) === -1
        : existing !== callback
    ) {
      on[event] = [callback].concat(existing);
    }
  } else {
    on[event] = callback;
  }
}

transformModel的逻辑可以看出,子组件vnode会为data.props 添加 data.model.value,并且给data.on 添加data.model.callback。因此父组件v-model语法糖本质上可以修改为 '<child :value="message" @input="function(e){message = e}"></child>'

显然,这种写法就是事件通信的写法,这个过程又回到对事件指令的分析过程了。因此我们可以很明显的意识到,组件使用v-model本质上还是一个子父组件通信的语法糖。