面试官: 你对虚拟DOM原理的理解?

本文首发于微信公众号「程序员面试官」前端

什么是Virtual DOM

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

2019-07-27-17-02-23

为何须要Virtual DOM

既然咱们已经有了DOM,为何还须要额外加一层抽象?node

首先,咱们都知道在前端性能优化的一个秘诀就是尽量少地操做DOM,不只仅是DOM相对较慢,更由于频繁变更DOM会形成浏览器的回流或者重回,这些都是性能的杀手,所以咱们须要这一层抽象,在patch过程当中尽量地一次性将差别更新到DOM中,这样保证了DOM不会出现性能不好的状况.react

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

最后,也是Virtual DOM最初的目的,就是更好的跨平台,好比Node.js就没有DOM,若是想实现SSR(服务端渲染),那么一个方式就是借助Virtual DOM,由于Virtual DOM自己是JavaScript对象.程序员

Virtual DOM的关键要素

Virtual DOM的建立

咱们已经知道Virtual DOM是对真实DOM的抽象,根据不一样的需求咱们能够作出不一样的抽象,好比snabbdom.js的抽象方式是这样的.github

2019-07-28-00-19-08

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

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

在明确了咱们抽象的Virtual DOM构造以后,咱们就须要一个函数来建立Virtual DOM.算法

/** * 生成 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的抽象.api

Virtual DOM Tree的建立

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

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.

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).

2019-07-29-15-12-28

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

// 遍历 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去对比,而后将新头指针后移

所有不符合靠key对比

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

2019-07-29-19-06-41

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

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

    添加遗漏节点

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

    删除冗余节点

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移动到最前端这一步操做.

* A: [a b c d]
 * B: [d a b c]
复制代码

参考文章:


相关文章
相关标签/搜索