【算法剖析】Virtual DOM

Virtual DOM 的优点

  1. 能够缓存dom操做,按批执行,减小dom操做。
  2. 能够知道DOM的具体更新位置,作到局部刷新。

Virtual dom至关于框架API与运行环境之间的中间层,将框架渲染与DOM API进行解耦,增长了跨平台能力。(能够将virtual dom映射为DOM的步骤,改成映射到其余的执行环境,好比安卓、IOS)javascript

Virtual DOM 的步骤

  1. 设计js数据结构来表示DOM节点:html

    function VNode(tagName, props, children, key) {
      this.tagName = tagName
      this.props = props
      this.children = children
      this.key = key
    }
  2. 实现一个方法,可以从VNode生成真正的DOM tree(VNode对应根节点):vue

    function render({tag, props, children, key}) {
      // 经过 tag 建立节点
      let el = document.createElement(tag)
      // 设置节点属性
      for (const key in props) {
        if (props.hasOwnProperty(key)) {
          const value = props[key]
          el.setAttribute(key, value)
        }
      }
      if (key) {
        el.setAttribute('key', key)
      }
      // 递归建立子节点
      if (children) {
        children.forEach(element => {
          let child
          if (element instanceof VNode) {
            child = this._createElement(
              element.tag,
              element.props,
              element.children,
              element.key
            )
          } else {
            child = document.createTextNode(element)
          }
          el.appendChild(child)
        })
      }
      return el
    }
  3. 找到一个方法,根据data model生成vDOM tree:
    clipboard.png

    这个映射是由用户来定义的(通常经过template),因此这个方法通常是经过编译template来获得。java

  4. 实现vdom的diff算法,计算两棵树的差别。
  5. 实现patch算法,将差别应用到真正的DOM tree上,使view(DOM)与新 vdom(data model)保持一致。

第4步和第5步的2个函数能够合并。找出新vdom和DOM tree之间的差别,同时更新DOM tree来消除差别。git

Virtual DOM 管理视图更新的流程

首次渲染:
github

状态更新:根据model渲染新的vdom tree,与旧的vdom tree对比,计算出须要的更新。
算法

若是选择合并diff和patch算法,渲染出新vdom之后,将新的vdom tree与真实的DOM tree对比,同时更新DOM tree,使view(DOM)与新 vdom(data model)保持一致。segmentfault

diff算法的实现

diff的算法有不少种实现(见下面的参考资料),目的都是计算出须要的更新步骤,以便应用到真实的DOM上。
各类vdom tree diff算法之间的主要差别在于diff子节点列表的算法(也就是下面的listDiff)。把握各类listDiff算法的关键在于,数组

  1. 在diff以前,旧vdom tree和新vdom tree的根节点做为参数传入。
  2. 开始对2棵树同时进行深度优先遍历。这是特殊的深度优先遍历,每次同时访问2个节点用于对比:旧vdom的节点(如下称为oldNode)和新vdom的对应节点(如下称为newNode)。缓存

    1. 若是newNode.tag === oldNode.tag && newNode.key === oldNode.key(key能够都为undefined),将它们视为同一个元素在不一样时刻的状态。要分别diff它们的属性和子节点。

      1. diffProps(oldNode, newNode),检测节点上的属性是否发生了增长、删除、修改,这些修改应该记录为patch(后面讨论patch)。
      2. diffChildren(oldNode.children, newNode.children),检测子节点(数组)是否发生了变化,这些修改应该记录为patch。此外,diffChildren将会递归调用diffTree,来检测子树的变化。

        1. 要检测子节点数组的变化,即须要一个算法来找出:oldNode.children数组如何经过 增长、删除、移动 节点,变成newNode.children。咱们把这种算法称为listDiff:

          1. 检测删除的节点:遍历oldNode.children,对于每一个child,查找newNode.children中是否有相同key的节点(用map数据结构,查找的时间为log(n))。若是不存在,说明这个是被删除的节点,要输出删除操做,并记录它在中间状态数组中的下标。

            • 中间状态数组:在listDiff算法开始的时候,中间状态数组就是oldNode.children。每检测出一个增长、删除、移动操做,都要对中间状态数组进行这个操做,中间状态数组跃迁到下一个状态。最后,中间状态数组跃迁变成了newNode.children。
            • 咱们每次检测出的操做都是要做用在中间状态数组上的。所以,在输出删除操做的时候,记录的下标是被删节点在中间状态数组中的下标。输出其余类型的操做也同理。
            • 基于中间状态数组输出操做的目的是:咱们能将这些操做相继执行,从oldNode.children获得newNode.children。这也是listDiff算法须要保证的语义。
            • 逆序遍历的技巧:若是你遍历oldNode.children的时候是按照下标顺序遍历的,你会发现,直接输出被删除节点在oldNode.children中的下标,是不符合上面所说的语义的。可是,若是你遍历oldNode.children的时候是按照下标逆序遍历的,直接输出被删除节点在oldNode.children中的下标就刚好符合语义。这是由于先删除数组后面的节点,不会影响数组前面的节点的下标。
          2. 检测增长和移动的节点:遍历newNode.children,对于每一个child,查找oldNode.children中是否存在相同key的节点。

            • 若是不存在,说明这个是被增长的节点,要输出这个节点,以及它被插入后在中间状态数组中的位置;
            • 若是存在,可是在newNode.children中的下标不等于在中间状态数组中的下标,说明这个是被移动的节点,要输出这个节点移动前和移动后的中间状态数组中的位置。

              • 遍历newNode.children的时候按照下标顺序遍历。直观上,中间状态数组从左往右被扫描和修正,被扫描过的节点一一匹配于newNode.children中的节点,就像一个分开的拉链被从左往右缓缓拉上同样。仔细思考一下,有这样的结论:节点被插入后在中间状态数组中的位置==节点在newNode.children中的位置(由于插入完成之后,中间状态数组的这个位置就不会再修改了),节点移动后在中间状态数组中的位置==节点在newNode.children中的位置(由于移动完成之后,中间状态数组的这个位置就不会再修改了)。
        2. listDiff完成之后,获得一个由增长、删除、移动组成的操做序列,能将oldNode.children变成newNode.children,要将这些修改记录为patch。
        3. 找到2个对应的子节点(一个在oldNode.children中,一个在newNode.children中,两个节点是同一个元素在不一样时刻的状态)来调用diffTree:

          1. 遍历oldNode.children,对于每个child,找出在newNode.children中有相同key的节点,这两个节点就是相互对应的节点。以这两个节点为参数调用diffTree。

            • 递归调用时,只对那些相互对应的2个节点递归调用diffTree,若是节点在另外一个vdom没有对应,则不会被递归遍历到。
            • 咱们只须要对同时存在于旧vdom和新vdom中的节点递归调用diffTree。假设一个节点不存在于旧vdom但在新vdom中,那么【它所在的整个子树】会在【某个祖先节点增长子节点的时候】被建立,这个新建立的子树确定是不须要更新的。不在旧vdom中也同理,详见patch的讨论。
    2. 若是newNode.tag !== oldNode.tag || newNode.key !== oldNode.key,说明newNode和oldNode根本不是同一个节点,所以直接替换(删除oldNode,增长newNode)。在遍历两个树的根节点的时候可能会出现这种状况,从这之后,若是2个节点不是同一个节点,那根本就不会对它们调用diffTree。

diffTree的基本代码以下(先不考虑patch):

function diffTree(oldTreeRootNode, newTreeRootNode) {
    diffProps(oldTreeRootNode, newTreeRootNode);
    // 旧树和新树中对应的节点结对返回
    const pairs = listDiff(oldTreeRootNode.children, newTreeRootNode.children);
    for (const [oldChild, newChild] of pairs) {
        diffTree(oldChild, newChild);
    }
}
上面的讨论没有考虑没有key的节点。能够将【旧list的无key节点】与【新list的无key节点】按出现顺序一一对应,视为同一个节点。

这个算法的实现能够参考参考资料1。这个实现仅用于理解中间状态diff算法的思想。

这个算法并非vue所使用的(见参考资料2)。这个算法仅用于理解diff的思想。

patches

patch 含义

patch的意思是“如何修改旧的vdom,将它变为新的vdom”。它是diff vdom最重要的输出,毕竟咱们diff vdom的目的就是要知道如何修改DOM。
patch的操做包括:

  1. 增长、删除、修改某个节点的属性。
  2. 增长、删除、移动某个节点的子节点。

    • 在实现patch的操做的时候要注意,“增长某个节点的子节点”的patch操做,意味着增长整个子树。删除、移动同理。

可见,任何patch的操做都和某个节点相关,而且这个节点一定在旧vdom和新vdom中都存在的。
反证法:假设某个patch的操做(设为patchA)做用的节点不存在于旧vdom中,说明这个节点或它的某个祖先节点是新增的节点,也就是说,一定有一个“增长某个节点的子节点”的patch操做(设为patchB)做用于一个祖先节点。既然patchB意味着增长整个子树,那么patchA根本就没有存在的必要,由于它所在的整个子树在patchB的时候就被已经正确建立了。

存储patches

由于每个patch操做都关联于一个已存在节点,因此咱们存储patch的方式是:为每一个旧vdom中的节点分配一个数组,这个数组包括了全部和这个节点有关的patch操做。所以最终的patches是一个二维数组。在第一个维度上,节点按照深度优先遍历的顺序排列,也就是第1行是根节点的patch操做,第2行是左子节点的patch操做,第3行是【左子节点的左子节点】的patch操做,以此类推。

应用patches

获得了patches之后,经过将patches中的操做应用于对应的DOM节点,咱们就能够更新DOM树,使得DOM树等价于新的vdom树。
更新的方法就是,对dom进行深度优先遍历,当前元素深度优先遍历的序号是n,那么patches[n]就是这个元素关联的patch操做,而后将这些操做依次应用于这个元素。
若是有一个子节点是被增长的,那么这个子节点下面的子树就能够被跳过了,由于这是按照新vdom建立的子树,不须要更新。

对patch的处理上, 参考资料5实现得比较清晰。

参考资料

  1. 中间状态 的diff算法: 与Angular的diff算法有一些相似,不过Angular的实现要高效不少(使用链表来加速插入删除),而且考虑得更加完备(多个节点有同一个key)。
  2. 双端对比 的diff算法: snabbdom使用了这个算法,Vue使用了snabbdom。
  3. 最长递增子序列(LIS) 的diff算法: inferno使用这个算法。
  4. 最长公共子序列(LCS) 的diff算法: petit-dom实现了这个算法。
  5. Virtual DOM 算法实现框架: 实现了完整的Virtual DOM工做流程,不过list-diff有点太简单了,以致于会忽略不少节点移动,而用插入/删除代替。建议用其它的diff算法来代替它的list-diff。
  6. 各类框架的状态-UI同步机制
相关文章
相关标签/搜索