Skip to content
登录后刷题更便捷

虚拟 DOM 原理

难度:

什么是 Virtual DOM

Virtual DOM 是对 DOM 的抽象,本质上是 JavaScript 对象,这个对象就是更加轻量级的对 DOM 的描述.

为什么需要 Virtual DOM

既然我们已经有了 DOM,为什么还需要额外加一层抽象?

首先,我们都知道在前端性能优化的一个秘诀就是尽可能少地操作 DOM,不仅仅是 DOM 相对较慢,更因为频繁变动 DOM 会造成浏览器的回流或者重回,这些都是性能的杀手,因此我们需要这一层抽象,在 patch 过程中尽可能地一次性将差异更新到 DOM 中,这样保证了 DOM 不会出现性能很差的情况.

其次, 现代前端框架的一个基本要求就是无须手动操作 DOM,一方面是因为手动操作 DOM 无法保证程序性能,多人协作的项目中如果 review 不严格,可能会有开发者写出性能较低的代码,另一方面更重要的是省略手动 DOM 操作可以大大提高开发效率.

最后,也是 Virtual DOM 最初的目的,就是更好的跨平台,比如 Node.js 就没有 DOM,如果想实现 SSR(服务端渲染),那么一个方式就是借助 Virtual DOM,因为 Virtual DOM 本身是 JavaScript 对象.

Virtual DOM 的关键要素

Virtual DOM 的创建

我们已经知道 Virtual DOM 是对真实 DOM 的抽象,根据不同的需求我们可以做出不同的抽象,比如snabbdom.js的抽象方式是这样的.

当然,snabbdom.js 由于是面向生产环境的库,所以做了大量的抽象各种,我们由于仅仅作为教程理解,因此采用最简单的抽象方法:

js
{
  type, // String,DOM 节点的类型,如 'div'
    data, // Object,包括 props,style等等 DOM 节点的各种属性
    children; // Array,子节点
}

在明确了我们抽象的 Virtual DOM 构造之后,我们就需要一个函数来创建 Virtual DOM.

js
/**
 * 生成 vnode
 * @param  {String} type     类型,如 'div'
 * @param  {String} key      key vnode的唯一id
 * @param  {Object} data     data,包括属性,事件等等
 * @param  {Array} children  子 vnode
 * @param  {String} text     文本
 * @param  {Element} elm     对应的 dom
 * @return {Object}          vnode
 */
function vnode(type, key, data, children, text, elm) {
  const element = {
    __type: VNODE_TYPE,
    type,
    key,
    data,
    children,
    text,
    elm,
  };

  return element;
}

这个函数很简单,接受一定的参数,再根据这些参数返回一个对象,这个对象就是 DOM 的抽象.

Virtual DOM Tree 的创建

上面我们已经声明了一个 vnode 函数用于单个 Virtual DOM 的创建工作,但是我们都知道 DOM 其实是一个 Tree,我们接下来要做的就是声明一个函数用于创建 DOM Tree 的抽象 -- Virtual DOM Tree.

js
function h(type, config, ...children) {
  const props = {};

  let key = null;

  // 获取 key,填充 props 对象
  if (config != null) {
    if (hasValidKey(config)) {
      key = "" + config.key;
    }

    for (let propName in config) {
      if (hasOwnProperty.call(config, propName) && !RESERVED_PROPS[propName]) {
        props[propName] = config[propName];
      }
    }
  }

  return vnode(
    type,
    key,
    props,
    flattenArray(children).map((c) => {
      return isPrimitive(c)
        ? vnode(undefined, undefined, undefined, undefined, c)
        : c;
    })
  );
}

Virtual DOM 的更新

Virtual DOM 归根到底是 JavaScript 对象,我们得想办法将 Virtual DOM 与真实的 DOM 对应起来,也就是说,需要我们声明一个函数,此函数可以将 vnode 转化为真实 DOM.

js
function createElm(vnode, insertedVnodeQueue) {
  let data = vnode.data;
  let i;
  // 省略 hook 调用
  let children = vnode.children;
  let type = vnode.type;

  /// 根据 type 来分别生成 DOM
  // 处理 comment
  if (type === "comment") {
    if (vnode.text == null) {
      vnode.text = "";
    }
    vnode.elm = api.createComment(vnode.text);
  }
  // 处理其它 type
  else if (type) {
    const elm = (vnode.elm = data.ns
      ? api.createElementNS(data.ns, type)
      : api.createElement(type));

    // 调用 create hook
    for (let i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode);

    // 分别处理 children 和 text。
    // 这里隐含一个逻辑:vnode 的 children 和 text 不会/应该同时存在。
    if (isArray(children)) {
      // 递归 children,保证 vnode tree 中每个 vnode 都有自己对应的 dom;
      // 即构建 vnode tree 对应的 dom tree。
      children.forEach((ch) => {
        ch && api.appendChild(elm, createElm(ch, insertedVnodeQueue));
      });
    } else if (isPrimitive(vnode.text)) {
      api.appendChild(elm, api.createTextNode(vnode.text));
    }
    // 调用 create hook;为 insert hook 填充 insertedVnodeQueue。
    i = vnode.data.hook;
    if (i) {
      i.create && i.create(emptyNode, vnode);
      i.insert && insertedVnodeQueue.push(vnode);
    }
  }
  // 处理 text(text的 type 是空)
  else {
    vnode.elm = api.createTextNode(vnode.text);
  }

  return vnode.elm;
}

上述函数其实工作很简单,就是根据 type 生成对应的 DOM,把 data 里定义的 各种属性设置到 DOM 上.

Virtual DOM 的 diff

Virtual DOM 的 diff 才是整个 Virtual DOM 中最难理解也最核心的部分,diff 的目的就是比较新旧 Virtual DOM Tree 找出差异并更新.

可见 diff 是直接影响 Virtual DOM 性能的关键部分.

要比较 Virtual DOM Tree 的差异,理论上的时间复杂度高达 O(n^3),这是一个奇高无比的时间复杂度,很显然选择这种低效的算法是无法满足我们对程序性能的基本要求的.

好在我们实际开发中,很少会出现跨层级的 DOM 变更,通常情况下的 DOM 变更是同级的,因此在现代的各种 Virtual DOM 库都是只比较同级差异,在这种情况下我们的时间复杂度是 O(n).

那么我们接下来需要实现一个函数,进行具体的 diff 运算,函数 updateChildren 的核心算法如下:

js
// 遍历 oldCh 和 newCh 来比较和更新
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      // 1⃣️ 首先检查 4 种情况,保证 oldStart/oldEnd/newStart/newEnd
      // 这 4 个 vnode 非空,左侧的 vnode 为空就右移下标,右侧的 vnode 为空就左移 下标。
      if (oldStartVnode == null) {
        oldStartVnode = oldCh[++oldStartIdx]
      } else if (oldEndVnode == null) {
        oldEndVnode = oldCh[--oldEndIdx]
      } else if (newStartVnode == null) {
        newStartVnode = newCh[++newStartIdx]
      } else if (newEndVnode == null) {
        newEndVnode = newCh[--newEndIdx]
      }
      /**
       * 2⃣️ 然后 oldStartVnode/oldEndVnode/newStartVnode/newEndVnode 两两比较,
       * 对有相同 vnode 的 4 种情况执行对应的 patch 逻辑。
       * - 如果同 start 或同 end 的两个 vnode 是相同的(情况 1 和 2),
       *   说明不用移动实际 dom,直接更新 dom 属性/children 即可;
       * - 如果 start 和 end 两个 vnode 相同(情况 3 和 4),
       *   那说明发生了 vnode 的移动,同理我们也要移动 dom。
       */
      // 1. 如果 oldStartVnode 和 newStartVnode 相同(key相同),执行 patch
      else if (isSameVnode(oldStartVnode, newStartVnode)) {
        // 不需要移动 dom
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      }
      // 2. 如果 oldEndVnode 和 newEndVnode 相同,执行 patch
      else if (isSameVnode(oldEndVnode, newEndVnode)) {
        // 不需要移动 dom
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      }
      // 3. 如果 oldStartVnode 和 newEndVnode 相同,执行 patch
      else if (isSameVnode(oldStartVnode, newEndVnode)) {
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
        // 把获得更新后的 (oldStartVnode/newEndVnode) 的 dom 右移,移动到
        // oldEndVnode 对应的 dom 的右边。为什么这么右移?
        // (1)oldStartVnode 和 newEndVnode 相同,显然是 vnode 右移了。
        // (2)若 while 循环刚开始,那移到 oldEndVnode.elm 右边就是最右边,是合理的;
        // (3)若循环不是刚开始,因为比较过程是两头向中间,那么两头的 dom 的位置已经是
        //     合理的了,移动到 oldEndVnode.elm 右边是正确的位置;
        // (4)记住,oldVnode 和 vnode 是相同的才 patch,且 oldVnode 自己对应的 dom
        //     总是已经存在的,vnode 的 dom 是不存在的,直接复用 oldVnode 对应的 dom。
        api.insertBefore(parentElm, oldStartVnode.elm, api.nextSibling(oldEndVnode.elm))
        oldStartVnode = oldCh[++oldStartIdx]
        newEndVnode = newCh[--newEndIdx]
      }
      // 4. 如果 oldEndVnode 和 newStartVnode 相同,执行 patch
      else if (isSameVnode(oldEndVnode, newStartVnode)) {
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
        // 这里是左移更新后的 dom,原因参考上面的右移。
        api.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      }

      // 3⃣️ 最后一种情况:4 个 vnode 都不相同,那么我们就要
      // 1. 从 oldCh 数组建立 key --> index 的 map。
      // 2. 只处理 newStartVnode (简化逻辑,有循环我们最终还是会处理到所有 vnode),
      //    以它的 key 从上面的 map 里拿到 index;
      // 3. 如果 index 存在,那么说明有对应的 old vnode,patch 就好了;
      // 4. 如果 index 不存在,那么说明 newStartVnode 是全新的 vnode,直接
      //    创建对应的 dom 并插入。
      else {
        // 如果 oldKeyToIdx 不存在,创建 old children 中 vnode 的 key 到 index 的
        // 映射,方便我们之后通过 key 去拿下标。
        if (oldKeyToIdx === undefined) {
          oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        }
        // 尝试通过 newStartVnode 的 key 去拿下标
        idxInOld = oldKeyToIdx[newStartVnode.key]
        // 下标不存在,说明 newStartVnode 是全新的 vnode。
        if (idxInOld == null) {
          // 那么为 newStartVnode 创建 dom 并插入到 oldStartVnode.elm 的前面。
          api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm)
          newStartVnode = newCh[++newStartIdx]
        }
        // 下标存在,说明 old children 中有相同 key 的 vnode,
        else {
          elmToMove = oldCh[idxInOld]
          // 如果 type 不同,没办法,只能创建新 dom;
          if (elmToMove.type !== newStartVnode.type) {
            api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm)
          }
          // type 相同(且key相同),那么说明是相同的 vnode,执行 patch。
          else {
            patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
            oldCh[idxInOld] = undefined
            api.insertBefore(parentElm, elmToMove.elm, oldStartVnode.elm)
          }
          newStartVnode = newCh[++newStartIdx]
        }
      }
    }

    // 上面的循环结束后(循环条件有两个),处理可能的未处理到的 vnode。
    // 如果是 new vnodes 里有未处理的(oldStartIdx > oldEndIdx
    // 说明 old vnodes 先处理完毕)
    if (oldStartIdx > oldEndIdx) {
      before = newCh[newEndIdx+1] == null ? null : newCh[newEndIdx+1].elm
      addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    }
    // 相反,如果 old vnodes 有未处理的,删除 (为处理 vnodes 对应的) 多余的 dom。
    else if (newStartIdx > newEndIdx) {
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
    }
  }

我们可以假设有旧的 Vnode 数组和新的 Vnode 数组这两个数组,而且有四个变量充当指针分别指到两个数组的头尾.

重复下面的对比过程,直到两个数组中任一数组的头指针超过尾指针,循环结束 :

  • 头头对比: 对比两个数组的头部,如果找到,把新节点 patch 到旧节点,头指针后移
  • 尾尾对比: 对比两个数组的尾部,如果找到,把新节点 patch 到旧节点,尾指针前移
  • 旧尾新头对比: 交叉对比,旧尾新头,如果找到,把新节点 patch 到旧节点,旧尾指针前移,新头指针后移
  • 旧头新尾对比: 交叉对比,旧头新尾,如果找到,把新节点 patch 到旧节点,新尾指针前移,旧头指针后移
  • 利用 key 对比: 用新指针对应节点的 key 去旧数组寻找对应的节点,这里分三种情况,当没有对应的 key,那么创建新的节点,如果有 key 并且是相同的节点,把新节点 patch 到旧节点,如果有 key 但是不是相同的节点,则创建新节点

我们假设有新旧两个数组:

  • 旧数组: [1, 2, 3, 4, 5]
  • 新数组: [1, 4, 6, 1000, 100, 5]

首先我们进行头头对比,新旧数组的头部都是1,因此将双方的头部指针后移.

我们继续头头对比,但是2 !== 4导致对比失败,我进入尾尾对比,5 === 5,那么尾部指针则可前移.

现在进入新的循环,头头对比2 !== 4,尾尾对比4 !== 100,此时进入交叉对比,先进行旧尾新头对比,即4 === 4,旧尾前移且新头后移.

接着再进入一个轮新的循环,头头对比2 !== 6,尾尾对比3 !== 100,交叉对比2 != 100 3 != 6,四种对比方式全部不符合,如果这个时候需要通过 key 去对比,然后将新头指针后移

继续重复上述对比的循环方式直至任一数组的头指针超过尾指针,循环结束.

在上述循环结束后,两个数组中可能存在未遍历完的情况: 循环结束后,

  • 先对比旧数组的头尾指针,如果旧数组遍历完了(可能新数组没遍历完,有漏添加的问题),添加新数组中漏掉的节点

  • 再对比新数组的头尾指针,如果新数组遍历完了(可能旧数组没遍历完,有漏删除的问题),删除旧数组中漏掉的节点

Virtual DOM 的优化

上一节我们的 Virtual DOM 实现是参考了 snabbdom.js 的实现,当然 Vue.js 也同样参考了 snabbdom.js,我们省略了大量边缘状态和 svg 等相关的代码,仅仅实现了其核心部分.

snabbdom.js 已经是社区内主流的 Virtual DOM 实现了,vue 2.0 阶段与 snabbdom.js 一样都采用了上面讲解的「双端比较算法」,那么有没有一些优化方案可以使其更快?

其实,社区内有更快的算法,例如 inferno.js 就号称最快 react-like 框架(虽然 inferno.js 性能强悍的原因不仅仅是算法,但是其 diff 算法的确是目前最快的),而 vue 3.0 就会借鉴 inferno.js 的算法进行优化.

我们可以等到 Vue 3.0 发布后再一探究竟,具体的优化思想可以先参考diff 算法原理概述,其中一个核心的思想就是利用 LIS(最长递增子序列)的思想做动态规划,找到最小的移动次数.

例如以下两个新旧数组,React 的算法会把 a, b, c 移动到他们的相应的位置 + 1 共三步操作,而 inferno.js 则是直接将 d 移动到最前端这一步操作.

js
 * A: [a b c d]
 * B: [d a b c]

参考文章:

内容仅供参考,难免有不恰当的地方,如果有问题欢迎及时反馈
部分内容来自网络,如果不慎侵犯您的权益,请联系我们,以便及时删除侵权内容