Vue 源码(二)  —— vdom

Vue 源码(二) —— vdom

前言:vue 最核心的两个部分就是响应式系统和vdom, 在上一篇的文章中已经介绍了响应式系统的实现原理,如果对这部分实现感兴趣的童鞋可以查看我之前的文章。

本文主要讲vdom 的实现原理,在讲述实现原理的时候,我们先从两个最基本的问题开始


一、vue 2+ 为什么要引入vdom

“罐头是1810发明出来的,可是开罐器呢,却在1858年才发明出来。有时就是这样,重要的东西可能迟来一步,但却一定会到。生活和爱情,都是如此。程序,当然也不例外。”

可能很多人会说引入vdom 是因为性能的原因。vdom因为是纯粹的JS对象,所以操作它会很高效,传统的dom算法的复杂度o(n^3), 而虚拟dom的复杂度才o(n) (仅在同级的vnode间做diff,递归地进行同级vnode的diff)。但是看了尤大关于vue理念问题的那篇文章后,我发现其实最主要的并不是性能问题。引入尤大的原话:Vue 2.0 引入 vdom 的主要原因是 vdom 把渲染过程抽象化了,从而使得组件的抽象能力也得到提升,并且可以适配 DOM 以外的渲染目标。可以看出来引入vdom最最主要是因为它的跨平台能力,从而使ssr和weex成为可能,也让vue 拥有了jsx书写template的能力。

尤雨溪:Vue 的理念问题zhuanlan.zhihu.com图标

二、vdom 的基本实现思路

虚拟的DOM的核心思想是:对复杂的文档DOM结构,提供一种方便的工具,进行最小化地DOM操作。vdom的基本实现主要是经过以下几个步骤

  • 用JS表示DOM结构 (创建 vnode)
  • 根据虚拟DOM树构建出真实的DOM树 (createElm 创建真实DOM元素 )
  • 通过JS对象表示的虚拟DOM计算出实际DOM需要做的最小变动 (updateChildren diff 算法的核心)

三、vue vdom 的实现

终于到了正文部分,vue vdom是基于snabbdom。3.0版本的计划是根据Inferno改进。Plans for the Next Iteration of Vue.js

从上一篇文章中,我们知道每个在template模板使用到的变量都会关联一个渲染视图的Watcher,我们先来看渲染视图的Watcher关联的方法:

updateComponent = function () {
    vm._update(vm._render(), hydrating);
};

new Watcher(vm, updateComponent, noop, {
    before: function before () {
      if (vm._isMounted) {
        callHook(vm, 'beforeUpdate');
      }
    }
  }, true /* isRenderWatcher */);

可以看出核心的方法就是vm._update(vm._render(), hydrating); 这里vm._render() 方法就是用来生成vnode。

(a) 创建 vnode

我们先来看这个方法的实现,省略了一部分不影响阅读代码

Vue.prototype._render = function () {
    var vm = this;
    var ref = vm.$options;
    var render = ref.render;
    var _parentVnode = ref._parentVnode;

    // set parent vnode. this allows render functions to have access
    // to the data on the placeholder node.
    vm.$vnode = _parentVnode;
    // render self
    var vnode;
    try {
      vnode = render.call(vm._renderProxy, vm.$createElement);
    } catch (e) {
      // 省略
      vnode = vm._vnode
    }
    // set parent
    vnode.parent = _parentVnode;
    return vnode
  };

这里我们来看 ref.render方法

// render
render (h): VNode {
    return h(ButtonGroup, this.setBackgroundColor(this.color, {
      staticClass: 'v-bottom-nav',
      class: this.classes,
      style: {
        height: `${parseInt(this.computedHeight)}px`
      },
      props: {
        mandatory: Boolean(this.mandatory || this.active !== undefined),
        value: this.active
      },
      on: { change: this.updateValue }
    }), this.$slots.default)
  }



// Vue loader —> template -> render
var __vue_render__ = function() {
  var _vm = this
  var _h = _vm.$createElement
  var _c = _vm._self._c || _h
  return _vm.href
    ? _c(
        "a",
        { class: _vm.className, attrs: { href: _vm.href } },
        [_vm._t("default")],
        2
      )
    : _c(
        "button",
        {
          class: _vm.className,
          attrs: { disabled: _vm.disabled },
          on: { click: _vm.click }
        },
        [_vm._t("default")],
        2
      )
}

写过render函数的童鞋可能对这个就不会感到陌生了,其实这里的render 就是render方法。如果是.vue 文件,则会通过vue-loader 编译生成render 方法。这里的 h对应的是createElement。

这里的vm._renderProxy是render 函数运行时的上下文。指向被代理后的vm。 使用proxy拦截到render template 时候的错误,在控制台抛出警告。

var warnNonPresent = function (target, key) {
    warn(
        "Property or method \"" + key + "\" is not defined on the instance but " +
        'referenced during render. Make sure that this property is reactive, ' +
        'either in the data option, or for class-based components, by ' +
        'initializing the property. ' +
        'See: https://vuejs.org/v2/guide/reactivity.html#Declaring-Reactive-Properties.',
        target
    );
};

var getHandler = {
    get: function get (target, key) {
      if (typeof key === 'string' && !(key in target)) {
        warnNonPresent(target, key);
      }
      return target[key]
    }
  };

  initProxy = function initProxy (vm) {
    if (hasProxy) {
      // determine which proxy handler to use
      var options = vm.$options;
      var handlers = options.render && options.render._withStripped
        ? getHandler
        : hasHandler;
      vm._renderProxy = new Proxy(vm, handlers);
    } else {
      vm._renderProxy = vm;
    }
  };

createElement 会调用_createElement来创建vnode。

export function _createElement (
  context: Component,
  tag?: string | Class<Component> | Function | Object,
  data?: VNodeData,
  children?: any,
  normalizationType?: number
): VNode | Array<VNode> {
  let vnode, ns
  if (typeof tag === 'string') {
    let Ctor
    ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
    if (config.isReservedTag(tag)) {
      // platform built-in elements
      vnode = new VNode(
        config.parsePlatformTagName(tag), data, children,
        undefined, undefined, context
      )
    } else if (isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      // component
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      vnode = new VNode(
        tag, data, children,
        undefined, undefined, context
      )
    }
  } else {
    // direct component options / constructor
    vnode = createComponent(tag, data, context, children)
  }
  if (Array.isArray(vnode)) {
    return vnode
  } else if (isDef(vnode)) {
    if (isDef(ns)) applyNS(vnode, ns)
    if (isDef(data)) registerDeepBindings(data)
    return vnode
  } else {
    return createEmptyVNode()
  }
}

参数context 是执行的上下文vm,tag 是标签,可能是html 标签或者是自定义组件的标签,如果是html标签会直接调用new Vnode 创建vnode, 如果是组件标签会调用createComponent 创建组件。data 是attributes被解析出来的数据{ attrs: { id: 'app', staticClass: 'container' } }, children 是子节点的vnode 数组。

我们先来看createComponent 方法做了什么

// return a placeholder vnode
  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
  )

可以看出来 createComponent 依旧是创建一个vnode, 但是组件的vnode 与原生html标签的vnode 区别在于多传了几个参数{ Ctor, propsData, listeners, tag, children } 这些属性会挂载在 vnode 的 componentOptions属性上面,这里的Ctor是component的构造函数,绑定在vm实例 $options 的 components 上。

propsData 是组件传递的props, listeners 一个包含了所有在父组件上注册的事件侦听器的对象。比如

<test-d t='12' @ttt="say" /> propsData: { t: "12" }, listeners: { ttt: ƒ () }

现在我们知道了原生标签和组件标签都会创建vnode,但是组件标签的vnode会有一个componentOptions绑定组件相关的属性方法。 接下来看Vnode 的构造方法。代码比较长,只截取构造方法的一部分,完整可以查看链接VNode

constructor (
    tag?: string,
    data?: VNodeData,
    children?: ?Array<VNode>,
    text?: string,
    elm?: Node,
    context?: Component,
    componentOptions?: VNodeComponentOptions,
    asyncFactory?: Function
  ) {
    this.tag = tag
    this.data = data
    this.children = children
    this.text = text
    this.elm = elm
    this.context = context
    this.key = data && data.key
    this.componentOptions = componentOptions
    // 省略
  }

VNode 类很简单,相比较于node节点属性也少很多。通过vnode的elm属性可以访问到对应的Node。节点的key属性,被当作节点的标志,用以优化。

// vnode
{
  tag: "span",
  data: {
    staticClass: "hello"
    id: "hello"
    key: "1",
  }
}
// 渲染之后的结果
<span class="hello" id="hello" key="1"></span>

到这来我们已经知道vnode是如何创建的(vm._render()),也知道vnode的数据结构,那么Vue是如何把 vnode 渲染成真实的DOM呢?

(b) patch 和 diff 算法

接下来我们看_update方法

Vue.prototype._update = function (vnode, hydrating) {
    var vm = this;
    var prevEl = vm.$el;
    var prevVnode = vm._vnode;
    var prevActiveInstance = activeInstance;
    activeInstance = vm;
    vm._vnode = vnode;
    // Vue.prototype.__patch__ is injected in entry points
    // based on the rendering backend used.
    if (!prevVnode) {
      // initial render
      vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */);
    } else {
      // updates
      vm.$el = vm.__patch__(prevVnode, vnode);
    }
    // 省略
  };

初始化渲染的时候vm._vnode还是null, 这个时候走vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */); 更新的时候走vm.$el = vm.__patch__(prevVnode, vnode); 这个时候的prevVnode是根节点的vnode

__patch__方法实际调用的是 patch 方法

return function patch (oldVnode, vnode, hydrating, removeOnly) {
    if (isUndef(vnode)) {
      if (isDef(oldVnode)) { invokeDestroyHook(oldVnode); }
      return
    }

    var isInitialPatch = false;
    var insertedVnodeQueue = [];

    if (isUndef(oldVnode)) {
      // empty mount (likely as component), create new root element
      isInitialPatch = true;
      createElm(vnode, insertedVnodeQueue);
    } else {
      // 初始化渲染时oldVnode是根节点,真实的dom 所以是true
      var isRealElement = isDef(oldVnode.nodeType);
      if (!isRealElement && sameVnode(oldVnode, vnode)) {
        // patch existing root node
        patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly);
      } else {
          // 省略
          // either not server-rendered, or hydration failed.
          // create an empty node and replace it
          oldVnode = emptyNodeAt(oldVnode);
        }

        // replacing existing element
        var oldElm = oldVnode.elm;
        var parentElm = nodeOps.parentNode(oldElm);

        // create new node
        createElm(
          vnode,
          insertedVnodeQueue,
          // extremely rare edge case: do not insert if old element is in a
          // leaving transition. Only happens when combining transition +
          // keep-alive + HOCs. (#4590)
          oldElm._leaveCb ? null : parentElm,
          nodeOps.nextSibling(oldElm)
        );

        // 省略
      }
    }

初始化渲染时oldVnode是根节点,是真实的dom,vnode 并没有nodeType这个字段。 所以isRealElement为true,此时直接走createElm方法,而更新的时候isRealElement为false。这个时候会先判断是否是相同节点,才进行patchVnode。

if (!isRealElement && sameVnode(oldVnode, vnode)) {
    // patch existing root node
    patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly);
}
// 主要是判断key 和 tag 是否相同
function sameVnode (a, b) {
  return (
    a.key === b.key && (
      (
        a.tag === b.tag &&
        a.isComment === b.isComment &&
        isDef(a.data) === isDef(b.data) &&
        sameInputType(a, b)
      ) || (
        isTrue(a.isAsyncPlaceholder) &&
        a.asyncFactory === b.asyncFactory &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}

如果是不同的元素(tag不同)则认为是一个新的vnode,进入 createElm方法。遵循了两个不同类型的元素将产生不同的树的原则,只要发现两个元素的类型不同,直接删除旧的并创建一个新的,而不是去递归比较。参考:

Reconciliation – Reactreactjs.org图标

如果是相同节点就需要比较并更新差异, 并且递归比较children vnode。我们来看patchVnode

function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
    if (oldVnode === vnode) {
      return
    }

    var elm = vnode.elm = oldVnode.elm;
    // 省略
    var oldCh = oldVnode.children;
    var ch = vnode.children;
    if (isDef(data) && isPatchable(vnode)) {
      for (i = 0; i < cbs.update.length; ++i) { cbs.update[i](oldVnode, vnode); }
      if (isDef(i = data.hook) && isDef(i = i.update)) { i(oldVnode, vnode); }
    }
    if (isUndef(vnode.text)) {
      if (isDef(oldCh) && isDef(ch)) {
        if (oldCh !== ch) { updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly); }
      } else if (isDef(ch)) {
        if (isDef(oldVnode.text)) { nodeOps.setTextContent(elm, ''); }
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
      } else if (isDef(oldCh)) {
        removeVnodes(elm, oldCh, 0, oldCh.length - 1);
      } else if (isDef(oldVnode.text)) {
        nodeOps.setTextContent(elm, '');
      }
    } else if (oldVnode.text !== vnode.text) {
      nodeOps.setTextContent(elm, vnode.text);
    }
    if (isDef(data)) {
      if (isDef(i = data.hook) && isDef(i = i.postpatch)) { i(oldVnode, vnode); }
    }
  }

isPatchable方法主要判断vnode 的tag 是否是undefined。文本节点的tag就是undefined、 Text 元素直接调用nodeOps.setTextContent(elm, vnode.text) 更新text内容。

// 这里的 cbs.update 是更新属性的,从方法名也可以看出来。
if (isDef(data) && isPatchable(vnode)) {
    for (i = 0; i < cbs.update.length; ++i) { cbs.update[i](oldVnode, vnode); }
    if (isDef(i = data.hook) && isDef(i = i.update)) { i(oldVnode, vnode); }
}

更新完当前的vnode节点后,要对子节点也更新

if (isDef(oldCh) && isDef(ch)) {
        if (oldCh !== ch) { updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly); }
      } else if (isDef(ch)) {
        if (isDef(oldVnode.text)) { nodeOps.setTextContent(elm, ''); }
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
      } else if (isDef(oldCh)) {
        removeVnodes(elm, oldCh, 0, oldCh.length - 1);
      } else if (isDef(oldVnode.text)) {
        nodeOps.setTextContent(elm, '');
      }

这个时候有三种情况:

  • 有新的子节点无旧的子节点,调用 addVnodes 新建
  • 有旧的子节点无新的子节点,调用removeVnodes 删除
  • 新旧子节点都存在,调用updateChildren 更新。

所以最核心的部分是updateChildren 方法, 也是 diff 算法的核心部分。

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    var oldStartIdx = 0;
    var newStartIdx = 0;
    var oldEndIdx = oldCh.length - 1;
    var oldStartVnode = oldCh[0];
    var oldEndVnode = oldCh[oldEndIdx];
    var newEndIdx = newCh.length - 1;
    var newStartVnode = newCh[0];
    var newEndVnode = newCh[newEndIdx];
    var oldKeyToIdx, idxInOld, vnodeToMove, refElm;

    // removeOnly is a special flag used only by <transition-group>
    // to ensure removed elements stay in correct relative positions
    // during leaving transitions
    var canMove = !removeOnly;

    {
      checkDuplicateKeys(newCh);
    }

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx]; // Vnode has been moved left
      } else if (isUndef(oldEndVnode)) {
        oldEndVnode = oldCh[--oldEndIdx];
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
        oldStartVnode = oldCh[++oldStartIdx];
        newStartVnode = newCh[++newStartIdx];
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
        oldEndVnode = oldCh[--oldEndIdx];
        newEndVnode = newCh[--newEndIdx];
      } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
        canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm));
        oldStartVnode = oldCh[++oldStartIdx];
        newEndVnode = newCh[--newEndIdx];
      } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
        oldEndVnode = oldCh[--oldEndIdx];
        newStartVnode = newCh[++newStartIdx];
      } else {
        if (isUndef(oldKeyToIdx)) { oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx); }
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
        if (isUndef(idxInOld)) { // New element
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx);
        } else {
          vnodeToMove = oldCh[idxInOld];
          if (sameVnode(vnodeToMove, newStartVnode)) {
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue);
            oldCh[idxInOld] = undefined;
            canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm);
          } else {
            // same key but different element. treat as new element
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx);
          }
        }
        newStartVnode = newCh[++newStartIdx];
      }
    }
    if (oldStartIdx > oldEndIdx) {
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
    } else if (newStartIdx > newEndIdx) {
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
    }
  }

咋一看方法非常的长,不要被吓到。updateChildren 中分别定义了新旧子节点头尾的四个指针,并定义了一系列的判断条件进行指针的移动。

我们来看一个简单的场景,假设我们把2与3的vnode调换,但1的vnode 并没有发生变化,此时我们会进入

if (sameVnode(oldStartVnode, newStartVnode)) {
    patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
    oldStartVnode = oldCh[++oldStartIdx];
    newStartVnode = newCh[++newStartIdx];
}

这个时候会对1的vnode 递归比较。当更新完1节点之后。指针会向前移动一位。此时的结果是

此时oldStartVnode 与 newEndVnode相同,会进入

if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
        canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm));
        oldStartVnode = oldCh[++oldStartIdx];
        newEndVnode = newCh[--newEndIdx];
      }

先更新节点2,更新完后指针的方位是

这个时候oldStartVnode 与newEndVnode 都指向了3的节点,进入

if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
        canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm));
        oldStartVnode = oldCh[++oldStartIdx];
        newEndVnode = newCh[--newEndIdx];
      }

依旧先更新3的节点,然后跳出循环。这样就完成了vnode的更新。由于指针仅仅对数组进行了一次遍历,因此时间复杂度是 O(n)。最后,调用createElm 方法,根据 vnode 创建真实的DOM。


(c)、关于Key的思考以及性能优化

key: 默认用“就地复用”策略,这个默认的模式是高效的,因为默认的时候key是undefined, 所以只要tag 相同 sameVnode 就为true, 通过 patchVnode 进行diff,有条件的更新以以获取性能上的提升。

那自然我们就想到了,v-for 列表的时候, key 使用 index vs id 的场景下, 如果确定html结构保持一致,只更新一些属性和内容,使用index 代替id,可以dom复用 (因为index 前后都是一致的),提高性能。我简单做了个demo 测试 发现使用index 的更新效率是id 的一倍以上,而且对于越复杂的dom结构,这个结果就更明显。

key : index
key: id

编辑于 2018-12-31

文章被以下专栏收录