React源码分析 - Diff算法

5,498 阅读7分钟

在《React源码分析 - 组件更新与事务》中的流程图的最后:

diff update

蓝色框框的部分分别是Diff算法的核心代码updateChildren以及processUpdates,通过Diff算法获取了组件更新的updates队列之后一次性进行更新。

Diff算法的代码(先别着急下面会具体解释算法的主要步骤):

    _updateChildren: function (nextNestedChildrenElements, transaction, context) {
      var prevChildren = this._renderedChildren;
      var removedNodes = {};
      var nextChildren = this._reconcilerUpdateChildren(prevChildren, nextNestedChildrenElements, removedNodes, transaction, context);
      if (!nextChildren && !prevChildren) {
        return;
      }
      var updates = null;
      var name;
      var lastIndex = 0;
      var nextIndex = 0;
      var lastPlacedNode = null;
      for (name in nextChildren) {
        if (!nextChildren.hasOwnProperty(name)) {
          continue;
        }
        var prevChild = prevChildren && prevChildren[name];
        var nextChild = nextChildren[name];
        if (prevChild === nextChild) {
          updates = enqueue(updates, this.moveChild(prevChild, lastPlacedNode, nextIndex, lastIndex));
          lastIndex = Math.max(prevChild._mountIndex, lastIndex);
          prevChild._mountIndex = nextIndex;
        } else {
          if (prevChild) {
            lastIndex = Math.max(prevChild._mountIndex, lastIndex);
          }
          updates = enqueue(updates, this._mountChildAtIndex(nextChild, lastPlacedNode, nextIndex, transaction, context));
        }
        nextIndex++;
        lastPlacedNode = ReactReconciler.getNativeNode(nextChild);
      }
      for (name in removedNodes) {
        if (removedNodes.hasOwnProperty(name)) {
          updates = enqueue(updates, this._unmountChild(prevChildren[name], removedNodes[name]));
        }
      }
      if (updates) {
        processQueue(this, updates);
      }
      this._renderedChildren = nextChildren;
    }

《深入React技术栈》这本书对Diff算法的解释比较好。其实只要记住几个原则以及在具体的计算updates队列的时候的算法优化的点就好了。

传统的diff算法的复杂度是O(n^3),想要具体的了解可以去看"A Survey on Tree Edit Distance and Related Problems"

这种复杂度在实际中应用会爆炸的,虽然现在的电脑的CPU很强,但一个页面也不能这样任性~。

对此React的做法是给出合理的假设和方法来让整个diff过程合理简化。

  • DOM节点跨层级的移动操作的场景是很少见的,可以忽略不计。(合理,可以通过组件的设计来尽量保证DOM结构的稳定,必要时可以通过CSS的方法来进行DOM在展示上的调整,因为创建、删除以及移动DOM的操作是能少则少,浏览器的每个DOM节点都是一个大对象,有着很多的方法和属性
  • 同一类的两个组件将会生成相似的树形结构,不同类的两个组件将会生成不同的树形结构。(合理,本身组件就有提高页面的可复用性的作用,也就是将结构功能类似的页面结构(或者说相似的DOM树形结构)抽象成一类组件,所以合理的组件抽象就应该满足这条假设)
  • 对于同一层级的一组节点可以通过设置唯一的key来进行区分,从而做到diff的进一步优化。(这个不算是一个假设而是一个提高性能的方法)
  • 对于同一类的两个组件,有可能其Virtual Dom是没有任何变化的。因此React允许开发者通过shouldComponentUpdate()来判断组件是否需要进行diff算法分析。(合理,开发者本身对页面的理解来进一步进行diff的优化,当然这有可能会因为开发者错误的使用shouldComponentUpdate()判断错误了是否需要更新,从而得到了错误的结果.....但是这怪sei ???,写bug了还不老实)

基于上面的几条,在具体的Diff过程中React只进行分层比较,新旧的树之间只比较同一个层次的节点。节点的操作分为3种:插入、移动和删除。

节点移动操作判断的过程,引用《深入React技术栈》中的话:

首先对新集合的节点进行循环遍历,for (name in nextChildren),通过唯一 key 可以判断新老集合中是否存在相同的节点,if (prevChild === nextChild),如果存在相同节点,则进行移动操作,但在移动前需要将当前节点在老集合中的位置与 lastIndex 进行比较,if (child._mountIndex < lastIndex),则进行节点移动操作,否则不执行该操作。这是一种顺序优化手段,lastIndex 一直在更新,表示访问过的节点在老集合中最右的位置(即最大的位置),如果新集合中当前访问的节点比 lastIndex 大,说明当前访问节点在老集合中就比上一个节点位置靠后,则该节点不会影响其他节点的位置,因此不用添加到差异队列中,即不执行移动操作,只有当访问的节点比 lastIndex 小时,才需要进行移动操作。

需要注意的是”这是一种顺序优化手段,lastIndex 一直在更新,表示访问过的节点在老集合中最右的位置(即最大的位置),如果新集合中当前访问的节点比 lastIndex 大,说明当前访问节点在老集合中就比上一个节点位置靠后,则该节点不会影响其他节点的位置,因此不用添加到差异队列中,即不执行移动操作“这句话。意思是如果一个节点在旧集合中的位置已经在你之前进行判断的最后一个节点的背后,那么这个节点已经在被diff过的节点的后面了和之前的diff过的节点在顺序上就已经是正确的了,不需要移动了,反之的节点需要被移动。

另外需要知道的是如果没有给key赋值,React会默认使用的是遍历过程中的 index 值。这里的index值指的是节点遍历的顺序号,效果等同于有些小伙伴用列表数组的index来当做key。这样其实是不好的,因为节点的key和节点的位置有关系和节点本身没关系,也就是如果我一个列表有10个节点,按照遍历的顺序key为1到10,然后我在列表的最开始增加了一个节点,这个时候按照列表遍历的顺序来设置key,则原来的10个节点的key都变了,而且新旧节点的key错误的对上了,要知道key在React中时对一个组件的身份识别的标示,错误或者重复的key会造成React错误的结果......so.......key需要是一个和节点本身有联系的唯一标示。

react的作者之一Paul O’Shannessy有提到:

Key is not really about performance, it’s more about identity (which in turn leads to better performance). Randomly assigned and changing values do not form an identity

你可能会问,上面的diff算法的源码部分没看到key啊,恩,其实每个component的key会变成nextChildren&prevChildren对象中的name对应的value是component,另外在_reconcilerUpdateChildren中的shouldUpdateReactComponent组件的key也有使用到。

对于新增和删除节点的操作简单来说:

  • 新增节点就是创建新的节点放在顺序遍历到的位置上。
  • 删除节点则是在该层次遍历结束后,对旧集合进行循环遍历,判断是否在新集合中没有,没有的话,则删除节点。

当然上面说的移动、新增和删除节点的操作,不是马上执行的,而是收集到updates数组中,然后用processUpdates方法一次性进行具体的DOM的的更新。

  processUpdates: function (parentNode, updates) {
    for (var k = 0; k < updates.length; k++) {
      var update = updates[k];
      switch (update.type) {
        case ReactMultiChildUpdateTypes.INSERT_MARKUP:
          insertLazyTreeChildAt(parentNode, update.content, getNodeAfter(parentNode, update.afterNode));
          break;
        case ReactMultiChildUpdateTypes.MOVE_EXISTING:
          moveChild(parentNode, update.fromNode, getNodeAfter(parentNode, update.afterNode));
          break;
        case ReactMultiChildUpdateTypes.SET_MARKUP:
          setInnerHTML(parentNode, update.content);
          break;
        case ReactMultiChildUpdateTypes.TEXT_CONTENT:
          setTextContent(parentNode, update.content);
          break;
        case ReactMultiChildUpdateTypes.REMOVE_NODE:
          removeChild(parentNode, update.fromNode);
          break;
      }
    }
  }

其中的节点的具体的操作就是到具体的浏览器的DOM的节点的操作了,举个栗子。

function insertLazyTreeChildAt(parentNode, childTree, referenceNode) {
  DOMLazyTree.insertTreeBefore(parentNode, childTree, referenceNode);
}

var insertTreeBefore = createMicrosoftUnsafeLocalFunction(function (parentNode, tree, referenceNode) {
  if (tree.node.nodeType === 11) {
    insertTreeChildren(tree);
    parentNode.insertBefore(tree.node, referenceNode);
  } else {
    parentNode.insertBefore(tree.node, referenceNode);
    insertTreeChildren(tree);
  }
});

Node.insertBefore()就是浏览器DOM操作的API了。

想要跟着具体的Diff的过程来理解的话,推荐单步调试或者看《深入React技术栈》中的栗子,这里我就不画了.....画图很累的.....网上也非常多类似的搜一下就好了。

本文对key的具体的使用的部分有待进一步深入。【TBD】

参考资料: