vue2.0源码简读(5. 扩展)

发布时间 2023-11-17 15:52:03作者: 柯基与佩奇

5.1 event

平时开发工作中,处理组件间的通讯,原生的交互,都离不开事件。对于一个组件元素,不仅仅可以绑定原生的 DOM 事件,还可以绑定自定义事件,非常灵活和方便。那么接下来从源码角度来看看它的实现原理。

为了更加直观,通过一个例子来分析它的实现:

let Child = {
  template: '<button @click="clickHandler($event)">' + "click me" + "</button>",
  methods: {
    clickHandler(e) {
      console.log("Button clicked!", e);
      this.$emit("select");
    },
  },
};

let vm = new Vue({
  el: "#app",
  template:
    "<div>" +
    '<child @select="selectHandler" @click.native.prevent="clickHandler"></child>' +
    "</div>",
  methods: {
    clickHandler() {
      console.log("Child clicked!");
    },
    selectHandler() {
      console.log("Child select!");
    },
  },
  components: {
    Child,
  },
});

编译

先从编译阶段开始看起,在 parse 阶段,会执行 processAttrs 方法,它的定义在 src/compiler/parser/index.js 中:

export const onRE = /^@|^v-on:/;
export const dirRE = /^v-|^@|^:/;
export const bindRE = /^:|^v-bind:/;
function processAttrs(el) {
  const list = el.attrsList;
  let i, l, name, rawName, value, modifiers, isProp;
  for (i = 0, l = list.length; i < l; i++) {
    name = rawName = list[i].name;
    value = list[i].value;
    if (dirRE.test(name)) {
      el.hasBindings = true;
      modifiers = parseModifiers(name);
      if (modifiers) {
        name = name.replace(modifierRE, "");
      }
      if (bindRE.test(name)) {
        // ..
      } else if (onRE.test(name)) {
        name = name.replace(onRE, "");
        addHandler(el, name, value, modifiers, false, warn);
      } else {
        // ...
      }
    } else {
      // ...
    }
  }
}

function parseModifiers(name: string): Object | void {
  const match = name.match(modifierRE);
  if (match) {
    const ret = {};
    match.forEach((m) => {
      ret[m.slice(1)] = true;
    });
    return ret;
  }
}

在对标签属性的处理过程中,判断如果是指令,首先通过 parseModifiers 解析出修饰符,然后判断如果事件的指令,则执行 addHandler(el, name, value, modifiers, false, warn) 方法,它的定义在 src/compiler/helpers.js 中:

export function addHandler(
  el: ASTElement,
  name: string,
  value: string,
  modifiers: ?ASTModifiers,
  important?: boolean,
  warn?: Function
) {
  modifiers = modifiers || emptyObject;
  // warn prevent and passive modifier
  /* istanbul ignore if */
  if (
    process.env.NODE_ENV !== "production" &&
    warn &&
    modifiers.prevent &&
    modifiers.passive
  ) {
    warn(
      "passive and prevent can't be used together. " +
        "Passive handler can't prevent default event."
    );
  }

  // check capture modifier
  if (modifiers.capture) {
    delete modifiers.capture;
    name = "!" + name; // mark the event as captured
  }
  if (modifiers.once) {
    delete modifiers.once;
    name = "~" + name; // mark the event as once
  }
  /* istanbul ignore if */
  if (modifiers.passive) {
    delete modifiers.passive;
    name = "&" + name; // mark the event as passive
  }

  // normalize click.right and click.middle since they don't actually fire
  // this is technically browser-specific, but at least for now browsers are
  // the only target envs that have right/middle clicks.
  if (name === "click") {
    if (modifiers.right) {
      name = "contextmenu";
      delete modifiers.right;
    } else if (modifiers.middle) {
      name = "mouseup";
    }
  }

  let events;
  if (modifiers.native) {
    delete modifiers.native;
    events = el.nativeEvents || (el.nativeEvents = {});
  } else {
    events = el.events || (el.events = {});
  }

  const newHandler: any = {
    value: value.trim(),
  };
  if (modifiers !== emptyObject) {
    newHandler.modifiers = modifiers;
  }

  const 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;
}

addHandler 函数看起来长,实际上就做了 3 件事情,首先根据 modifier 修饰符对事件名 name 做处理,接着根据 modifier.native 判断是一个纯原生事件还是普通事件,分别对应 el.nativeEvents 和 el.events,最后按照 name 对事件做归类,并把回调函数的字符串保留到对应的事件中。

在例子中,父组件的 child 节点生成的 el.events 和 el.nativeEvents 如下:

el.events = {
  select: {
    value: "selectHandler",
  },
};

el.nativeEvents = {
  click: {
    value: "clickHandler",
    modifiers: {
      prevent: true,
    },
  },
};

子组件的 button 节点生成的 el.events 如下:

el.events = {
  click: {
    value: "clickHandler($event)",
  },
};

然后在 codegen 的阶段,会在 genData 函数中根据 AST 元素节点上的 events 和 nativeEvents 生成 data 数据,它的定义在 src/compiler/codegen/index.js 中:

export function genData(el: ASTElement, state: CodegenState): string {
  let data = "{";
  // ...
  if (el.events) {
    data += `${genHandlers(el.events, false, state.warn)},`;
  }
  if (el.nativeEvents) {
    data += `${genHandlers(el.nativeEvents, true, state.warn)},`;
  }
  // ...
  return data;
}

对于这两个属性,会调用 genHandlers 函数,定义在 src/compiler/codegen/events.js 中:

export function genHandlers(
  events: ASTElementHandlers,
  isNative: boolean,
  warn: Function
): string {
  let res = isNative ? "nativeOn:{" : "on:{";
  for (const name in events) {
    res += `"${name}":${genHandler(name, events[name])},`;
  }
  return res.slice(0, -1) + "}";
}

const fnExpRE = /^\s*([\w$_]+|\([^)]*?\))\s*=>|^function\s*\(/;
const simplePathRE =
  /^\s*[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\['.*?']|\[".*?"]|\[\d+]|\[[A-Za-z_$][\w$]*])*\s*$/;
function genHandler(
  name: string,
  handler: ASTElementHandler | Array<ASTElementHandler>
): string {
  if (!handler) {
    return "function(){}";
  }

  if (Array.isArray(handler)) {
    return `[${handler.map((handler) => genHandler(name, handler)).join(",")}]`;
  }

  const isMethodPath = simplePathRE.test(handler.value);
  const isFunctionExpression = fnExpRE.test(handler.value);

  if (!handler.modifiers) {
    if (isMethodPath || isFunctionExpression) {
      return handler.value;
    }
    /* istanbul ignore if */
    if (__WEEX__ && handler.params) {
      return genWeexHandler(handler.params, handler.value);
    }
    return `function($event){${handler.value}}`; // inline statement
  } else {
    let code = "";
    let genModifierCode = "";
    const keys = [];
    for (const key in handler.modifiers) {
      if (modifierCode[key]) {
        genModifierCode += modifierCode[key];
        // left/right
        if (keyCodes[key]) {
          keys.push(key);
        }
      } else if (key === "exact") {
        const modifiers: ASTModifiers = (handler.modifiers: any);
        genModifierCode += genGuard(
          ["ctrl", "shift", "alt", "meta"]
            .filter((keyModifier) => !modifiers[keyModifier])
            .map((keyModifier) => `$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;
    }
    const handlerCode = isMethodPath
      ? `return ${handler.value}($event)`
      : isFunctionExpression
      ? `return (${handler.value})($event)`
      : handler.value;
    /* istanbul ignore if */
    if (__WEEX__ && handler.params) {
      return genWeexHandler(handler.params, code + handlerCode);
    }
    return `function($event){${code}${handlerCode}}`;
  }
}

genHandlers 方法遍历事件对象 events,对同一个事件名称的事件调用 genHandler(name, events[name]) 方法,它的内容看起来多,但实际上逻辑很简单,首先先判断如果 handler 是一个数组,就遍历它然后递归调用 genHandler 方法并拼接结果,然后判断 hanlder.value 是一个函数的调用路径还是一个函数表达式, 接着对 modifiers 做判断,对于没有 modifiers 的情况,就根据 handler.value 不同情况处理,要么直接返回,要么返回一个函数包裹的表达式;对于有 modifiers 的情况,则对各种不同的 modifer 情况做不同处理,添加相应的代码串。

那么对于例子而言,父组件生成的 data 串为:

{
  on: {"select": selectHandler},
  nativeOn: {"click": function($event) {
      $event.preventDefault();
      return clickHandler($event)
    }
  }
}

子组件生成的 data 串为:

{
  on: {"click": function($event) {
      clickHandler($event)
    }
  }
}

那么到这里,编译部分完了,接下来来看一下运行时部分是如何实现的。其实 Vue 的事件有 2 种,一种是原生 DOM 事件,一种是用户自定义事件,分别来看。

DOM 事件

还记得之前在 patch 的时候执行各种 module 的钩子函数吗,当时这部分是略过的,之前只分析了 DOM 是如何渲染的,而 DOM 元素相关的属性、样式、事件等都是通过这些 module 的钩子函数完成设置的。

所有和 web 相关的 module 都定义在 src/platforms/web/runtime/modules 目录下,这次只关注目录下的 events.js 即可。

在 patch 过程中的创建阶段和更新阶段都会执行 updateDOMListeners:

let target: any;
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, vnode.context);
  target = undefined;
}

首先获取 vnode.data.on,这就是之前的生成的 data 中对应的事件对象,target 是当前 vnode 对于的 DOM 对象,normalizeEvents 主要是对 v-model 相关的处理,之后分析 v-model 的时候会介绍,接着调用 updateListeners(on, oldOn, add, remove, vnode.context) 方法,它的定义在 src/core/vdom/helpers/update-listeners.js 中:

export function updateListeners(
  on: Object,
  oldOn: Object,
  add: Function,
  remove: Function,
  vm: Component
) {
  let name, def, cur, old, event;
  for (name in on) {
    def = cur = on[name];
    old = oldOn[name];
    event = normalizeEvent(name);
    /* istanbul ignore if */
    if (__WEEX__ && isPlainObject(def)) {
      cur = def.handler;
      event.params = def.params;
    }
    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);
      }
      add(
        event.name,
        cur,
        event.once,
        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(event.name, oldOn[name], event.capture);
    }
  }
}

updateListeners 的逻辑很简单,遍历 on 去添加事件监听,遍历 oldOn 去移除事件监听,关于监听和移除事件的方法都是外部传入的,因为它既处理原生 DOM 事件的添加删除,也处理自定义事件的添加删除。

对于 on 的遍历,首先获得每一个事件名,然后做 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,
    };
  }
);

根据的的事件名的一些特殊标识(之前在 addHandler 的时候添加上的)区分出这个事件是否有 once、capture、passive 等修饰符。

处理完事件名后,又对事件回调函数做处理,对于第一次,满足 isUndef(old) 并且 isUndef(cur.fns),会执行 cur = on[name] = createFnInvoker(cur) 方法去创建一个回调函数,然后在执行 add(event.name, cur, event.once, event.capture, event.passive, event.params) 完成一次事件绑定。先看一下 createFnInvoker 的实现:

export function createFnInvoker(fns: Function | Array<Function>): Function {
  function invoker() {
    const fns = invoker.fns;
    if (Array.isArray(fns)) {
      const cloned = fns.slice();
      for (let i = 0; i < cloned.length; i++) {
        cloned[i].apply(null, arguments);
      }
    } else {
      return fns.apply(null, arguments);
    }
  }
  invoker.fns = fns;
  return invoker;
}

这里定义了 invoker 方法并返回,由于一个事件可能会对应多个回调函数,所以这里做了数组的判断,多个回调函数就依次调用。注意最后的赋值逻辑, invoker.fns = fns,每一次执行 invoker 函数都是从 invoker.fns 里取执行的回调函数,回到 updateListeners,当第二次执行该函数的时候,判断如果 cur !== old,那么只需要更改 old.fns = cur 把之前绑定的 involer.fns 赋值为新的回调函数即可,并且 通过 on[name] = old 保留引用关系,这样就保证了事件回调只添加一次,之后仅仅去修改它的回调函数的引用。

updateListeners 函数的最后遍历 oldOn 拿到事件名称,判断如果满足 isUndef(on[name]),则执行 remove(event.name, oldOn[name], event.capture) 去移除事件回调。

了解了 updateListeners 的实现后,来看一下在原生 DOM 事件中真正添加回调和移除回调函数的实现,它们的定义都在 src/platforms/web/runtime/modules/event.js 中:

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

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

add 和 remove 的逻辑很简单,就是实际上调用原生 addEventListener 和 removeEventListener,并根据参数传递一些配置,注意这里的 hanlder 会用 withMacroTask(hanlder) 包裹一下,它的定义在 src/core/util/next-tick.js 中:

export function withMacroTask(fn: Function): Function {
  return (
    fn._withTask ||
    (fn._withTask = function () {
      useMacroTask = true;
      const res = fn.apply(null, arguments);
      useMacroTask = false;
      return res;
    })
  );
}

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

自定义事件

除了原生 DOM 事件,Vue 还支持了自定义事件,并且自定义事件只能作用在组件上,如果在组件上使用原生事件,需要加 .native 修饰符,普通元素上使用 .native 修饰符无效,接下来就来分析它的实现。

在 render 阶段,如果是一个组件节点,则通过 createComponent 创建一个组件 vnode,再来回顾这个方法,定义在 src/core/vdom/create-component.js 中:

export function createComponent(
  Ctor: Class<Component> | Function | Object | void,
  data: ?VNodeData,
  context: Component,
  children: ?Array<VNode>,
  tag?: string
): VNode | Array<VNode> | void {
  // ...
  const listeners = data.on;

  data.on = data.nativeOn;

  // ...
  const name = Ctor.options.name || tag;
  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 赋值给了 listeners,把 data.nativeOn 赋值给了 data.on,这样所有的原生 DOM 事件处理跟刚才介绍的一样,它是在当前组件环境中处理的。而对于自定义事件,把 listeners 作为 vnode 的 componentOptions 传入,它是在子组件初始化阶段中处理的,所以它的处理环境是子组件。

然后在子组件的初始化的时候,会执行 initInternalComponent 方法,它的定义在 src/core/instance/init.js 中:

export function initInternalComponent(
  vm: Component,
  options: InternalComponentOptions
) {
  const opts = (vm.$options = Object.create(vm.constructor.options));
  // ....
  const vnodeComponentOptions = parentVnode.componentOptions;

  opts._parentListeners = vnodeComponentOptions.listeners;
  // ...
}

这里拿到了父组件传入的 listeners,然后在执行 initEvents 的过程中,会处理这个 listeners,定义在 src/core/instance/events.js 中:

export function initEvents(vm: Component) {
  vm._events = Object.create(null);
  vm._hasHookEvent = false;
  // init parent attached events
  const listeners = vm.$options._parentListeners;
  if (listeners) {
    updateComponentListeners(vm, listeners);
  }
}

拿到 listeners 后,执行 updateComponentListeners(vm, listeners) 方法:

let target: any;
export function updateComponentListeners(
  vm: Component,
  listeners: Object,
  oldListeners: ?Object
) {
  target = vm;
  updateListeners(listeners, oldListeners || {}, add, remove, vm);
  target = undefined;
}

updateListeners 之前介绍过,所以对于自定义事件和原生 DOM 事件处理的差异就在事件添加和删除的实现上,来看一下自定义事件 add 和 remove 的实现:

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

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

实际上是利用 Vue 定义的事件中心,简单分析一下它的实现:

export function eventsMixin(Vue: Class<Component>) {
  const hookRE = /^hook:/;
  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++) {
        this.$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;
  };

  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.$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++) {
        this.$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;
    }
    if (fn) {
      // specific handler
      let cb;
      let i = cbs.length;
      while (i--) {
        cb = cbs[i];
        if (cb === fn || cb.fn === fn) {
          cbs.splice(i, 1);
          break;
        }
      }
    }
    return vm;
  };

  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(
          `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}".`
        );
      }
    }
    let cbs = vm._events[event];
    if (cbs) {
      cbs = cbs.length > 1 ? toArray(cbs) : cbs;
      const args = toArray(arguments, 1);
      for (let 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;
  };
}

非常经典的事件中心的实现,把所有的事件用 vm._events 存储起来,当执行 vm.$on(event,fn) 的时候,按事件的名称 event 把回调函数 fn 存储起来 vm._events[event].push(fn)。当执行 vm.$emit(event) 的时候,根据事件名 event 找到所有的回调函数 let cbs = vm._events[event],然后遍历执行所有的回调函数。当执行 vm.$off(event,fn) 的时候会移除指定事件名 event 和指定的 fn 当执行 vm.$once(event,fn) 的时候,内部就是执行 vm.$on,并且当回调函数执行一次后再通过 vm.$off 移除事件的回调,这样就确保了回调函数只执行一次。

所以对于用户自定义的事件添加和删除就是利用了这几个事件中心的 API。需要注意的事一点,vm.$emit 是给当前的 vm 上派发的实例,之所以常用它做父子组件通讯,是因为它的回调函数的定义是在父组件中,对于这个例子而言,当子组件的 button 被点击了,它通过 this.$emit('select') 派发事件,那么子组件的实例就监听到了这个 select 事件,并执行它的回调函数——定义在父组件中的 selectHandler 方法,这样就相当于完成了一次父子组件的通讯。

5.2 v-model

在理解 Vue 的时候都把 Vue 的数据响应原理理解为双向绑定,但实际上这是不准确的,之前提到的数据响应,都是通过数据的改变去驱动 DOM 视图的变化,而双向绑定除了数据驱动 DOM 外, DOM 的变化反过来影响数据,是一个双向关系,在 Vue 中,可以通过 v-model 来实现双向绑定。

v-model 即可以作用在普通表单元素上,又可以作用在组件上,它其实是一个语法糖,接下来就来分析 v-model 的实现原理。

表单元素

为了更加直观,还是结合示例来分析:

let vm = new Vue({
  el: "#app",
  template:
    "<div>" +
    '<input v-model="message" placeholder="edit me">' +
    "<p>Message is: {{ message }}</p>" +
    "</div>",
  data() {
    return {
      message: "",
    };
  },
});

这是一个非常简单 demo,在 input 元素上设置了 v-model 属性,绑定了 message,当在 input 上输入了内容,message 也会同步变化。接下来就来分析 Vue 是如何实现这一效果的,其实非常简单。

也是先从编译阶段分析,首先是 parse 阶段, v-model 被当做普通的指令解析到 el.directives 中,然后在 codegen 阶段,执行 genData 的时候,会执行 const dirs = genDirectives(el, state),它的定义在 src/compiler/codegen/index.js 中:

function genDirectives(el: ASTElement, state: CodegenState): string | void {
  const dirs = el.directives;
  if (!dirs) return;
  let res = "directives:[";
  let hasRuntime = false;
  let i, l, dir, needRuntime;
  for (i = 0, l = dirs.length; i < l; i++) {
    dir = dirs[i];
    needRuntime = true;
    const gen: DirectiveFunction = 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.arg}"` : ""}${
        dir.modifiers ? `,modifiers:${JSON.stringify(dir.modifiers)}` : ""
      }},`;
    }
  }
  if (hasRuntime) {
    return res.slice(0, -1) + "]";
  }
}

genDrirectives 方法就是遍历 el.directives,然后获取每一个指令对应的方法 const gen: DirectiveFunction = state.directives[dir.name],这个指令方法实际上是在实例化 CodegenState 的时候通过 option 传入的,这个 option 就是编译相关的配置,它在不同的平台下配置不同,在 web 环境下的定义在 src/platforms/web/compiler/options.js 下:

export const baseOptions: CompilerOptions = {
  expectHTML: true,
  modules,
  directives,
  isPreTag,
  isUnaryTag,
  mustUseProp,
  canBeLeftOpenTag,
  isReservedTag,
  getTagNamespace,
  staticKeys: genStaticKeys(modules)
}
directives 定义在 src/platforms/web/compiler/directives/index.js 中:

export default {
  model,
  text,
  html
}

那么对于 v-model 而言,对应的 directive 函数是在 src/platforms/web/compiler/directives/model.js 中定义的 model 函数:

export default function model(
  el: ASTElement,
  dir: ASTDirective,
  _warn: Function
): ?boolean {
  warn = _warn;
  const value = dir.value;
  const modifiers = dir.modifiers;
  const tag = el.tag;
  const type = el.attrsMap.type;

  if (process.env.NODE_ENV !== "production") {
    // inputs with type="file" are read only and setting the input's
    // value will throw an error.
    if (tag === "input" && type === "file") {
      warn(
        `<${el.tag} v-model="${value}" type="file">:\n` +
          `File inputs are read only. Use a v-on:change listener instead.`
      );
    }
  }

  if (el.component) {
    genComponentModel(el, value, modifiers);
    // component v-model doesn't need extra runtime
    return false;
  } else if (tag === "select") {
    genSelect(el, value, modifiers);
  } else if (tag === "input" && type === "checkbox") {
    genCheckboxModel(el, value, modifiers);
  } else if (tag === "input" && type === "radio") {
    genRadioModel(el, value, modifiers);
  } else if (tag === "input" || tag === "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 if (process.env.NODE_ENV !== "production") {
    warn(
      `<${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."
    );
  }

  // ensure runtime directive metadata
  return true;
}

也就是说执行 needRuntime = !!gen(el, dir, state.warn) 就是在执行 model 函数,它会根据 AST 元素节点的不同情况去执行不同的逻辑,对于这个 case 而言,它会命中 genDefaultModel(el, value, modifiers) 的逻辑,稍后也会介绍组件的处理。来看一下 genDefaultModel 的实现:

function genDefaultModel(
  el: ASTElement,
  value: string,
  modifiers: ?ASTModifiers
): ?boolean {
  const type = el.attrsMap.type;

  // warn if v-bind:value conflicts with v-model
  // except for inputs with v-bind:type
  if (process.env.NODE_ENV !== "production") {
    const value = el.attrsMap["v-bind:value"] || el.attrsMap[":value"];
    const typeBinding = el.attrsMap["v-bind:type"] || el.attrsMap[":type"];
    if (value && !typeBinding) {
      const binding = el.attrsMap["v-bind:value"] ? "v-bind:value" : ":value";
      warn(
        `${binding}="${value}" conflicts with v-model on the same element ` +
          "because the latter already expands to a value binding internally"
      );
    }
  }

  const { lazy, number, trim } = modifiers || {};
  const needCompositionGuard = !lazy && type !== "range";
  const event = lazy ? "change" : type === "range" ? RANGE_TOKEN : "input";

  let valueExpression = "$event.target.value";
  if (trim) {
    valueExpression = `$event.target.value.trim()`;
  }
  if (number) {
    valueExpression = `_n(${valueExpression})`;
  }

  let code = genAssignmentCode(value, valueExpression);
  if (needCompositionGuard) {
    code = `if($event.target.composing)return;${code}`;
  }

  addProp(el, "value", `(${value})`);
  addHandler(el, event, code, null, true);
  if (trim || number) {
    addHandler(el, "blur", "$forceUpdate()");
  }
}

genDefaultModel 函数先处理了 modifiers,它的不同主要影响的是 event 和 valueExpression 的值,对于的例子,event 为 input,valueExpression 为 $event.target.value。然后去执行 genAssignmentCode 去生成代码,它的定义在 src/compiler/directives/model.js 中:

/**
 * Cross-platform codegen helper for generating v-model value assignment code.
 */
export function genAssignmentCode(value: string, assignment: string): string {
  const res = parseModel(value);
  if (res.key === null) {
    return `${value}=${assignment}`;
  } else {
    return `$set(${res.exp}, ${res.key}, ${assignment})`;
  }
}

该方法首先对 v-model 对应的 value 做了解析,它处理了非常多的情况,对例子,value 就是 messgae,所以返回的 res.key 为 null,然后就得到 ${value}=${assignment},也就是 message=$event.target.value。然后又命中了 needCompositionGuard 为 true 的逻辑,所以最终的 code 为 if($event.target.composing)return;message=$event.target.value。

code 生成完后,又执行了 2 句非常关键的代码:

addProp(el, 'value', (${value}))
addHandler(el, event, code, null, true)
这实际上就是 input 实现 v-model 的精髓,通过修改 AST 元素,给 el 添加一个 prop,相当于在 input 上动态绑定了 value,又给 el 添加了事件处理,相当于在 input 上绑定了 input 事件,其实转换成模板如下:

<input v-bind:value="message" v-on:input="message=$event.target.value" />

其实就是动态绑定了 input 的 value 指向了 messgae 变量,并且在触发 input 事件的时候去动态把 message 设置为目标值,这样实际上就完成了数据双向绑定了,所以说 v-model 实际上就是语法糖。

再回到 genDirectives,它接下来的逻辑就是根据指令生成一些 data 的代码:

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.arg}"` : ""}${
    dir.modifiers ? `,modifiers:${JSON.stringify(dir.modifiers)}` : ""
  }},`;
}

对例子而言,最终生成的 render 代码如下:

with (this) {
  return _c("div", [
    _c("input", {
      directives: [
        {
          name: "model",
          rawName: "v-model",
          value: message,
          expression: "message",
        },
      ],
      attrs: { placeholder: "edit me" },
      domProps: { value: message },
      on: {
        input: function ($event) {
          if ($event.target.composing) return;
          message = $event.target.value;
        },
      },
    }),
    _c("p", [_v("Message is: " + _s(message))]),
  ]);
}

关于事件的处理之前的章节已经分析过了,所以对于 input 的 v-model 而言,完全就是语法糖,并且对于其它表单元素套路都是一样,区别在于生成的事件代码会略有不同。

v-model 除了作用在表单元素上,新版的 Vue 还把这一语法糖用在了组件上,接下来来分析它的实现。

组件

为了更加直观,也是通过一个例子分析:

let Child = {
  template:
    "<div>" +
    '<input :value="value" @input="updateValue" placeholder="edit me">' +
    "</div>",
  props: ["value"],
  methods: {
    updateValue(e) {
      this.$emit("input", e.target.value);
    },
  },
};

let vm = new Vue({
  el: "#app",
  template:
    "<div>" +
    '<child v-model="message"></child>' +
    "<p>Message is: {{ message }}</p>" +
    "</div>",
  data() {
    return {
      message: "",
    };
  },
  components: {
    Child,
  },
});

可以看到,父组件引用 child 子组件的地方使用了 v-model 关联了数据 message;而子组件定义了一个 value 的 prop,并且在 input 事件的回调函数中,通过 this.$emit('input', e.target.value) 派发了一个事件,为了让 v-model 生效,这两点是必须的。

接着从源码角度分析实现原理,还是从编译阶段说起,对于父组件而言,在编译阶段会解析 v-model 指令,依然会执行 genData 函数中的 genDirectives 函数,接着执行 src/platforms/web/compiler/directives/model.js 中定义的 model 函数,并命中如下逻辑:

else if (!config.isReservedTag(tag)) {
  genComponentModel(el, value, modifiers);
  return false
}

genComponentModel 函数定义在 src/compiler/directives/model.js 中:

export function genComponentModel(
  el: ASTElement,
  value: string,
  modifiers: ?ASTModifiers
): ?boolean {
  const { number, trim } = modifiers || {};

  const baseValueExpression = "$$v";
  let valueExpression = baseValueExpression;
  if (trim) {
    valueExpression =
      `(typeof ${baseValueExpression} === 'string'` +
      `? ${baseValueExpression}.trim()` +
      `: ${baseValueExpression})`;
  }
  if (number) {
    valueExpression = `_n(${valueExpression})`;
  }
  const assignment = genAssignmentCode(value, valueExpression);

  el.model = {
    value: `(${value})`,
    expression: `"${value}"`,
    callback: `function (${baseValueExpression}) {${assignment}}`,
  };
}

genComponentModel 的逻辑很简单,对的例子而言,生成的 el.model 的值为:

el.model = {
  callback: "function ($$v) {message=$$v}",
  expression: '"message"',
  value: "(message)",
};

那么在 genDirectives 之后,genData 函数中有一段逻辑如下:

if (el.model) {
  data += `model:{value:${el.model.value},callback:${el.model.callback},expression:${el.model.expression}},`;
}

那么父组件最终生成的 render 代码如下:

with (this) {
  return _c(
    "div",
    [
      _c("child", {
        model: {
          value: message,
          callback: function ($$v) {
            message = $$v;
          },
          expression: "message",
        },
      }),
      _c("p", [_v("Message is: " + _s(message))]),
    ],
    1
  );
}

然后在创建子组件 vnode 阶段,会执行 createComponent 函数,它的定义在 src/core/vdom/create-component.js 中:

export function createComponent(
  Ctor: Class<Component> | Function | Object | void,
  data: ?VNodeData,
  context: Component,
  children: ?Array<VNode>,
  tag?: string
): VNode | Array<VNode> | void {
  // ...
  // transform component v-model data into props & events
  if (isDef(data.model)) {
    transformModel(Ctor.options, data);
  }

  // extract props
  const propsData = extractPropsFromVNodeData(data, Ctor, tag);
  // ...
  // extract listeners, since these needs to be treated as
  // child component listeners instead of DOM listeners
  const listeners = data.on;
  // ...
  const vnode = new VNode(
    `vue-component-${Ctor.cid}${name ? `-${name}` : ""}`,
    data,
    undefined,
    undefined,
    undefined,
    context,
    { Ctor, propsData, listeners, tag, children },
    asyncFactory
  );

  return vnode;
}

其中会对 data.model 的情况做处理,执行 transformModel(Ctor.options, data) 方法:

// transform component v-model info (value and callback) into
// prop and event handler respectively.
function transformModel(options, data: any) {
  const prop = (options.model && options.model.prop) || "value";
  const event = (options.model && options.model.event) || "input";
  (data.props || (data.props = {}))[prop] = data.model.value;
  const on = data.on || (data.on = {});
  if (isDef(on[event])) {
    on[event] = [data.model.callback].concat(on[event]);
  } else {
    on[event] = data.model.callback;
  }
}

transformModel 逻辑很简单,给 data.props 添加 data.model.value,并且给 data.on 添加 data.model.callback,对例子而言,扩展结果如下:

data.props = {
  value: message,
};
data.on = {
  input: function ($$v) {
    message = $$v;
  },
};

其实就相当于在这样编写父组件:

let vm = new Vue({
  el: "#app",
  template:
    "<div>" +
    '<child :value="message" @input="message=arguments[0]"></child>' +
    "<p>Message is: {{ message }}</p>" +
    "</div>",
  data() {
    return {
      message: "",
    };
  },
  components: {
    Child,
  },
});

子组件传递的 value 绑定到当前父组件的 message,同时监听自定义 input 事件,当子组件派发 input 事件的时候,父组件会在事件回调函数中修改 message 的值,同时 value 也会发生变化,子组件的 input 值被更新。

这就是典型的 Vue 的父子组件通讯模式,父组件通过 prop 把数据传递到子组件,子组件修改了数据后把改变通过 $emit 事件的方式通知父组件,所以说组件上的 v-model 也是一种语法糖。

另外注意到组件 v-model 的实现,子组件的 value prop 以及派发的 input 事件名是可配的,可以看到 transformModel 中对这部分的处理:

function transformModel(options, data: any) {
  const prop = (options.model && options.model.prop) || "value";
  const event = (options.model && options.model.event) || "input";
  // ...
}

也就是说可以在定义子组件的时候通过 model 选项配置子组件接收的 prop 名以及派发的事件名,举个例子:

let Child = {
  template:
    "<div>" +
    '<input :value="msg" @input="updateValue" placeholder="edit me">' +
    "</div>",
  props: ["msg"],
  model: {
    prop: "msg",
    event: "change",
  },
  methods: {
    updateValue(e) {
      this.$emit("change", e.target.value);
    },
  },
};

let vm = new Vue({
  el: "#app",
  template:
    "<div>" +
    '<child v-model="message"></child>' +
    "<p>Message is: {{ message }}</p>" +
    "</div>",
  data() {
    return {
      message: "",
    };
  },
  components: {
    Child,
  },
});

子组件修改了接收的 prop 名以及派发的事件名,然而这一切父组件作为调用方是不用关心的,这样做的好处是可以把 value 这个 prop 作为其它的用途。

5.3 slot

Vue 的组件提供了一个非常有用的特性 —— slot 插槽,它让组件的实现变的更加灵活。平时在开发组件库的时候,为了让组件更加灵活可定制,经常用插槽的方式让用户可以自定义内容。插槽分为普通插槽和作用域插槽,它们可以解决不同的场景,但它是怎么实现的呢,下面就从源码的角度来分析插槽的实现原理。

普通插槽

为了更加直观,还是通过一个例子来分析插槽的实现:

let AppLayout = {
  template:
    '<div class="container">' +
    '<header><slot name="header"></slot></header>' +
    "<main><slot>默认内容</slot></main>" +
    '<footer><slot name="footer"></slot></footer>' +
    "</div>",
};

let vm = new Vue({
  el: "#app",
  template:
    "<div>" +
    "<app-layout>" +
    '<h1 slot="header">{{title}}</h1>' +
    "<p>{{msg}}</p>" +
    '<p slot="footer">{{desc}}</p>' +
    "</app-layout>" +
    "</div>",
  data() {
    return {
      title: "是标题",
      msg: "是内容",
      desc: "其它信息",
    };
  },
  components: {
    AppLayout,
  },
});

这里定义了 AppLayout 子组件,它内部定义了 3 个插槽,2 个为具名插槽,一个 name 为 header,一个 name 为 footer,还有一个没有定义 name 的是默认插槽。 之前填写的内容为默认内容。父组件注册和引用了 AppLayout 的组件,并在组件内部定义了一些元素,用来替换插槽,那么它最终生成的 DOM 如下:

<div>
  <div class="container">
    <header><h1>是标题</h1></header>
    <main><p>是内容</p></main>
    <footer><p>其它信息</p></footer>
  </div>
</div>

编译

还是先从编译说起,知道编译是发生在调用 vm.$mount 的时候,所以编译的顺序是先编译父组件,再编译子组件。

首先编译父组件,在 parse 阶段,会执行 processSlot 处理 slot,它的定义在 src/compiler/parser/index.js 中:

function processSlot(el) {
  if (el.tag === "slot") {
    el.slotName = getBindingAttr(el, "name");
    if (process.env.NODE_ENV !== "production" && el.key) {
      warn(
        `\`key\` does not work on <slot> because slots are abstract outlets ` +
          `and can possibly expand into multiple elements. ` +
          `Use the key on a wrapping element instead.`
      );
    }
  } else {
    let slotScope;
    if (el.tag === "template") {
      slotScope = getAndRemoveAttr(el, "scope");
      /* istanbul ignore if */
      if (process.env.NODE_ENV !== "production" && slotScope) {
        warn(
          `the "scope" attribute for scoped slots have been deprecated and ` +
            `replaced by "slot-scope" since 2.5. The new "slot-scope" attribute ` +
            `can also be used on plain elements in addition to <template> to ` +
            `denote scoped slots.`,
          true
        );
      }
      el.slotScope = slotScope || getAndRemoveAttr(el, "slot-scope");
    } else if ((slotScope = getAndRemoveAttr(el, "slot-scope"))) {
      /* istanbul ignore if */
      if (process.env.NODE_ENV !== "production" && el.attrsMap["v-for"]) {
        warn(
          `Ambiguous combined usage of slot-scope and v-for on <${el.tag}> ` +
            `(v-for takes higher priority). Use a wrapper <template> for the ` +
            `scoped slot to make it clearer.`,
          true
        );
      }
      el.slotScope = slotScope;
    }
    const slotTarget = getBindingAttr(el, "slot");
    if (slotTarget) {
      el.slotTarget = slotTarget === '""' ? '"default"' : slotTarget;
      // preserve slot as an attribute for native shadow DOM compat
      // only for non-scoped slots.
      if (el.tag !== "template" && !el.slotScope) {
        addAttr(el, "slot", slotTarget);
      }
    }
  }
}

当解析到标签上有 slot 属性的时候,会给对应的 AST 元素节点添加 slotTarget 属性,然后在 codegen 阶段,在 genData 中会处理 slotTarget,相关代码在 src/compiler/codegen/index.js 中:

if (el.slotTarget && !el.slotScope) {
  data += `slot:${el.slotTarget},`;
}

会给 data 添加一个 slot 属性,并指向 slotTarget,之后会用到。在的例子中,父组件最终生成的代码如下:

with (this) {
  return _c(
    "div",
    [
      _c("app-layout", [
        _c("h1", { attrs: { slot: "header" }, slot: "header" }, [
          _v(_s(title)),
        ]),
        _c("p", [_v(_s(msg))]),
        _c("p", { attrs: { slot: "footer" }, slot: "footer" }, [_v(_s(desc))]),
      ]),
    ],
    1
  );
}

接下来编译子组件,同样在 parser 阶段会执行 processSlot 处理函数,它的定义在 src/compiler/parser/index.js 中:

function processSlot(el) {
  if (el.tag === "slot") {
    el.slotName = getBindingAttr(el, "name");
  }
  // ...
}

当遇到 slot 标签的时候会给对应的 AST 元素节点添加 slotName 属性,然后在 codegen 阶段,会判断如果当前 AST 元素节点是 slot 标签,则执行 genSlot 函数,它的定义在 src/compiler/codegen/index.js 中:

function genSlot(el: ASTElement, state: CodegenState): string {
  const slotName = el.slotName || '"default"';
  const children = genChildren(el, state);
  let res = `_t(${slotName}${children ? `,${children}` : ""}`;
  const attrs =
    el.attrs &&
    `{${el.attrs.map((a) => `${camelize(a.name)}:${a.value}`).join(",")}}`;
  const bind = el.attrsMap["v-bind"];
  if ((attrs || bind) && !children) {
    res += `,null`;
  }
  if (attrs) {
    res += `,${attrs}`;
  }
  if (bind) {
    res += `${attrs ? "" : ",null"},${bind}`;
  }
  return res + ")";
}

先不考虑 slot 标签上有 attrs 以及 v-bind 的情况,那么它生成的代码实际上就只有:

const slotName = el.slotName || '"default"';
const children = genChildren(el, state);
let res = `_t(${slotName}${children ? `,${children}` : ""}`;

这里的 slotName 从 AST 元素节点对应的属性上取,默认是 default,而 children 对应的就是 slot 开始和闭合标签包裹的内容。来看一下例子的子组件最终生成的代码,如下:

with (this) {
  return _c(
    "div",
    {
      staticClass: "container",
    },
    [
      _c("header", [_t("header")], 2),
      _c("main", [_t("default", [_v("默认内容")])], 2),
      _c("footer", [_t("footer")], 2),
    ]
  );
}

在编译章节了解到,_t 函数对应的就是 renderSlot 方法,它的定义在 src/core/instance/render-heplpers/render-slot.js 中:

/**
 * Runtime helper for rendering <slot>
 */
export function renderSlot(
  name: string,
  fallback: ?Array<VNode>,
  props: ?Object,
  bindObject: ?Object
): ?Array<VNode> {
  const scopedSlotFn = this.$scopedSlots[name];
  let nodes;
  if (scopedSlotFn) {
    // scoped slot
    props = props || {};
    if (bindObject) {
      if (process.env.NODE_ENV !== "production" && !isObject(bindObject)) {
        warn("slot v-bind without argument expects an Object", this);
      }
      props = extend(extend({}, bindObject), props);
    }
    nodes = scopedSlotFn(props) || fallback;
  } else {
    const slotNodes = this.$slots[name];
    // warn duplicate slot usage
    if (slotNodes) {
      if (process.env.NODE_ENV !== "production" && slotNodes._rendered) {
        warn(
          `Duplicate presence of slot "${name}" found in the same render tree ` +
            `- this will likely cause render errors.`,
          this
        );
      }
      slotNodes._rendered = true;
    }
    nodes = slotNodes || fallback;
  }

  const target = props && props.slot;
  if (target) {
    return this.$createElement("template", { slot: target }, nodes);
  } else {
    return nodes;
  }
}

render-slot 的参数 name 代表插槽名称 slotName,fallback 代表插槽的默认内容生成的 vnode 数组。先忽略 scoped-slot,只看默认插槽逻辑。如果 this.$slot[name] 有值,就返回它对应的 vnode 数组,否则返回 fallback。那么这个 this.$slot 是哪里来的呢?知道子组件的 init 时机是在父组件执行 patch 过程的时候,那这个时候父组件已经编译完成了。并且子组件在 init 过程中会执行 initRender 函数,initRender 的时候获取到 vm.$slot,相关代码在 src/core/instance/render.js 中:

export function initRender(vm: Component) {
  // ...
  const parentVnode = (vm.$vnode = options._parentVnode); // the placeholder node in parent tree
  const renderContext = parentVnode && parentVnode.context;
  vm.$slots = resolveSlots(options._renderChildren, renderContext);
}

vm.$slots 是通过执行 resolveSlots(options._renderChildren, renderContext) 返回的,它的定义在 src/core/instance/render-helpers/resolve-slots.js 中:

/**
 * Runtime helper for resolving raw children VNodes into a slot object.
 */
export function resolveSlots(
  children: ?Array<VNode>,
  context: ?Component
): { [key: string]: Array<VNode> } {
  const slots = {};
  if (!children) {
    return slots;
  }
  for (let i = 0, l = children.length; i < l; i++) {
    const child = children[i];
    const data = child.data;
    // remove slot attribute if the node is resolved as a Vue slot node
    if (data && data.attrs && data.attrs.slot) {
      delete data.attrs.slot;
    }
    // named slots should only be respected if the vnode was rendered in the
    // same context.
    if (
      (child.context === context || child.fnContext === context) &&
      data &&
      data.slot != null
    ) {
      const name = data.slot;
      const slot = slots[name] || (slots[name] = []);
      if (child.tag === "template") {
        slot.push.apply(slot, child.children || []);
      } else {
        slot.push(child);
      }
    } else {
      (slots.default || (slots.default = [])).push(child);
    }
  }
  // ignore slots that contains only whitespace
  for (const name in slots) {
    if (slots[name].every(isWhitespace)) {
      delete slots[name];
    }
  }
  return slots;
}

resolveSlots 方法接收 2 个参数,第一个参数 chilren 对应的是父 vnode 的 children,在例子中就是 包裹的内容。第二个参数 context 是父 vnode 的上下文,也就是父组件的 vm 实例。

resolveSlots 函数的逻辑就是遍历 chilren,拿到每一个 child 的 data,然后通过 data.slot 获取到插槽名称,这个 slot 就是之前编译父组件在 codegen 阶段设置的 data.slot。接着以插槽名称为 key 把 child 添加到 slots 中,如果 data.slot 不存在,则是默认插槽的内容,则把对应的 child 添加到 slots.defaults 中。这样就获取到整个 slots,它是一个对象,key 是插槽名称,value 是一个 vnode 类型的数组,因为它可以有多个同名插槽。

这样就拿到了 vm.$slots 了,回到 renderSlot 函数,const slotNodes = this.$slots[name],也就能根据插槽名称获取到对应的 vnode 数组了,这个数组里的 vnode 都是在父组件创建的,这样就实现了在父组件替换子组件插槽的内容了。

对应的 slot 渲染成 vnodes,作为当前组件渲染 vnode 的 children,之后的渲染过程之前分析过,不再赘述。

知道在普通插槽中,父组件应用到子组件插槽里的数据都是绑定到父组件的,因为它渲染成 vnode 的时机的上下文是父组件的实例。但是在一些实际开发中,想通过子组件的一些数据来决定父组件实现插槽的逻辑,Vue 提供了另一种插槽——作用域插槽,接下来就来分析一下它的实现原理。

作用域插槽

为了更加直观,也是通过一个例子来分析作用域插槽的实现:

let Child = {
  template:
    '<div class="child">' + '<slot text="Hello " :msg="msg"></slot>' + "</div>",
  data() {
    return {
      msg: "Vue",
    };
  },
};

let vm = new Vue({
  el: "#app",
  template:
    "<div>" +
    "<child>" +
    '<template slot-scope="props">' +
    "<p>Hello from parent</p>" +
    "<p>{{ props.text + props.msg}}</p>" +
    "</template>" +
    "</child>" +
    "</div>",
  components: {
    Child,
  },
});

最终生成的 DOM 结构如下:

<div>
  <div class="child">
    <p>Hello from parent</p>
    <p>Hello Vue</p>
  </div>
</div>

可以看到子组件的 slot 标签多了 text 属性,以及 :msg 属性。父组件实现插槽的部分多了一个 template 标签,以及 scope-slot 属性,其实在 Vue 2.5+ 版本,scoped-slot 可以作用在普通元素上。这些就是作用域插槽和普通插槽在写法上的差别。

在编译阶段,仍然是先编译父组件,同样是通过 processSlot 函数去处理 scoped-slot,它的定义在在 src/compiler/parser/index.js 中:

function processSlot(el) {
  // ...
  let slotScope;
  if (el.tag === "template") {
    slotScope = getAndRemoveAttr(el, "scope");
    /* istanbul ignore if */
    if (process.env.NODE_ENV !== "production" && slotScope) {
      warn(
        `the "scope" attribute for scoped slots have been deprecated and ` +
          `replaced by "slot-scope" since 2.5. The new "slot-scope" attribute ` +
          `can also be used on plain elements in addition to <template> to ` +
          `denote scoped slots.`,
        true
      );
    }
    el.slotScope = slotScope || getAndRemoveAttr(el, "slot-scope");
  } else if ((slotScope = getAndRemoveAttr(el, "slot-scope"))) {
    /* istanbul ignore if */
    if (process.env.NODE_ENV !== "production" && el.attrsMap["v-for"]) {
      warn(
        `Ambiguous combined usage of slot-scope and v-for on <${el.tag}> ` +
          `(v-for takes higher priority). Use a wrapper <template> for the ` +
          `scoped slot to make it clearer.`,
        true
      );
    }
    el.slotScope = slotScope;
  }
  // ...
}

这块逻辑很简单,读取 scoped-slot 属性并赋值给当前 AST 元素节点的 slotScope 属性,接下来在构造 AST 树的时候,会执行以下逻辑:

if (element.elseif || element.else) {
  processIfConditions(element, currentParent);
} else if (element.slotScope) {
  currentParent.plain = false;
  const name = element.slotTarget || '"default"';
  (currentParent.scopedSlots || (currentParent.scopedSlots = {}))[name] =
    element;
} else {
  currentParent.children.push(element);
  element.parent = currentParent;
}

可以看到对于拥有 scopedSlot 属性的 AST 元素节点而言,是不会作为 children 添加到当前 AST 树中,而是存到父 AST 元素节点的 scopedSlots 属性上,它是一个对象,以插槽名称 name 为 key。

然后在 genData 的过程,会对 scopedSlots 做处理:

if (el.scopedSlots) {
  data += `${genScopedSlots(el.scopedSlots, state)},`;
}

function genScopedSlots(
  slots: { [key: string]: ASTElement },
  state: CodegenState
): string {
  return `scopedSlots:_u([${Object.keys(slots)
    .map((key) => {
      return genScopedSlot(key, slots[key], state);
    })
    .join(",")}])`;
}

function genScopedSlot(
  key: string,
  el: ASTElement,
  state: CodegenState
): string {
  if (el.for && !el.forProcessed) {
    return genForScopedSlot(key, el, state);
  }
  const fn =
    `function(${String(el.slotScope)}){` +
    `return ${
      el.tag === "template"
        ? el.if
          ? `${el.if}?${genChildren(el, state) || "undefined"}:undefined`
          : genChildren(el, state) || "undefined"
        : genElement(el, state)
    }}`;
  return `{key:${key},fn:${fn}}`;
}

genScopedSlots 就是对 scopedSlots 对象遍历,执行 genScopedSlot,并把结果用逗号拼接,而 genScopedSlot 是先生成一段函数代码,并且函数的参数就是 slotScope,也就是写在标签属性上的 scoped-slot 对应的值,然后再返回一个对象,key 为插槽名称,fn 为生成的函数代码。

对于这个例子而言,父组件最终生成的代码如下:

with (this) {
  return _c(
    "div",
    [
      _c("child", {
        scopedSlots: _u([
          {
            key: "default",
            fn: function (props) {
              return [
                _c("p", [_v("Hello from parent")]),
                _c("p", [_v(_s(props.text + props.msg))]),
              ];
            },
          },
        ]),
      }),
    ],
    1
  );
}

可以看到它和普通插槽父组件编译结果的一个很明显的区别就是没有 children 了,data 部分多了一个对象,并且执行了 _u 方法,在编译章节了解到,_u 函数对的就是 resolveScopedSlots 方法,它的定义在 src/core/instance/render-heplpers/resolve-slots.js 中:

export function resolveScopedSlots(
  fns: ScopedSlotsData, // see flow/vnode
  res?: Object
): { [key: string]: Function } {
  res = res || {};
  for (let i = 0; i < fns.length; i++) {
    if (Array.isArray(fns[i])) {
      resolveScopedSlots(fns[i], res);
    } else {
      res[fns[i].key] = fns[i].fn;
    }
  }
  return res;
}

其中,fns 是一个数组,每一个数组元素都有一个 key 和一个 fn,key 对应的是插槽的名称,fn 对应一个函数。整个逻辑就是遍历这个 fns 数组,生成一个对象,对象的 key 就是插槽名称,value 就是函数。这个函数的执行时机稍后会介绍。

接着再来看一下子组件的编译,和普通插槽的过程基本相同,唯一一点区别是在 genSlot 的时候:

function genSlot(el: ASTElement, state: CodegenState): string {
  const slotName = el.slotName || '"default"';
  const children = genChildren(el, state);
  let res = `_t(${slotName}${children ? `,${children}` : ""}`;
  const attrs =
    el.attrs &&
    `{${el.attrs.map((a) => `${camelize(a.name)}:${a.value}`).join(",")}}`;
  const bind = el.attrsMap["v-bind"];
  if ((attrs || bind) && !children) {
    res += `,null`;
  }
  if (attrs) {
    res += `,${attrs}`;
  }
  if (bind) {
    res += `${attrs ? "" : ",null"},${bind}`;
  }
  return res + ")";
}

它会对 attrs 和 v-bind 做处理,对应到例子,最终生成的代码如下:

with (this) {
  return _c(
    "div",
    { staticClass: "child" },
    [_t("default", null, { text: "Hello ", msg: msg })],
    2
  );
}

_t 方法之前介绍过,对应的是 renderSlot 方法:

export function renderSlot(
  name: string,
  fallback: ?Array<VNode>,
  props: ?Object,
  bindObject: ?Object
): ?Array<VNode> {
  const scopedSlotFn = this.$scopedSlots[name];
  let nodes;
  if (scopedSlotFn) {
    props = props || {};
    if (bindObject) {
      if (process.env.NODE_ENV !== "production" && !isObject(bindObject)) {
        warn("slot v-bind without argument expects an Object", this);
      }
      props = extend(extend({}, bindObject), props);
    }
    nodes = scopedSlotFn(props) || fallback;
  } else {
    // ...
  }

  const target = props && props.slot;
  if (target) {
    return this.$createElement("template", { slot: target }, nodes);
  } else {
    return nodes;
  }
}

只关注作用域插槽的逻辑,那么这个 this.$scopedSlots 又是在什么地方定义的呢,原来在子组件的渲染函数执行前,在 vm_render 方法内,有这么一段逻辑,定义在 src/core/instance/render.js 中:

if (_parentVnode) {
vm.$scopedSlots = _parentVnode.data.scopedSlots || emptyObject
}
这个 _parentVNode.data.scopedSlots 对应的就是在父组件通过执行 resolveScopedSlots 返回的对象。所以回到 genSlot 函数,就可以通过插槽的名称拿到对应的 scopedSlotFn,然后把相关的数据扩展到 props 上,作为函数的参数传入,原来之前提到的函数这个时候执行,然后返回生成的 vnodes,为后续渲染节点用。

后续流程之前已介绍过,不再赘述,那么至此,作用域插槽的实现也就分析完毕。

5.4 keep-alive

在平时开发工作中,经常为了组件的缓存优化而使用 组件,乐此不疲,但很少有人关注它的实现原理,下面就让来一探究竟。

内置组件

是 Vue 源码中实现的一个组件,也就是说 Vue 源码不仅实现了一套组件化的机制,也实现了一些内置组件,它的定义在 src/core/components/keep-alive.js 中:

export default {
  name: "keep-alive",
  abstract: true,

  props: {
    include: patternTypes,
    exclude: patternTypes,
    max: [String, Number],
  },

  created() {
    this.cache = Object.create(null);
    this.keys = [];
  },

  destroyed() {
    for (const key in this.cache) {
      pruneCacheEntry(this.cache, key, this.keys);
    }
  },

  mounted() {
    this.$watch("include", (val) => {
      pruneCache(this, (name) => matches(val, name));
    });
    this.$watch("exclude", (val) => {
      pruneCache(this, (name) => !matches(val, name));
    });
  },

  render() {
    const slot = this.$slots.default;
    const vnode: VNode = getFirstComponentChild(slot);
    const componentOptions: ?VNodeComponentOptions =
      vnode && vnode.componentOptions;
    if (componentOptions) {
      // check pattern
      const name: ?string = getComponentName(componentOptions);
      const { include, exclude } = this;
      if (
        // not included
        (include && (!name || !matches(include, name))) ||
        // excluded
        (exclude && name && matches(exclude, name))
      ) {
        return vnode;
      }

      const { cache, keys } = this;
      const key: ?string =
        vnode.key == null
          ? // same constructor may get registered as different local components
            // so cid alone is not enough (#3269)
            componentOptions.Ctor.cid +
            (componentOptions.tag ? `::${componentOptions.tag}` : "")
          : vnode.key;
      if (cache[key]) {
        vnode.componentInstance = cache[key].componentInstance;
        // make current key freshest
        remove(keys, key);
        keys.push(key);
      } else {
        cache[key] = vnode;
        keys.push(key);
        // prune oldest entry
        if (this.max && keys.length > parseInt(this.max)) {
          pruneCacheEntry(cache, keys[0], keys, this._vnode);
        }
      }

      vnode.data.keepAlive = true;
    }
    return vnode || (slot && slot[0]);
  },
};

可以看到 组件的实现也是一个对象,注意它有一个属性 abstract 为 true,是一个抽象组件,Vue 的文档没有提这个概念,实际上它在组件实例建立父子关系的时候会被忽略,发生在 initLifecycle 的过程中:

// locate first non-abstract parent
let parent = options.parent;
if (parent && !options.abstract) {
  while (parent.$options.abstract && parent.$parent) {
    parent = parent.$parent;
  }
  parent.$children.push(vm);
}
vm.$parent = parent;

在 created 钩子里定义了 this.cache 和 this.keys,本质上它就是去缓存已经创建过的 vnode。它的 props 定义了 include,exclude,它们可以字符串或者表达式,include 表示只有匹配的组件会被缓存,而 exclude 表示任何匹配的组件都不会被缓存,props 还定义了 max,它表示缓存的大小,因为是缓存的 vnode 对象,它也会持有 DOM,当缓存很多的时候,会比较占用内存,所以该配置允许指定缓存大小。

直接实现了 render 函数,而不是常规模板的方式,执行 组件渲染的时候,就会执行到这个 render 函数,接下来分析一下它的实现。

首先获取第一个子元素的 vnode:

const slot = this.$slots.default;
const vnode: VNode = getFirstComponentChild(slot);

由于也是在 标签内部写 DOM,所以可以先获取到它的默认插槽,然后再获取到它的第一个子节点。 只处理第一个子元素,所以一般和它搭配使用的有 component 动态组件或者是 router-view,这点要牢记。

然后又判断了当前组件的名称和 include、exclude 的关系:

// check pattern
const name: ?string = getComponentName(componentOptions);
const { include, exclude } = this;
if (
  // not included
  (include && (!name || !matches(include, name))) ||
  // excluded
  (exclude && name && matches(exclude, name))
) {
  return vnode;
}

function matches(
  pattern: string | RegExp | Array<string>,
  name: string
): boolean {
  if (Array.isArray(pattern)) {
    return pattern.indexOf(name) > -1;
  } else if (typeof pattern === "string") {
    return pattern.split(",").indexOf(name) > -1;
  } else if (isRegExp(pattern)) {
    return pattern.test(name);
  }
  return false;
}

matches 的逻辑很简单,就是做匹配,分别处理了数组、字符串、正则表达式的情况,也就是说平时传的 include 和 exclude 可以是这三种类型的任意一种。并且组件名如果满足了配置 include 且不匹配或者是配置了 exclude 且匹配,那么就直接返回这个组件的 vnode,否则的话走下一步缓存:

const { cache, keys } = this;
const key: ?string =
  vnode.key == null
    ? // same constructor may get registered as different local components
      // so cid alone is not enough (#3269)
      componentOptions.Ctor.cid +
      (componentOptions.tag ? `::${componentOptions.tag}` : "")
    : vnode.key;
if (cache[key]) {
  vnode.componentInstance = cache[key].componentInstance;
  // make current key freshest
  remove(keys, key);
  keys.push(key);
} else {
  cache[key] = vnode;
  keys.push(key);
  // prune oldest entry
  if (this.max && keys.length > parseInt(this.max)) {
    pruneCacheEntry(cache, keys[0], keys, this._vnode);
  }
}

这部分逻辑很简单,如果命中缓存,则直接从缓存中拿 vnode 的组件实例,并且重新调整了 key 的顺序放在了最后一个;否则把 vnode 设置进缓存,最后还有一个逻辑,如果配置了 max 并且缓存的长度超过了 this.max,还要从缓存中删除第一个:

function pruneCacheEntry(
  cache: VNodeCache,
  key: string,
  keys: Array<string>,
  current?: VNode
) {
  const cached = cache[key];
  if (cached && (!current || cached.tag !== current.tag)) {
    cached.componentInstance.$destroy();
  }
  cache[key] = null;
  remove(keys, key);
}

除了从缓存中删除外,还要判断如果要删除的缓存并的组件 tag 不是当前渲染组件 tag,也执行删除缓存的组件实例的 $destroy 方法。

最后设置 vnode.data.keepAlive = true ,这个作用稍后介绍。

注意, 组件也是为观测 include 和 exclude 的变化,对缓存做处理:

watch: {
  include (val: string | RegExp | Array<string>) {
    pruneCache(this, name => matches(val, name))
  },
  exclude (val: string | RegExp | Array<string>) {
    pruneCache(this, name => !matches(val, name))
  }
}

function pruneCache (keepAliveInstance: any, filter: Function) {
  const { cache, keys, _vnode } = keepAliveInstance
  for (const key in cache) {
    const cachedNode: ?VNode = cache[key]
    if (cachedNode) {
      const name: ?string = getComponentName(cachedNode.componentOptions)
      if (name && !filter(name)) {
        pruneCacheEntry(cache, key, keys, _vnode)
      }
    }
  }
}

逻辑很简单,观测他们的变化执行 pruneCache 函数,其实就是对 cache 做遍历,发现缓存的节点名称和新的规则没有匹配上的时候,就把这个缓存节点从缓存中摘除。

组件渲染

到此为止,只了解了 的组件实现,但并不知道它包裹的子组件渲染和普通组件有什么不一样的地方。关注 2 个方面,首次渲染和缓存渲染。

同样为了更好地理解,也结合一个示例来分析:

let A = {
  template: '<div class="a">' + "<p>A Comp</p>" + "</div>",
  name: "A",
};

let B = {
  template: '<div class="b">' + "<p>B Comp</p>" + "</div>",
  name: "B",
};

let vm = new Vue({
  el: "#app",
  template:
    "<div>" +
    "<keep-alive>" +
    '<component :is="currentComp">' +
    "</component>" +
    "</keep-alive>" +
    '<button @click="change">switch</button>' +
    "</div>",
  data: {
    currentComp: "A",
  },
  methods: {
    change() {
      this.currentComp = this.currentComp === "A" ? "B" : "A";
    },
  },
  components: {
    A,
    B,
  },
});

首次渲染

Vue 的渲染最后都会到 patch 过程,而组件的 patch 过程会执行 createComponent 方法,它的定义在 src/core/vdom/patch.js 中:

function createComponent(vnode, insertedVnodeQueue, parentElm, refElm) {
  let i = vnode.data;
  if (isDef(i)) {
    const isReactivated = isDef(vnode.componentInstance) && i.keepAlive;
    if (isDef((i = i.hook)) && isDef((i = i.init))) {
      i(vnode, false /* hydrating */);
    }
    // after calling the init hook, if the vnode is a child component
    // it should've created a child instance and mounted it. the child
    // component also has set the placeholder vnode's elm.
    // in that case we can just return the element and be done.
    if (isDef(vnode.componentInstance)) {
      initComponent(vnode, insertedVnodeQueue);
      insert(parentElm, vnode.elm, refElm);
      if (isTrue(isReactivated)) {
        reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm);
      }
      return true;
    }
  }
}

createComponent 定义了 isReactivated 的变量,它是根据 vnode.componentInstance 以及 vnode.data.keepAlive 的判断,第一次渲染的时候,vnode.componentInstance 为 undefined,vnode.data.keepAlive 为 true,因为它的父组件 的 render 函数会先执行,那么该 vnode 缓存到内存中,并且设置 vnode.data.keepAlive 为 true,因此 isReactivated 为 false,那么走正常的 init 的钩子函数执行组件的 mount。当 vnode 已经执行完 patch 后,执行 initComponent 函数:

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

这里会有 vnode.elm 缓存了 vnode 创建生成的 DOM 节点。所以对于首次渲染而言,除了在 中建立缓存,和普通组件渲染没什么区别。

所以对例子,初始化渲染 A 组件以及第一次点击 switch 渲染 B 组件,都是首次渲染。

缓存渲染

当从 B 组件再次点击 switch 切换到 A 组件,就会命中缓存渲染。

之前分析过,当数据发送变化,在 patch 的过程中会执行 patchVnode 的逻辑,它会对比新旧 vnode 节点,甚至对比它们的子节点去做更新逻辑,但是对于组件 vnode 而言,是没有 children 的,那么对于 组件而言,如何更新它包裹的内容呢?

原来 patchVnode 在做各种 diff 之前,会先执行 prepatch 的钩子函数,它的定义在 src/core/vdom/create-component 中:

const componentVNodeHooks = {
  prepatch(oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
    const options = vnode.componentOptions;
    const child = (vnode.componentInstance = oldVnode.componentInstance);
    updateChildComponent(
      child,
      options.propsData, // updated props
      options.listeners, // updated listeners
      vnode, // new parent vnode
      options.children // new children
    );
  },
  // ...
};

prepatch 核心逻辑就是执行 updateChildComponent 方法,它的定义在 src/core/instance/lifecycle.js 中:

export function updateChildComponent(
  vm: Component,
  propsData: ?Object,
  listeners: ?Object,
  parentVnode: MountedComponentVNode,
  renderChildren: ?Array<VNode>
) {
  const hasChildren = !!(
    renderChildren ||
    vm.$options._renderChildren ||
    parentVnode.data.scopedSlots ||
    vm.$scopedSlots !== emptyObject
  );

  // ...
  if (hasChildren) {
    vm.$slots = resolveSlots(renderChildren, parentVnode.context);
    vm.$forceUpdate();
  }
}

updateChildComponent 方法主要是去更新组件实例的一些属性,这里重点关注一下 slot 部分,由于 组件本质上支持了 slot,所以它执行 prepatch 的时候,需要对自己的 children,也就是这些 slots 做重新解析,并触发 组件实例 $forceUpdate 逻辑,也就是重新执行 的 render 方法,这个时候如果它包裹的第一个组件 vnode 命中缓存,则直接返回缓存中的 vnode.componentInstance,在例子中就是缓存的 A 组件,接着又会执行 patch 过程,再次执行到 createComponent 方法,再回顾一下:

function createComponent(vnode, insertedVnodeQueue, parentElm, refElm) {
  let i = vnode.data;
  if (isDef(i)) {
    const isReactivated = isDef(vnode.componentInstance) && i.keepAlive;
    if (isDef((i = i.hook)) && isDef((i = i.init))) {
      i(vnode, false /* hydrating */);
    }
    // after calling the init hook, if the vnode is a child component
    // it should've created a child instance and mounted it. the child
    // component also has set the placeholder vnode's elm.
    // in that case we can just return the element and be done.
    if (isDef(vnode.componentInstance)) {
      initComponent(vnode, insertedVnodeQueue);
      insert(parentElm, vnode.elm, refElm);
      if (isTrue(isReactivated)) {
        reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm);
      }
      return true;
    }
  }
}

这个时候 isReactivated 为 true,并且在执行 init 钩子函数的时候不会再执行组件的 mount 过程了,相关逻辑在 src/core/vdom/create-component.js 中:

const componentVNodeHooks = {
  init(vnode: VNodeWithData, hydrating: boolean): ?boolean {
    if (
      vnode.componentInstance &&
      !vnode.componentInstance._isDestroyed &&
      vnode.data.keepAlive
    ) {
      // kept-alive components, treat as a patch
      const mountedNode: any = vnode; // work around flow
      componentVNodeHooks.prepatch(mountedNode, mountedNode);
    } else {
      const child = (vnode.componentInstance = createComponentInstanceForVnode(
        vnode,
        activeInstance
      ));
      child.$mount(hydrating ? vnode.elm : undefined, hydrating);
    }
  },
  // ...
};

这也就是被 包裹的组件在有缓存的时候就不会在执行组件的 created、mounted 等钩子函数的原因了。回到 createComponent 方法,在 isReactivated 为 true 的情况下会执行 reactivateComponent 方法:

function reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm) {
  let i;
  // hack for #4339: a reactivated component with inner transition
  // does not trigger because the inner node's created hooks are not called
  // again. It's not ideal to involve module-specific logic in here but
  // there doesn't seem to be a better way to do it.
  let innerNode = vnode;
  while (innerNode.componentInstance) {
    innerNode = innerNode.componentInstance._vnode;
    if (isDef((i = innerNode.data)) && isDef((i = i.transition))) {
      for (i = 0; i < cbs.activate.length; ++i) {
        cbs.activate[i](emptyNode, innerNode);
      }
      insertedVnodeQueue.push(innerNode);
      break;
    }
  }
  // unlike a newly created component,
  // a reactivated keep-alive component doesn't insert itself
  insert(parentElm, vnode.elm, refElm);
}

前面部分的逻辑是解决对 reactived 组件 transition 动画不触发的问题,可以先不关注,最后通过执行 insert(parentElm, vnode.elm, refElm) 就把缓存的 DOM 对象直接插入到目标元素中,这样就完成了在数据更新的情况下的渲染过程。

生命周期

之前提到,组件一旦被 缓存,那么再次渲染的时候就不会执行 created、mounted 等钩子函数,但是很多业务场景都是希望在被缓存的组件再次被渲染的时候做一些事情,好在 Vue 提供了 activated 钩子函数,它的执行时机是 包裹的组件渲染的时候,接下来从源码角度来分析一下它的实现原理。

在渲染的最后一步,会执行 invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch) 函数执行 vnode 的 insert 钩子函数,它的定义在 src/core/vdom/create-component.js 中:

const componentVNodeHooks = {
  insert(vnode: MountedComponentVNode) {
    const { context, componentInstance } = vnode;
    if (!componentInstance._isMounted) {
      componentInstance._isMounted = true;
      callHook(componentInstance, "mounted");
    }
    if (vnode.data.keepAlive) {
      if (context._isMounted) {
        // vue-router#1212
        // During updates, a kept-alive component's child components may
        // change, so directly walking the tree here may call activated hooks
        // on incorrect children. Instead we push them into a queue which will
        // be processed after the whole patch process ended.
        queueActivatedComponent(componentInstance);
      } else {
        activateChildComponent(componentInstance, true /* direct */);
      }
    }
  },
  // ...
};

这里判断如果是被 包裹的组件已经 mounted,那么则执行 queueActivatedComponent(componentInstance) ,否则执行 activateChildComponent(componentInstance, true)。先分析非 mounted 的情况,activateChildComponent 的定义在 src/core/instance/lifecycle.js 中:

export function activateChildComponent(vm: Component, direct?: boolean) {
  if (direct) {
    vm._directInactive = false;
    if (isInInactiveTree(vm)) {
      return;
    }
  } else if (vm._directInactive) {
    return;
  }
  if (vm._inactive || vm._inactive === null) {
    vm._inactive = false;
    for (let i = 0; i < vm.$children.length; i++) {
      activateChildComponent(vm.$children[i]);
    }
    callHook(vm, "activated");
  }
}

可以看到这里就是执行组件的 acitvated 钩子函数,并且递归去执行它的所有子组件的 activated 钩子函数。

那么再看 queueActivatedComponent 的逻辑,它定义在 src/core/observer/scheduler.js 中:

export function queueActivatedComponent(vm: Component) {
  vm._inactive = false;
  activatedChildren.push(vm);
}

这个逻辑很简单,把当前 vm 实例添加到 activatedChildren 数组中,等所有的渲染完毕,在 nextTick 后会执行 flushSchedulerQueue,这个时候就会执行:

function flushSchedulerQueue() {
  // ...
  const activatedQueue = activatedChildren.slice();
  callActivatedHooks(activatedQueue);
  // ...
}

function callActivatedHooks(queue) {
  for (let i = 0; i < queue.length; i++) {
    queue[i]._inactive = true;
    activateChildComponent(queue[i], true);
  }
}

也就是遍历所有的 activatedChildren,执行 activateChildComponent 方法,通过队列调的方式就是把整个 activated 时机延后了。

有 activated 钩子函数,也就有对应的 deactivated 钩子函数,它是发生在 vnode 的 destory 钩子函数,定义在 src/core/vdom/create-component.js 中:

const componentVNodeHooks = {
  destroy(vnode: MountedComponentVNode) {
    const { componentInstance } = vnode;
    if (!componentInstance._isDestroyed) {
      if (!vnode.data.keepAlive) {
        componentInstance.$destroy();
      } else {
        deactivateChildComponent(componentInstance, true /* direct */);
      }
    }
  },
};

对于 包裹的组件而言,它会执行 deactivateChildComponent(componentInstance, true) 方法,定义在 src/core/instance/lifecycle.js 中:

export function deactivateChildComponent(vm: Component, direct?: boolean) {
  if (direct) {
    vm._directInactive = true;
    if (isInInactiveTree(vm)) {
      return;
    }
  }
  if (!vm._inactive) {
    vm._inactive = true;
    for (let i = 0; i < vm.$children.length; i++) {
      deactivateChildComponent(vm.$children[i]);
    }
    callHook(vm, "deactivated");
  }
}

和 activateChildComponent 方法类似,就是执行组件的 deacitvated 钩子函数,并且递归去执行它的所有子组件的 deactivated 钩子函数。

5.5 transition

在平时的前端项目开发中,经常会遇到如下需求,一个 DOM 节点的插入和删除或者是显示和隐藏,不想让它特别生硬,通常会考虑加一些过渡效果。

Vue.js 除了实现了强大的数据驱动,组件化的能力,也给提供了一整套过渡的解决方案。它内置了 组件,可以利用它配合一些 CSS3 样式很方便地实现过渡动画,也可以利用它配合 JavaScript 的钩子函数实现过渡动画,在下列情形中,可以给任何元素和组件添加 entering/leaving 过渡:

条件渲染 (使用 v-if)
条件展示 (使用 v-show)
动态组件
组件根节点
那么举一个最简单的实例,如下:

let vm = new Vue({
  el: '#app',
  template: '<div id="demo">' +
  '<button v-on:click="show = !show">' +
  'Toggle' +
  '</button>' +
  '<transition :appear="true" name="fade">' +
  '<p v-if="show">hello</p>' +
  '</transition>' +
  '</div>',
  data() {
    return {
      show: true
    }
  }
})
.fade-enter-active, .fade-leave-active {
  transition: opacity .5s;
}
.fade-enter, .fade-leave-to {
  opacity: 0;
}

当点击按钮切换显示状态的时候,被 包裹的内容会有过渡动画。那么接下来从源码的角度来分析它的实现原理。

内置组件

组件和 组件一样,都是 Vue 的内置组件,而 的定义在 src/platforms/web/runtime/component/transtion.js 中,之所以在这里定义,是因为 组件是 web 平台独有的,先来看一下它的实现:

export default {
  name: "transition",
  props: transitionProps,
  abstract: true,

  render(h: Function) {
    let children: any = this.$slots.default;
    if (!children) {
      return;
    }

    // filter out text nodes (possible whitespaces)
    children = children.filter((c: VNode) => c.tag || isAsyncPlaceholder(c));
    /* istanbul ignore if */
    if (!children.length) {
      return;
    }

    // warn multiple elements
    if (process.env.NODE_ENV !== "production" && children.length > 1) {
      warn(
        "<transition> can only be used on a single element. Use " +
          "<transition-group> for lists.",
        this.$parent
      );
    }

    const mode: string = this.mode;

    // warn invalid mode
    if (
      process.env.NODE_ENV !== "production" &&
      mode &&
      mode !== "in-out" &&
      mode !== "out-in"
    ) {
      warn("invalid <transition> mode: " + mode, this.$parent);
    }

    const rawChild: VNode = children[0];

    // if this is a component root node and the component's
    // parent container node also has transition, skip.
    if (hasParentTransition(this.$vnode)) {
      return rawChild;
    }

    // apply transition data to child
    // use getRealChild() to ignore abstract components e.g. keep-alive
    const child: ?VNode = getRealChild(rawChild);
    /* istanbul ignore if */
    if (!child) {
      return rawChild;
    }

    if (this._leaving) {
      return placeholder(h, rawChild);
    }

    // ensure a key that is unique to the vnode type and to this transition
    // component instance. This key will be used to remove pending leaving nodes
    // during entering.
    const id: string = `__transition-${this._uid}-`;
    child.key =
      child.key == null
        ? child.isComment
          ? id + "comment"
          : id + child.tag
        : isPrimitive(child.key)
        ? String(child.key).indexOf(id) === 0
          ? child.key
          : id + child.key
        : child.key;

    const data: Object = ((child.data || (child.data = {})).transition =
      extractTransitionData(this));
    const oldRawChild: VNode = this._vnode;
    const oldChild: VNode = getRealChild(oldRawChild);

    // mark v-show
    // so that the transition module can hand over the control to the directive
    if (
      child.data.directives &&
      child.data.directives.some((d) => d.name === "show")
    ) {
      child.data.show = true;
    }

    if (
      oldChild &&
      oldChild.data &&
      !isSameChild(child, oldChild) &&
      !isAsyncPlaceholder(oldChild) &&
      // #6687 component root is a comment node
      !(
        oldChild.componentInstance &&
        oldChild.componentInstance._vnode.isComment
      )
    ) {
      // replace old child transition data with fresh one
      // important for dynamic transitions!
      const oldData: Object = (oldChild.data.transition = extend({}, data));
      // handle transition mode
      if (mode === "out-in") {
        // return placeholder node and queue update when leave finishes
        this._leaving = true;
        mergeVNodeHook(oldData, "afterLeave", () => {
          this._leaving = false;
          this.$forceUpdate();
        });
        return placeholder(h, rawChild);
      } else if (mode === "in-out") {
        if (isAsyncPlaceholder(child)) {
          return oldRawChild;
        }
        let delayedLeave;
        const performLeave = () => {
          delayedLeave();
        };
        mergeVNodeHook(data, "afterEnter", performLeave);
        mergeVNodeHook(data, "enterCancelled", performLeave);
        mergeVNodeHook(oldData, "delayLeave", (leave) => {
          delayedLeave = leave;
        });
      }
    }

    return rawChild;
  },
};

组件和 组件有几点实现类似,同样是抽象组件,同样直接实现 render 函数,同样利用了默认插槽。 组件非常灵活,支持的 props 非常多:

export const transitionProps = {
  name: String,
  appear: Boolean,
  css: Boolean,
  mode: String,
  type: String,
  enterClass: String,
  leaveClass: String,
  enterToClass: String,
  leaveToClass: String,
  enterActiveClass: String,
  leaveActiveClass: String,
  appearClass: String,
  appearActiveClass: String,
  appearToClass: String,
  duration: [Number, String, Object],
};

这些配置稍后会分析它们的作用, 组件另一个重要的就是 render 函数的实现,render 函数主要作用就是渲染生成 vnode,下面来看一下这部分的逻辑。

处理 children

let children: any = this.$slots.default;
if (!children) {
  return;
}

// filter out text nodes (possible whitespaces)
children = children.filter((c: VNode) => c.tag || isAsyncPlaceholder(c));
/* istanbul ignore if */
if (!children.length) {
  return;
}

// warn multiple elements
if (process.env.NODE_ENV !== "production" && children.length > 1) {
  warn(
    "<transition> can only be used on a single element. Use " +
      "<transition-group> for lists.",
    this.$parent
  );
}

先从默认插槽中获取 包裹的子节点,并且判断了子节点的长度,如果长度为 0,则直接返回,否则判断长度如果大于 1,也会在开发环境报警告,因为 组件是只能包裹一个子节点的。

处理 model

const mode: string = this.mode;

// warn invalid mode
if (
  process.env.NODE_ENV !== "production" &&
  mode &&
  mode !== "in-out" &&
  mode !== "out-in"
) {
  warn("invalid <transition> mode: " + mode, this.$parent);
}

过渡组件的对 mode 的支持只有 2 种,in-out 或者是 out-in。

获取 rawChild & child

const rawChild: VNode = children[0];

// if this is a component root node and the component's
// parent container node also has transition, skip.
if (hasParentTransition(this.$vnode)) {
  return rawChild;
}

// apply transition data to child
// use getRealChild() to ignore abstract components e.g. keep-alive
const child: ?VNode = getRealChild(rawChild);
/* istanbul ignore if */
if (!child) {
  return rawChild;
}

rawChild 就是第一个子节点 vnode,接着判断当前 如果是组件根节点并且外面包裹该组件的容器也是 的时候要跳过。来看一下 hasParentTransition 的实现:

function hasParentTransition(vnode: VNode): ?boolean {
  while ((vnode = vnode.parent)) {
    if (vnode.data.transition) {
      return true;
    }
  }
}

因为传入的是 this.$vnode,也就是 组件的 占位 vnode,只有当它同时作为根 vnode,也就是 vm._vnode 的时候,它的 parent 才不会为空,并且判断 parent 也是 组件,才返回 true,vnode.data.transition 稍后会介绍。

getRealChild 的目的是获取组件的非抽象子节点,因为 很可能会包裹一个 keep-alive,它的实现如下:

// in case the child is also an abstract component, e.g. <keep-alive>
// we want to recursively retrieve the real component to be rendered
function getRealChild(vnode: ?VNode): ?VNode {
  const compOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions;
  if (compOptions && compOptions.Ctor.options.abstract) {
    return getRealChild(getFirstComponentChild(compOptions.children));
  } else {
    return vnode;
  }
}

会递归找到第一个非抽象组件的 vnode 并返回,在这个 case 下,rawChild === child。

处理 id & data

// ensure a key that is unique to the vnode type and to this transition
// component instance. This key will be used to remove pending leaving nodes
// during entering.
const id: string = `__transition-${this._uid}-`;
child.key =
  child.key == null
    ? child.isComment
      ? id + "comment"
      : id + child.tag
    : isPrimitive(child.key)
    ? String(child.key).indexOf(id) === 0
      ? child.key
      : id + child.key
    : child.key;

const data: Object = ((child.data || (child.data = {})).transition =
  extractTransitionData(this));
const oldRawChild: VNode = this._vnode;
const oldChild: VNode = getRealChild(oldRawChild);

// mark v-show
// so that the transition module can hand over the control to the directive
if (
  child.data.directives &&
  child.data.directives.some((d) => d.name === "show")
) {
  child.data.show = true;
}

先根据 key 等一系列条件获取 id,接着从当前通过 extractTransitionData 组件实例上提取出过渡所需要的数据:

export function extractTransitionData(comp: Component): Object {
  const data = {};
  const options: ComponentOptions = comp.$options;
  // props
  for (const key in options.propsData) {
    data[key] = comp[key];
  }
  // events.
  // extract listeners and pass them directly to the transition methods
  const listeners: ?Object = options._parentListeners;
  for (const key in listeners) {
    data[camelize(key)] = listeners[key];
  }
  return data;
}

首先是遍历 props 赋值到 data 中,接着是遍历所有父组件的事件也把事件回调赋值到 data 中。

这样 child.data.transition 中就包含了过渡所需的一些数据,这些稍后都会用到,对于 child 如果使用了 v-show 指令,也会把 child.data.show 设置为 true,在例子中,得到的 child.data 如下:

{
  transition: {
    appear: true,
    name: 'fade'
  }
}

至于 oldRawChild 和 oldChild 是与后面的判断逻辑相关,这些这里先不介绍。

transition module

刚刚介绍完 组件的实现,它的 render 阶段只获取了一些数据,并且返回了渲染的 vnode,并没有任何和动画相关,而动画相关的逻辑全部在 src/platforms/web/modules/transition.js 中:

function _enter(_: any, vnode: VNodeWithData) {
  if (vnode.data.show !== true) {
    enter(vnode);
  }
}

export default inBrowser
  ? {
      create: _enter,
      activate: _enter,
      remove(vnode: VNode, rm: Function) {
        /* istanbul ignore else */
        if (vnode.data.show !== true) {
          leave(vnode, rm);
        } else {
          rm();
        }
      },
    }
  : {};

在之前介绍事件实现的章节中提到过在 vnode patch 的过程中,会执行很多钩子函数,那么对于过渡的实现,它只接收了 create 和 activate 2 个钩子函数,create 钩子函数只有当节点的创建过程才会执行,而 remove 会在节点销毁的时候执行,这也就印证了 必须要满足 v-if 、动态组件、组件根节点条件之一了,对于 v-show 在它的指令的钩子函数中也会执行相关逻辑,这块儿先不介绍。

过渡动画提供了 2 个时机,一个是 create 和 activate 的时候提供了 entering 进入动画,一个是 remove 的时候提供了 leaving 离开动画,那么接下来就来分别去分析这两个过程。

entering

整个 entering 过程的实现是 enter 函数:

export function enter(vnode: VNodeWithData, toggleDisplay: ?() => void) {
  const el: any = vnode.elm;

  // call leave callback now
  if (isDef(el._leaveCb)) {
    el._leaveCb.cancelled = true;
    el._leaveCb();
  }

  const data = resolveTransition(vnode.data.transition);
  if (isUndef(data)) {
    return;
  }

  /* istanbul ignore if */
  if (isDef(el._enterCb) || el.nodeType !== 1) {
    return;
  }

  const {
    css,
    type,
    enterClass,
    enterToClass,
    enterActiveClass,
    appearClass,
    appearToClass,
    appearActiveClass,
    beforeEnter,
    enter,
    afterEnter,
    enterCancelled,
    beforeAppear,
    appear,
    afterAppear,
    appearCancelled,
    duration,
  } = data;

  // activeInstance will always be the <transition> component managing this
  // transition. One edge case to check is when the <transition> is placed
  // as the root node of a child component. In that case we need to check
  // <transition>'s parent for appear check.
  let context = activeInstance;
  let transitionNode = activeInstance.$vnode;
  while (transitionNode && transitionNode.parent) {
    transitionNode = transitionNode.parent;
    context = transitionNode.context;
  }

  const isAppear = !context._isMounted || !vnode.isRootInsert;

  if (isAppear && !appear && appear !== "") {
    return;
  }

  const startClass = isAppear && appearClass ? appearClass : enterClass;
  const activeClass =
    isAppear && appearActiveClass ? appearActiveClass : enterActiveClass;
  const toClass = isAppear && appearToClass ? appearToClass : enterToClass;

  const beforeEnterHook = isAppear ? beforeAppear || beforeEnter : beforeEnter;
  const enterHook = isAppear
    ? typeof appear === "function"
      ? appear
      : enter
    : enter;
  const afterEnterHook = isAppear ? afterAppear || afterEnter : afterEnter;
  const enterCancelledHook = isAppear
    ? appearCancelled || enterCancelled
    : enterCancelled;

  const explicitEnterDuration: any = toNumber(
    isObject(duration) ? duration.enter : duration
  );

  if (process.env.NODE_ENV !== "production" && explicitEnterDuration != null) {
    checkDuration(explicitEnterDuration, "enter", vnode);
  }

  const expectsCSS = css !== false && !isIE9;
  const userWantsControl = getHookArgumentsLength(enterHook);

  const cb = (el._enterCb = once(() => {
    if (expectsCSS) {
      removeTransitionClass(el, toClass);
      removeTransitionClass(el, activeClass);
    }
    if (cb.cancelled) {
      if (expectsCSS) {
        removeTransitionClass(el, startClass);
      }
      enterCancelledHook && enterCancelledHook(el);
    } else {
      afterEnterHook && afterEnterHook(el);
    }
    el._enterCb = null;
  }));

  if (!vnode.data.show) {
    // remove pending leave element on enter by injecting an insert hook
    mergeVNodeHook(vnode, "insert", () => {
      const parent = el.parentNode;
      const pendingNode =
        parent && parent._pending && parent._pending[vnode.key];
      if (
        pendingNode &&
        pendingNode.tag === vnode.tag &&
        pendingNode.elm._leaveCb
      ) {
        pendingNode.elm._leaveCb();
      }
      enterHook && enterHook(el, cb);
    });
  }

  // start enter transition
  beforeEnterHook && beforeEnterHook(el);
  if (expectsCSS) {
    addTransitionClass(el, startClass);
    addTransitionClass(el, activeClass);
    nextFrame(() => {
      removeTransitionClass(el, startClass);
      if (!cb.cancelled) {
        addTransitionClass(el, toClass);
        if (!userWantsControl) {
          if (isValidDuration(explicitEnterDuration)) {
            setTimeout(cb, explicitEnterDuration);
          } else {
            whenTransitionEnds(el, type, cb);
          }
        }
      }
    });
  }

  if (vnode.data.show) {
    toggleDisplay && toggleDisplay();
    enterHook && enterHook(el, cb);
  }

  if (!expectsCSS && !userWantsControl) {
    cb();
  }
}

enter 的代码很长,先分析其中的核心逻辑。

解析过渡数据

const data = resolveTransition(vnode.data.transition);
if (isUndef(data)) {
  return;
}

const {
  css,
  type,
  enterClass,
  enterToClass,
  enterActiveClass,
  appearClass,
  appearToClass,
  appearActiveClass,
  beforeEnter,
  enter,
  afterEnter,
  enterCancelled,
  beforeAppear,
  appear,
  afterAppear,
  appearCancelled,
  duration,
} = data;

从 vnode.data.transition 中解析出过渡相关的一些数据,resolveTransition 的定义在 src/platforms/web/transition-util.js 中:

export function resolveTransition(def?: string | Object): ?Object {
  if (!def) {
    return;
  }
  /* istanbul ignore else */
  if (typeof def === "object") {
    const res = {};
    if (def.css !== false) {
      extend(res, autoCssTransition(def.name || "v"));
    }
    extend(res, def);
    return res;
  } else if (typeof def === "string") {
    return autoCssTransition(def);
  }
}

const autoCssTransition: (name: string) => Object = cached((name) => {
  return {
    enterClass: `${name}-enter`,
    enterToClass: `${name}-enter-to`,
    enterActiveClass: `${name}-enter-active`,
    leaveClass: `${name}-leave`,
    leaveToClass: `${name}-leave-to`,
    leaveActiveClass: `${name}-leave-active`,
  };
});

resolveTransition 会通过 autoCssTransition 处理 name 属性,生成一个用来描述各个阶段的 Class 名称的对象,扩展到 def 中并返回给 data,这样就可以从 data 中获取到过渡相关的所有数据。

处理边界情况

// activeInstance will always be the <transition> component managing this
// transition. One edge case to check is when the <transition> is placed
// as the root node of a child component. In that case we need to check
// <transition>'s parent for appear check.
let context = activeInstance;
let transitionNode = activeInstance.$vnode;
while (transitionNode && transitionNode.parent) {
  transitionNode = transitionNode.parent;
  context = transitionNode.context;
}

const isAppear = !context._isMounted || !vnode.isRootInsert;

if (isAppear && !appear && appear !== "") {
  return;
}

这是为了处理当 作为子组件的根节点,那么需要检查它的父组件作为 appear 的检查。isAppear 表示当前上下文实例还没有 mounted,第一次出现的时机。如果是第一次并且 组件没有配置 appear 的话,直接返回。

定义过渡类名、钩子函数和其它配置

const startClass = isAppear && appearClass ? appearClass : enterClass;
const activeClass =
  isAppear && appearActiveClass ? appearActiveClass : enterActiveClass;
const toClass = isAppear && appearToClass ? appearToClass : enterToClass;

const beforeEnterHook = isAppear ? beforeAppear || beforeEnter : beforeEnter;
const enterHook = isAppear
  ? typeof appear === "function"
    ? appear
    : enter
  : enter;
const afterEnterHook = isAppear ? afterAppear || afterEnter : afterEnter;
const enterCancelledHook = isAppear
  ? appearCancelled || enterCancelled
  : enterCancelled;

const explicitEnterDuration: any = toNumber(
  isObject(duration) ? duration.enter : duration
);

if (process.env.NODE_ENV !== "production" && explicitEnterDuration != null) {
  checkDuration(explicitEnterDuration, "enter", vnode);
}

const expectsCSS = css !== false && !isIE9;
const userWantsControl = getHookArgumentsLength(enterHook);

const cb = (el._enterCb = once(() => {
  if (expectsCSS) {
    removeTransitionClass(el, toClass);
    removeTransitionClass(el, activeClass);
  }
  if (cb.cancelled) {
    if (expectsCSS) {
      removeTransitionClass(el, startClass);
    }
    enterCancelledHook && enterCancelledHook(el);
  } else {
    afterEnterHook && afterEnterHook(el);
  }
  el._enterCb = null;
}));

对于过渡类名方面,startClass 定义进入过渡的开始状态,在元素被插入时生效,在下一个帧移除;activeClass 定义过渡的状态,在元素整个过渡过程中作用,在元素被插入时生效,在 transition/animation 完成之后移除;toClass 定义进入过渡的结束状态,在元素被插入一帧后生效 (与此同时 startClass 被删除),在 /animation 完成之后移除。

对于过渡钩子函数方面,beforeEnterHook 是过渡开始前执行的钩子函数,enterHook 是在元素插入后或者是 v-show 显示切换后执行的钩子函数。afterEnterHook 是在过渡动画执行完后的钩子函数。

explicitEnterDuration 表示 enter 动画执行的时间。
expectsCSS 表示过渡动画是受 CSS 的影响。
cb 定义的是过渡完成执行的回调函数。

合并 insert 钩子函数

if (!vnode.data.show) {
  // remove pending leave element on enter by injecting an insert hook
  mergeVNodeHook(vnode, "insert", () => {
    const parent = el.parentNode;
    const pendingNode = parent && parent._pending && parent._pending[vnode.key];
    if (
      pendingNode &&
      pendingNode.tag === vnode.tag &&
      pendingNode.elm._leaveCb
    ) {
      pendingNode.elm._leaveCb();
    }
    enterHook && enterHook(el, cb);
  });
}

mergeVNodeHook 的定义在 src/core/vdom/helpers/merge-hook.js 中:

export function mergeVNodeHook(def: Object, hookKey: string, hook: Function) {
  if (def instanceof VNode) {
    def = def.data.hook || (def.data.hook = {});
  }
  let invoker;
  const oldHook = def[hookKey];

  function wrappedHook() {
    hook.apply(this, arguments);
    // important: remove merged hook to ensure it's called only once
    // and prevent memory leak
    remove(invoker.fns, wrappedHook);
  }

  if (isUndef(oldHook)) {
    // no existing hook
    invoker = createFnInvoker([wrappedHook]);
  } else {
    /* istanbul ignore if */
    if (isDef(oldHook.fns) && isTrue(oldHook.merged)) {
      // already a merged invoker
      invoker = oldHook;
      invoker.fns.push(wrappedHook);
    } else {
      // existing plain hook
      invoker = createFnInvoker([oldHook, wrappedHook]);
    }
  }

  invoker.merged = true;
  def[hookKey] = invoker;
}

mergeVNodeHook 的逻辑很简单,就是把 hook 函数合并到 def.data.hook[hookey] 中,生成新的 invoker,createFnInvoker 方法在分析事件章节的时候已经介绍过了。

之前知道组件的 vnode 原本定义了 init、prepatch、insert、destroy 四个钩子函数,而 mergeVNodeHook 函数就是把一些新的钩子函数合并进来,例如在 过程中合并的 insert 钩子函数,就会合并到组件 vnode 的 insert 钩子函数中,这样当组件插入后,就会执行定义的 enterHook 了。

开始执行过渡动画

// start enter transition
beforeEnterHook && beforeEnterHook(el);
if (expectsCSS) {
  addTransitionClass(el, startClass);
  addTransitionClass(el, activeClass);
  nextFrame(() => {
    removeTransitionClass(el, startClass);
    if (!cb.cancelled) {
      addTransitionClass(el, toClass);
      if (!userWantsControl) {
        if (isValidDuration(explicitEnterDuration)) {
          setTimeout(cb, explicitEnterDuration);
        } else {
          whenTransitionEnds(el, type, cb);
        }
      }
    }
  });
}

首先执行 beforeEnterHook 钩子函数,把当前元素的 DOM 节点 el 传入,然后判断 expectsCSS,如果为 true 则表明希望用 CSS 来控制动画,那么会执行 addTransitionClass(el, startClass) 和 addTransitionClass(el, activeClass),它的定义在 src/platforms/runtime/transition-util.js 中:

export function addTransitionClass(el: any, cls: string) {
  const transitionClasses =
    el._transitionClasses || (el._transitionClasses = []);
  if (transitionClasses.indexOf(cls) < 0) {
    transitionClasses.push(cls);
    addClass(el, cls);
  }
}

其实非常简单,就是给当前 DOM 元素 el 添加样式 cls,所以这里添加了 startClass 和 activeClass,在的例子中就是给 p 标签添加了 fade-enter 和 fade-enter-active 2 个样式。

接下来执行了 nextFrame:

const raf = inBrowser
  ? window.requestAnimationFrame
    ? window.requestAnimationFrame.bind(window)
    : setTimeout
  : (fn) => fn();

export function nextFrame(fn: Function) {
  raf(() => {
    raf(fn);
  });
}

它就是一个简单的 requestAnimationFrame 的实现,它的参数 fn 会在下一帧执行,因此下一帧执行了
removeTransitionClass(el, startClass):

export function removeTransitionClass(el: any, cls: string) {
  if (el._transitionClasses) {
    remove(el._transitionClasses, cls);
  }
  removeClass(el, cls);
}

把 startClass 移除,在例子中就是移除 fade-enter 样式。然后判断此时过渡没有被取消,则执行 addTransitionClass(el, toClass) 添加 toClass,在例子中就是添加了 fade-enter-to。然后判断 !userWantsControl,也就是用户不通过 enterHook 钩子函数控制动画,这时候如果用户指定了 explicitEnterDuration,则延时这个时间执行 cb,否则通过 whenTransitionEnds(el, type, cb) 决定执行 cb 的时机:

export function whenTransitionEnds (
  el: Element,
  expectedType: ?string,
  cb: Function
) {
  const { type, timeout, propCount } = getTransitionInfo(el, expectedType)
  if (!type) return cb()
  const event: string = type === <transition> ? transitionEndEvent : animationEndEvent
  let ended = 0
  const end = () => {
    el.removeEventListener(event, onEnd)
    cb()
  }
  const onEnd = e => {
    if (e.target === el) {
      if (++ended >= propCount) {
        end()
      }
    }
  }
  setTimeout(() => {
    if (ended < propCount) {
      end()
    }
  }, timeout + 1)
  el.addEventListener(event, onEnd)
}

whenTransitionEnds 的逻辑具体不深讲了,本质上就利用了过渡动画的结束事件来决定 cb 函数的执行。

最后再回到 cb 函数:

const cb = (el._enterCb = once(() => {
  if (expectsCSS) {
    removeTransitionClass(el, toClass);
    removeTransitionClass(el, activeClass);
  }
  if (cb.cancelled) {
    if (expectsCSS) {
      removeTransitionClass(el, startClass);
    }
    enterCancelledHook && enterCancelledHook(el);
  } else {
    afterEnterHook && afterEnterHook(el);
  }
  el._enterCb = null;
}));

其实很简单,执行了 removeTransitionClass(el, toClass) 和 removeTransitionClass(el, activeClass) 把 toClass 和 activeClass 移除,然后判断如果有没有取消,如果取消则移除 startClass 并执行 enterCancelledHook,否则执行 afterEnterHook(el)。

那么到这里,entering 的过程就介绍完了。

leaving

与 entering 相对的就是 leaving 阶段了,entering 主要发生在组件插入后,而 leaving 主要发生在组件销毁前。

export function leave(vnode: VNodeWithData, rm: Function) {
  const el: any = vnode.elm;

  // call enter callback now
  if (isDef(el._enterCb)) {
    el._enterCb.cancelled = true;
    el._enterCb();
  }

  const data = resolveTransition(vnode.data.transition);
  if (isUndef(data) || el.nodeType !== 1) {
    return rm();
  }

  /* istanbul ignore if */
  if (isDef(el._leaveCb)) {
    return;
  }

  const {
    css,
    type,
    leaveClass,
    leaveToClass,
    leaveActiveClass,
    beforeLeave,
    leave,
    afterLeave,
    leaveCancelled,
    delayLeave,
    duration,
  } = data;

  const expectsCSS = css !== false && !isIE9;
  const userWantsControl = getHookArgumentsLength(leave);

  const explicitLeaveDuration: any = toNumber(
    isObject(duration) ? duration.leave : duration
  );

  if (process.env.NODE_ENV !== "production" && isDef(explicitLeaveDuration)) {
    checkDuration(explicitLeaveDuration, "leave", vnode);
  }

  const cb = (el._leaveCb = once(() => {
    if (el.parentNode && el.parentNode._pending) {
      el.parentNode._pending[vnode.key] = null;
    }
    if (expectsCSS) {
      removeTransitionClass(el, leaveToClass);
      removeTransitionClass(el, leaveActiveClass);
    }
    if (cb.cancelled) {
      if (expectsCSS) {
        removeTransitionClass(el, leaveClass);
      }
      leaveCancelled && leaveCancelled(el);
    } else {
      rm();
      afterLeave && afterLeave(el);
    }
    el._leaveCb = null;
  }));

  if (delayLeave) {
    delayLeave(performLeave);
  } else {
    performLeave();
  }

  function performLeave() {
    // the delayed leave may have already been cancelled
    if (cb.cancelled) {
      return;
    }
    // record leaving element
    if (!vnode.data.show) {
      (el.parentNode._pending || (el.parentNode._pending = {}))[
        (vnode.key: any)
      ] = vnode;
    }
    beforeLeave && beforeLeave(el);
    if (expectsCSS) {
      addTransitionClass(el, leaveClass);
      addTransitionClass(el, leaveActiveClass);
      nextFrame(() => {
        removeTransitionClass(el, leaveClass);
        if (!cb.cancelled) {
          addTransitionClass(el, leaveToClass);
          if (!userWantsControl) {
            if (isValidDuration(explicitLeaveDuration)) {
              setTimeout(cb, explicitLeaveDuration);
            } else {
              whenTransitionEnds(el, type, cb);
            }
          }
        }
      });
    }
    leave && leave(el, cb);
    if (!expectsCSS && !userWantsControl) {
      cb();
    }
  }
}

纵观 leave 的实现,和 enter 的实现几乎是一个镜像过程,不同的是从 data 中解析出来的是 leave 相关的样式类名和钩子函数。还有一点不同是可以配置 delayLeave,它是一个函数,可以延时执行 leave 的相关过渡动画,在 leave 动画执行完后,它会执行 rm 函数把节点从 DOM 中真正做移除。

5.6 transition-group

前一节介绍了 组件的实现原理,它只能针对单一元素实现过渡效果。做前端开发经常会遇到列表的需求,对列表元素进行添加和删除,有时候也希望有过渡效果,Vue.js 提供了 组件,很好地帮助实现了列表的过渡效果。那么接下来就来分析一下它的实现原理。

为了更直观,也是通过一个示例来说明:

let vm = new Vue({
  el: '#app',
  template: '<div id="list-complete-demo" class="demo">' +
  '<button v-on:click="add">Add</button>' +
  '<button v-on:click="remove">Remove</button>' +
  '<transition-group name="list-complete" tag="p">' +
  '<span v-for="item in items" v-bind:key="item" class="list-complete-item">' +
  '{{ item }}' +
  '</span>' +
  '</transition-group>' +
  '</div>',
  data: {
    items: [1, 2, 3, 4, 5, 6, 7, 8, 9],
    nextNum: 10
  },
  methods: {
    randomIndex: function () {
      return Math.floor(Math.random() * this.items.length)
    },
    add: function () {
      this.items.splice(this.randomIndex(), 0, this.nextNum++)
    },
    remove: function () {
      this.items.splice(this.randomIndex(), 1)
    }
  }
})
 .list-complete-item {
  display: inline-block;
  margin-right: 10px;
}
.list-complete-move {
  transition: all 1s;
}
.list-complete-enter, .list-complete-leave-to {
  opacity: 0;
  transform: translateY(30px);
}
.list-complete-enter-active {
  transition: all 1s;
}
.list-complete-leave-active {
  transition: all 1s;
  position: absolute;
}

这个示例初始会展现 1-9 十个数字,当点击 Add 按钮时,会生成 nextNum 并随机在当前数列表中插入;当点击 Remove 按钮时,会随机删除掉一个数。会发现在数添加删除的过程中在列表中会有过渡动画,这就是 组件配合定义的 CSS 产生的效果。

首先还是来分析 组件的实现,它的定义在 src/platforms/web/runtime/components/transitions.js 中:

const props = extend(
  {
    tag: String,
    moveClass: String,
  },
  transitionProps
);

delete props.mode;

export default {
  props,

  beforeMount() {
    const update = this._update;
    this._update = (vnode, hydrating) => {
      // force removing pass
      this.__patch__(
        this._vnode,
        this.kept,
        false, // hydrating
        true // removeOnly (!important, avoids unnecessary moves)
      );
      this._vnode = this.kept;
      update.call(this, vnode, hydrating);
    };
  },

  render(h: Function) {
    const tag: string = this.tag || this.$vnode.data.tag || "span";
    const map: Object = Object.create(null);
    const prevChildren: Array<VNode> = (this.prevChildren = this.children);
    const rawChildren: Array<VNode> = this.$slots.default || [];
    const children: Array<VNode> = (this.children = []);
    const transitionData: Object = extractTransitionData(this);

    for (let i = 0; i < rawChildren.length; i++) {
      const c: VNode = rawChildren[i];
      if (c.tag) {
        if (c.key != null && String(c.key).indexOf("__vlist") !== 0) {
          children.push(c);
          map[c.key] = c;
          (c.data || (c.data = {})).transition = transitionData;
        } else if (process.env.NODE_ENV !== "production") {
          const opts: ?VNodeComponentOptions = c.componentOptions;
          const name: string = opts
            ? opts.Ctor.options.name || opts.tag || ""
            : c.tag;
          warn(`<transition-group> children must be keyed: <${name}>`);
        }
      }
    }

    if (prevChildren) {
      const kept: Array<VNode> = [];
      const removed: Array<VNode> = [];
      for (let i = 0; i < prevChildren.length; i++) {
        const c: VNode = prevChildren[i];
        c.data.transition = transitionData;
        c.data.pos = c.elm.getBoundingClientRect();
        if (map[c.key]) {
          kept.push(c);
        } else {
          removed.push(c);
        }
      }
      this.kept = h(tag, null, kept);
      this.removed = removed;
    }

    return h(tag, null, children);
  },

  updated() {
    const children: Array<VNode> = this.prevChildren;
    const moveClass: string = this.moveClass || (this.name || "v") + "-move";
    if (!children.length || !this.hasMove(children[0].elm, moveClass)) {
      return;
    }

    // we divide the work into three loops to avoid mixing DOM reads and writes
    // in each iteration - which helps prevent layout thrashing.
    children.forEach(callPendingCbs);
    children.forEach(recordPosition);
    children.forEach(applyTranslation);

    // force reflow to put everything in position
    // assign to this to avoid being removed in tree-shaking
    // $flow-disable-line
    this._reflow = document.body.offsetHeight;

    children.forEach((c: VNode) => {
      if (c.data.moved) {
        let el: any = c.elm;
        let s: any = el.style;
        addTransitionClass(el, moveClass);
        s.transform = s.WebkitTransform = s.transitionDuration = "";
        el.addEventListener(
          transitionEndEvent,
          (el._moveCb = function cb(e) {
            if (!e || /transform$/.test(e.propertyName)) {
              el.removeEventListener(transitionEndEvent, cb);
              el._moveCb = null;
              removeTransitionClass(el, moveClass);
            }
          })
        );
      }
    });
  },

  methods: {
    hasMove(el: any, moveClass: string): boolean {
      /* istanbul ignore if */
      if (!hasTransition) {
        return false;
      }
      /* istanbul ignore if */
      if (this._hasMove) {
        return this._hasMove;
      }
      // Detect whether an element with the move class applied has
      // CSS transitions. Since the element may be inside an entering
      // transition at this very moment, we make a clone of it and remove
      // all other transition classes applied to ensure only the move class
      // is applied.
      const clone: HTMLElement = el.cloneNode();
      if (el._transitionClasses) {
        el._transitionClasses.forEach((cls: string) => {
          removeClass(clone, cls);
        });
      }
      addClass(clone, moveClass);
      clone.style.display = "none";
      this.$el.appendChild(clone);
      const info: Object = getTransitionInfo(clone);
      this.$el.removeChild(clone);
      return (this._hasMove = info.hasTransform);
    },
  },
};

render 函数

组件也是由 render 函数渲染生成 vnode,接下来先分析 render 的实现。

定义一些变量

const tag: string = this.tag || this.$vnode.data.tag || "span";
const map: Object = Object.create(null);
const prevChildren: Array<VNode> = (this.prevChildren = this.children);
const rawChildren: Array<VNode> = this.$slots.default || [];
const children: Array<VNode> = (this.children = []);
const transitionData: Object = extractTransitionData(this);

不同于 组件, 组件非抽象组件,它会渲染成一个真实元素,默认 tag 是 span。 prevChildren 用来存储上一次的子节点;children 用来存储当前的子节点;rawChildren 表示 包裹的原始子节点;transtionData 是从 组件上提取出来的一些渲染数据,这点和 组件的实现是一样的。

遍历 rawChidren,初始化 children

for (let i = 0; i < rawChildren.length; i++) {
  const c: VNode = rawChildren[i];
  if (c.tag) {
    if (c.key != null && String(c.key).indexOf("__vlist") !== 0) {
      children.push(c);
      map[c.key] = c;
      (c.data || (c.data = {})).transition = transitionData;
    } else if (process.env.NODE_ENV !== "production") {
      const opts: ?VNodeComponentOptions = c.componentOptions;
      const name: string = opts
        ? opts.Ctor.options.name || opts.tag || ""
        : c.tag;
      warn(`<transition-group> children must be keyed: <${name}>`);
    }
  }
}

其实就是对 rawChildren 遍历,拿到每个 vnode,然后会判断每个 vnode 是否设置了 key,这个是 对列表元素的要求。然后把 vnode 添加到 children 中,然后把刚刚提取的过渡数据 transitionData 添加的 vnode.data.transition 中,这点很关键,只有这样才能实现列表中单个元素的过渡动画。

处理 prevChildren

if (prevChildren) {
  const kept: Array<VNode> = [];
  const removed: Array<VNode> = [];
  for (let i = 0; i < prevChildren.length; i++) {
    const c: VNode = prevChildren[i];
    c.data.transition = transitionData;
    c.data.pos = c.elm.getBoundingClientRect();
    if (map[c.key]) {
      kept.push(c);
    } else {
      removed.push(c);
    }
  }
  this.kept = h(tag, null, kept);
  this.removed = removed;
}

return h(tag, null, children);

当有 prevChildren 的时候,会对它做遍历,获取到每个 vnode,然后把 transitionData 赋值到 vnode.data.transition,这个是为了当它在 enter 和 leave 的钩子函数中有过渡动画,在上节介绍 transition 的实现中说过。接着又调用了原生 DOM 的 getBoundingClientRect 方法获取到原生 DOM 的位置信息,记录到 vnode.data.pos 中,然后判断一下 vnode.key 是否在 map 中,如果在则放入 kept 中,否则表示该节点已被删除,放入 removed 中,然后通过执行 h(tag, null, kept) 渲染后放入 this.kept 中,把 removed 用 this.removed 保存。最后整个 render 函数通过 h(tag, null, children) 生成渲染 vnode。

如果 transition-group 只实现了这个 render 函数,那么每次插入和删除的元素的缓动动画是可以实现的,在例子中,当新增一个元素,它的插入的过渡动画是有的,但是剩余元素平移的过渡效果是出不来的,所以接下来来分析 组件是如何实现剩余元素平移的过渡效果的。

move 过渡实现

其实在实现元素的插入和删除,无非就是操作数据,控制它们的添加和删除。比如新增数据的时候,会添加一条数据,除了重新执行 render 函数渲染新的节点外,还要触发 updated 钩子函数,接着就来分析 updated 钩子函数的实现。

判断子元素是否定义 move 相关样式

const children: Array<VNode> = this.prevChildren
const moveClass: string = this.moveClass || ((this.name || 'v') + '-move')
if (!children.length || !this.hasMove(children[0].elm, moveClass)) {
  return
}

hasMove (el: any, moveClass: string): boolean {
  /* istanbul ignore if */
  if (!hasTransition) {
    return false
  }
  /* istanbul ignore if */
  if (this._hasMove) {
    return this._hasMove
  }
  // Detect whether an element with the move class applied has
  // CSS transitions. Since the element may be inside an entering
  // transition at this very moment, we make a clone of it and remove
  // all other transition classes applied to ensure only the move class
  // is applied.
  const clone: HTMLElement = el.cloneNode()
  if (el._transitionClasses) {
    el._transitionClasses.forEach((cls: string) => { removeClass(clone, cls) })
  }
  addClass(clone, moveClass)
  clone.style.display = 'none'
  this.$el.appendChild(clone)
  const info: Object = getTransitionInfo(clone)
  this.$el.removeChild(clone)
  return (this._hasMove = info.hasTransform)
}

核心就是 hasMove 的判断,首先克隆一个 DOM 节点,然后为了避免影响,移除它的所有其他的过渡 Class;接着添加了 moveClass 样式,设置 display 为 none,添加到组件根节点上;接下来通过 getTransitionInfo 获取它的一些缓动相关的信息,这个函数在上一节也介绍过,然后从组件根节点上删除这个克隆节点,并通过判断 info.hasTransform 来判断 hasMove,在例子中,该值为 true。

子节点预处理

children.forEach(callPendingCbs)
children.forEach(recordPosition)
children.forEach(applyTranslation)
对 children 做了 3 轮循环,分别做了如下一些处理:

function callPendingCbs (c: VNode) {
  if (c.elm._moveCb) {
    c.elm._moveCb()
  }
  if (c.elm._enterCb) {
    c.elm._enterCb()
  }
}

function recordPosition (c: VNode) {
  c.data.newPos = c.elm.getBoundingClientRect()
}

function applyTranslation (c: VNode) {
  const oldPos = c.data.pos
  const newPos = c.data.newPos
  const dx = oldPos.left - newPos.left
  const dy = oldPos.top - newPos.top
  if (dx || dy) {
    c.data.moved = true
    const s = c.elm.style
    s.transform = s.WebkitTransform = `translate(${dx}px,${dy}px)`
    s.transitionDuration = '0s'
  }
}

callPendingCbs 方法是在前一个过渡动画没执行完又再次执行到该方法的时候,会提前执行 _moveCb 和 _enterCb。
recordPosition 的作用是记录节点的新位置。
applyTranslation 的作用是先计算节点新位置和旧位置的差值,如果差值不为 0,则说明这些节点是需要移动的,所以记录 vnode.data.moved 为 true,并且通过设置 transform 把需要移动的节点的位置又偏移到之前的旧位置,目的是为了做 move 缓动做准备。

遍历子元素实现 move 过渡

this._reflow = document.body.offsetHeight;

children.forEach((c: VNode) => {
  if (c.data.moved) {
    let el: any = c.elm;
    let s: any = el.style;
    addTransitionClass(el, moveClass);
    s.transform = s.WebkitTransform = s.transitionDuration = "";
    el.addEventListener(
      transitionEndEvent,
      (el._moveCb = function cb(e) {
        if (!e || /transform$/.test(e.propertyName)) {
          el.removeEventListener(transitionEndEvent, cb);
          el._moveCb = null;
          removeTransitionClass(el, moveClass);
        }
      })
    );
  }
});

首先通过 document.body.offsetHeight 强制触发浏览器重绘,接着再次对 children 遍历,先给子节点添加 moveClass,在例子中,moveClass 定义了 transition: all 1s; 缓动;接着把子节点的 style.transform 设置为空,由于前面把这些节点偏移到之前的旧位置,所以它就会从旧位置按照 1s 的缓动时间过渡偏移到它的当前目标位置,这样就实现了 move 的过渡动画。并且接下来会监听 transitionEndEvent 过渡结束的事件,做一些清理的操作。

另外,由于虚拟 DOM 的子元素更新算法是不稳定的,它不能保证被移除元素的相对位置,所以强制 组件更新子节点通过 2 个步骤:第一步移除需要移除的 vnode,同时触发它们的 leaving 过渡;第二步需要把插入和移动的节点达到它们的最终态,同时还要保证移除的节点保留在应该的位置,而这个是通过 beforeMount 钩子函数来实现的:

beforeMount () {
  const update = this._update
  this._update = (vnode, hydrating) => {
    // force removing pass
    this.__patch__(
      this._vnode,
      this.kept,
      false, // hydrating
      true // removeOnly (!important, avoids unnecessary moves)
    )
    this._vnode = this.kept
    update.call(this, vnode, hydrating)
  }
}

通过把 patch 方法的第四个参数 removeOnly 设置为 true,这样在 updateChildren 阶段,是不会移动 vnode 节点的。

总结

前面几章分析了 Vue 的核心以及编译过程,除此之外,Vue 还提供了很多好用的 feature 如 event、v-model、slot、keep-alive、transition 等等。对他们的理解有助于在平时开发中更好地应用这些 feature,即使出现 bug 也可以很从容地应对。

这一章是一个可扩展的章节,除了已分析的这些 feature 外,未来可能会扩展更多的内容。