深入理解React源码 - 界面更新(DOM树)IX

2,661 阅读10分钟

上次我们走完了从setState()到单个DOM更新的流程,并简单的分析了diffing算法。这个分析显然不够,因为diffing算法从一开始就是为应对更加复杂的情况而设计的。

本篇我们会用两个例子进一步考察diffing算法。更具体点,我们来看这个算法如何处理DOM树的结构变化。

注意,本文用到的例子借鉴了官方文档。这个文档也对diffing算法做了比较上层的描述。所以如果您对本文的主题不太了解,也可以先读一下。

Photo by Luke Leung on Unsplash

例子一,diff无key的节点

class App extends Component {
  constructor(props) {
    super(props);
    this.state = {
      data : ['one', 'two'],
    };

    this.timer = setInterval(
      () => this.tick(),
      5000
    );
  }

  tick() {
    this.setState({
      data: ['new', 'one', 'two'],
    });
  }

  render() {
    return (
      <ul>
      {
        this.state.data.map(function(val, i) {
          return <li>{ val }</li>;
        })
      }
      </ul>
    );
  }
}

export default App;

babeled后的版本:

render() {
  return React.createElement(
    'ul',
    null,
    this.state.data.map(function (val, i) {
      return React.createElement(
        'li',
        null,
        ' ',
        val,
        ' '
      );
    })
  );
}

新,旧虚DOM树

我们已经知道了render()函数会生成如下的虚DOM树{第四篇}(通过嵌套调用React.createElement()

我们先忽略各ReactElement对应的ReactDOMComponent

上面的图给出了在初始渲染过程中生成的旧虚DOM树。和{上一篇}一样,setState()会在5秒钟后触发,然后开始界面更新流程,

Figure-I

对这个数据结构有个印象,我们跳过和{上一篇}重复的逻辑过程(大部分是transaction开始前的),直接进入diffing算法,

_updateRenderedComponent: function (transaction, context) {
  var prevComponentInstance = this._renderedComponent; // scr: -> 1)

  // scr: ------------------------------------------------------> 2)
  var prevRenderedElement = prevComponentInstance._currentElement;

  // scr: create a new DOM tree
  var nextRenderedElement = this._renderValidatedComponent();

  var debugID = 0;

  // scr: DEV code
...

  if (shouldUpdateReactComponent( // scr: ----------------------> 3)
      prevRenderedElement,
      nextRenderedElement)
  ) {
    ReactReconciler.receiveComponent( // scr: ------------------> 5)
      prevComponentInstance,
      nextRenderedElement,
      transaction,
      this._processChildContext(context)
    );
  } else { // scr: ---------------------------------------------> 4)
  // scr: code that is not applicable this time
...
  }
},

ReactCompositeComponent@renderers/shared/stack/reconciler/ReactCompositeComponent.js
步骤1-5)和{上一篇}也一样,这里就不赘述了

正如{第四篇}讨论过,这个算法始于用ReactCompositeComponent._renderValidatedComponent() 构件新的DOM树(Figure-I右边的那个)。

跟节点一样,那就diff子节点吧

既然新旧ReactElement[1] 的类型相同(“ul”),逻辑和{上一篇}一样走向5)。

receiveComponent: function (nextElement, transaction, context) {
  var prevElement = this._currentElement;
  this._currentElement = nextElement;
  this.updateComponent(transaction,
                       prevElement,
                       nextElement,
                       context);
},

updateComponent: function(
  transaction,
  prevElement,
  nextElement,
  context
) {
  var lastProps = prevElement.props;
  var nextProps = this._currentElement.props;

// scr: code that is not applicable this time
...

// scr: ------------------------------------------------------> 1)
  this._updateDOMProperties(lastProps, nextProps, transaction);

// scr: ------------------------------------------------------> 2)
  this._updateDOMChildren(lastProps, nextProps, transaction, context);

// scr: code that is not applicable this time
...
},

ReactDOMComponent@renderers/dom/shared/ReactDOMComponent.js

在{上一篇}中,第1)步更新DOM节点的各属性;而2)则更新它的内容。

但是对于跟节点(ReactElement[1])来说,因为新旧版本的内容完全一致,所以整个ReactDOMComponent.updateComponent()调用只做了一件事, 遍历和更新它的直接子节点。

我把{上一篇}的静态调用栈扩展一下,以便引出下面的讨论:

...                                                            ___
ReactReconciler.receiveComponent()      <----------------|      |
  |-ReactDOMComponent.receiveComponent()                 |      |
    |-this.updateComponent()                             |      |
      |-this._updateDOMProperties()                      |      |
        |-CSSPropertyOperations.setValueForStyles()      |      |
      |-this._updateDOMChildren()                        |      |
        |-this.updateTextContent()                       |   diffing
        |-this._updateDOMChildren() (the focus this time)|      |
          |-this.updateChildren()                        |      |
          |=this._updateChildren()                       |      |
            |-this._reconcilerUpdateChildren()           |      |
              |-this.flattenChildren()                   |      |
              |-ReactChildReconciler.updateChildren() ---|      |
                                                               ---

之前提到过,这个遍历(子节点)是从ReactDOMComponent._updateDOMChildren()方法开始的。在下面的小节中,我们会一次一个函数的走到栈底。

ReactDOMComponent._updateDOMChildren() — Start recursing direct children

_updateDOMChildren: function (
  lastProps, nextProps, transaction, context
) {
  // scr: code for content updating
...
  var nextChildren = nextContent != null ? null : nextProps.children;

  if (lastChildren != null && nextChildren == null) { // scr: --> 1)
    this.updateChildren(null, transaction, context);
  } else if (lastHasContentOrHtml && !nextHasContentOrHtml) {
    // scr: code for content updating
...
  }

  if (nextContent != null) {
    if (lastContent !== nextContent) {
      // scr: code for content updating
...
    } else if (nextHtml != null) {
      // scr: code for content updating
...
    } else if (nextChildren != null) {
      // scr: DEV code
...
      // scr: --------------------------------------------------> 2)
      this.updateChildren(nextChildren, transaction, context);
  }
},

ReactDOMComponent@renderers/dom/shared/ReactDOMComponent.js 
我把内容更新相关的代码折叠了,以便集中注意在子DOM遍历上

1)如果条件满足(lastChildren != null && nextChildren == null)则删除所有子节点;

2)开始遍历。

ReactMultiChild.updateChildren() I —真正干活的

过了几个别名(和其他一些各种划水的)函数后,这是第一个真正在做事的函数。I)它遍历虚DOM子节点,比较新旧版本并且更新ReactDOMComponent(我们简称这个为虚DOM操作);II) 将记录的操作固化到真实DOM中。

这里ReactMultiChild.updateChildren()的角色有点类似初次渲染中 的mountComponentIntoNode()第二篇

updateChildren: function (
  nextNestedChildrenElements,
  transaction,
  context
) {
  // Hook used by React ART
  this._updateChildren(nextNestedChildrenElements, transaction, context);
},

_updateChildren: function (
  nextNestedChildrenElements,
  transaction,
  context
) {
  var prevChildren = this._renderedChildren;
  var removedNodes = {};
  var mountImages = [];
  var nextChildren = this._reconcilerUpdateChildren( // scr: ---> I)
                       prevChildren, // scr: ------------------>  i)
                       nextNestedChildrenElements, // scr: ----> ii)
                       mountImages,
                       removedNodes,
                       transaction,
                       context
                     );

  if (!nextChildren && !prevChildren) {
    return;
  }

  // scr: -----------------------------------------------------> II)
  var updates = null;
  var name;
  // `nextIndex` will increment for each child in `nextChildren`, but
  // `lastIndex` will be the last index visited in `prevChildren`.
  var nextIndex = 0;
  var lastIndex = 0;
  
  // `nextMountIndex` will increment for each newly mounted child.
  var nextMountIndex = 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) {
        // Update `lastIndex` before `_mountIndex` gets unset by unmounting.
        lastIndex = Math.max(prevChild._mountIndex, lastIndex);
        // The `removedNodes` loop below will actually remove the child.
      }

      // The child must be instantiated before it's mounted.
      updates = enqueue(updates, this._mountChildAtIndex(nextChild, mountImages[nextMountIndex], lastPlacedNode, nextIndex, transaction, context));
      nextMountIndex++;
    }

    nextIndex++;
    lastPlacedNode = ReactReconciler.getHostNode(nextChild);
  }

  // Remove children that are no longer present.
  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;

  // scr: DEV code
...

ReactMultiChild@renderers/shared/stack/reconciler/ReactMultiChild.js

我们先来看虚DOM操作,I)。这里注意这个操作的担当函数ReactDOMComponent._reconcilerUpdateChildren()的两个参数,i)prevChildren,其实是在初次渲染中赋值给ReactDOMComponent._renderedChildren 的子ReactDOMComponents{第五篇};ii)nextNestedChildrenElements则是从ReactDOMComponent._updateDOMChildren()传过来的nextProps.children

ReactDOMComponent._reconcilerUpdateChildren() — Virtual DOM operations

_reconcilerUpdateChildren: function (
  prevChildren,
  nextNestedChildrenElements,
  mountImages,
  removedNodes,
  transaction,
  context
) {
  var nextChildren;
  var selfDebugID = 0;
  // scr: DEV code
...

  nextChildren = flattenChildren(      // scr: -----------------> 1)
                   nextNestedChildrenElements,
                   selfDebugID);

  ReactChildReconciler.updateChildren( // scr: -----------------> 2)
                   prevChildren,
                   nextChildren,
                   mountImages,
                   removedNodes,
                   transaction,
                   this,
                   this._hostContainerInfo,
                   context, selfDebugID);

  return nextChildren;
},

ReactMultiChild@renderers/shared/stack/reconciler/ReactMultiChild.js

在第2)步,遍历并更新子虚DOM节点之前,这个方法1)调用

flattenChildren() —将 ReactElement 数组转成对象

function flattenChildren(children, selfDebugID) {
  if (children == null) {
    return children;
  }
  var result = {};

  // scr: DEV code
...

  {
    traverseAllChildren(children, flattenSingleChildIntoContext, result);
  }

  return result;
}

flattenChildren@shared/utils/flattenChildren.js

这里我们需要注意穿给traverseAllChildren()的回调

function flattenSingleChildIntoContext(
  traverseContext,
  child,
  name,
  selfDebugID
) {
  // We found a component instance.
  if (traverseContext && typeof traverseContext === 'object') {
    var result = traverseContext;
    var keyUnique = result[name] === undefined;

    // scr: DEV code
...

    if (keyUnique && child != null) {
      result[name] = child;
    }
  }
}

flattenSingleChildIntoContext@shared/utils/flattenChildren.js 

,把单个的ReactElement 和它的 对应key(name)设置给目标对象中。下面我们继续看traverseAllChildren() 的函数体,来了解key是怎么生成的。

...
var SEPARATOR = '.';
...

function traverseAllChildren(children, callback, traverseContext) {
  if (children == null) {
    return 0;
  }

  return traverseAllChildrenImpl(children, '', callback, traverseContext);
}

traverseAllChildren@shared/utils/traverseAllChildren.js

function traverseAllChildrenImpl(
  children,
  nameSoFar, // scr: -------- ''
  callback,
  traverseContext
) {
  var type = typeof children;

  if (type === 'undefined' || type === 'boolean') {
    // All of the above are perceived as null.
    children = null;
  }

  if (children === null || type === 'string' || type === 'number' ||
type === 'object' && children.?typeof === REACT_ELEMENT_TYPE) {
    callback(traverseContext, children,
    // If it's the only child, treat the name as if it was wrapped in an array
    // so that it's consistent if the number of children grows.
    nameSoFar === '' ? SEPARATOR + getComponentKey(children, 0) : nameSoFar);
    return 1;
  }

  var child;
  var nextName;
  var subtreeCount = 0; // Count of children found in the current subtree.
  var nextNamePrefix = nameSoFar === '' ? SEPARATOR : nameSoFar + SUBSEPARATOR;

  if (Array.isArray(children)) {
    for (var i = 0; i < children.length; i++) {
      child = children[i];
      nextName = nextNamePrefix + getComponentKey(child, i);
      subtreeCount += traverseAllChildrenImpl(child, nextName, callback, traverseContext);
    }
  } else {
    // scr: code that is not applicable
...
  }

  return subtreeCount;
}

traverseAllChildrenImpl@shared/utils/traverseAllChildren.js

这个函数我们在{第五篇}中讨论过,(我把它拷过来)

当它第一次被调用时(这时参数children的类型是array),它会对这个数组中所有的ReactElement再递归调一次自己;当它被后续调用时(参数childrenReactElement),它会调用前面提到的回调函数。这个回调函数内部再......

“把单个的ReactElement 和它的 对应key(name)设置给目标对象中”(上面一段)。

而key则是由getComponentKey() 负责生成,

function getComponentKey(component, index) {
  if (component && typeof component === 'object' && component.key != null) {
    // Explicit key
    return KeyEscapeUtils.escape(component.key);
  }
  // Implicit key determined by the index in the set
  return index.toString(36);
}

getComponentKey@shared/utils/traverseAllChildren.js

逻辑比较直观,直接用数组的下标来作为key(index.toString(36))。也是因为我们现在讨论的是无key节点的情况。

当前子调用栈,

...
flattenChildren()
  |-traverseAllChildren()
    |-traverseAllChildrenImpl()
      |↻traverseAllChildrenImpl() // for direct each child
        |-flattenSingleChildIntoContext()

现在我们就有一个包含键值对的对象nextChildren 可以被用于“diff”prevChildren 了。

ReactChildReconciler.updateChildren() — 操作虚DOM树

updateChildren: function(
  prevChildren,
  nextChildren,
  mountImages,
  removedNodes,
  transaction,
  hostParent,
  hostContainerInfo,
  context,
  selfDebugID, // 0 in production and for roots
) {
  if (!nextChildren && !prevChildren) {
    return;
  }

  var name;
  var prevChild;

  for (name in nextChildren) {
    if (!nextChildren.hasOwnProperty(name)) {
      continue;
    }

    prevChild = prevChildren && prevChildren[name];
    var prevElement = prevChild && prevChild._currentElement;
    var nextElement = nextChildren[name];
    if ( // scr: -----------------------------------------------> 1)
      prevChild != null &&
      shouldUpdateReactComponent(prevElement, nextElement)
    ) {
      ReactReconciler.receiveComponent(
        prevChild,
        nextElement,
        transaction,
        context,
      );
      nextChildren[name] = prevChild; // scr: --------------> end 1)
    } else {
      if (prevChild) { // scr: ---------------------------------> 2)
        removedNodes[name] = ReactReconciler.getHostNode(prevChild);
        ReactReconciler.unmountComponent(prevChild, false);
      }

      // The child must be instantiated before it's mounted.
      var nextChildInstance = instantiateReactComponent(nextElement, true);
      nextChildren[name] = nextChildInstance;
      // Creating mount image now ensures refs are resolved in right order
      // (see https://github.com/facebook/react/pull/7101 for explanation).
      var nextChildMountImage = ReactReconciler.mountComponent(
        nextChildInstance,
        transaction,
        hostParent,
        hostContainerInfo,
        context,
        selfDebugID,
      );

      mountImages.push(nextChildMountImage);
    } // scr: ----------------------------------------------> end 2)
  }

  // scr: ------------------------------------------------------> 3)
  // Unmount children that are no longer present.
  for (name in prevChildren) {
    if (
      prevChildren.hasOwnProperty(name) &&
      !(nextChildren && nextChildren.hasOwnProperty(name))
    ) {
      prevChild = prevChildren[name];
      removedNodes[name] = ReactReconciler.getHostNode(prevChild);
      ReactReconciler.unmountComponent(prevChild, false);
    }
  } // scr: ------------------------------------------------> end 3)
},
所谓“更新”无非就是增,删,改

这个函数会遍历nextChildren, 然后

1)如果“pre”和“next”的类型相同(由shouldUpdateReactComponent()判定),遍历回ReactReconciler.receiveComponent()并更新子节点 {上一篇}的内容,具体来说,这个分支会更新

以及

2)如果“pre”和“next”的类型不同,或者对应的“pre”根本就不存在,则重新加载(remount)虚DOM节点;

在{第五篇}中,这个虚DOM对应的li节点是在加载(mounting)过程中被创建的

3)如果“next”中不存在“pre”,则卸载(un-mount)“pre”虚DOM。

更新节点内容的操作是封装在遍历ReactReconciler.receiveComponent()中的{上一篇},然而对实际DOM树的操作则需要等处理逻辑返回至ReactMultiChild.updateChildren()

ReactMultiChild.updateChildren() II — matipulate real DOMs

...
  var updates = null;
  var name;
  // `nextIndex` will increment for each child in `nextChildren`, but
  // `lastIndex` will be the last index visited in `prevChildren`.
  var nextIndex = 0;
  var lastIndex = 0;
  
  // `nextMountIndex` will increment for each newly mounted child.
  var nextMountIndex = 0;
  var lastPlacedNode = null;
  for (name in nextChildren) {
    if (!nextChildren.hasOwnProperty(name)) {
      continue;
    }

    // scr: --------------------------------------------------> III)
    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; // scr: ---------> end III)
    } else { // scr: ------------------------------------------> IV)
      if (prevChild) {
        // Update `lastIndex` before `_mountIndex` gets unset by unmounting.
        lastIndex = Math.max(prevChild._mountIndex, lastIndex);
        // The `removedNodes` loop below will actually remove the child.
      }

      // The child must be instantiated before it's mounted.
      updates = enqueue(
                  updates,
                  this._mountChildAtIndex(
                    nextChild,
                    mountImages[nextMountIndex],
                    lastPlacedNode,
                    nextIndex,
                    transaction,
                    context
                  )
                );

      nextMountIndex++;
    } // scr: ---------------------------------------------> end IV)

    nextIndex++;
    lastPlacedNode = ReactReconciler.getHostNode(nextChild);
  }

// Remove children that are no longer present.
  for (name in removedNodes) { // scr: -------------------------> V)
    if (removedNodes.hasOwnProperty(name)) {
      updates = enqueue(
                  updates,
                  this._unmountChild(
                    prevChildren[name],
                    removedNodes[name]
                  )
                );
    }
  } // scr: ------------------------------------------------> end V)

  if (updates) {
    processQueue(this, updates); // scr: ----------------------> VI)
  }

  this._renderedChildren = nextChildren;

  // scr: DEV code
...

ReactMultiChild@renderers/shared/stack/reconciler/ReactMultiChild.js

这个逻辑块会遍历nextChildren,然后根据情况

III) 标记节点的位置变化;

IV)标记新增节点;

V)标记删除节点;

VI)将更新固化到真实的DOM树中{上一篇}。

这里生效的分支是IV),将ReactElement[4] 对应的节点添加到DOM树中。

_mountChildAtIndex: function (
  child,
  mountImage,
  afterNode,
  index,
  transaction,
  context
) {
  child._mountIndex = index;
  return this.createChild(child, afterNode, mountImage);
},

createChild: function (child, afterNode, mountImage) {
  return makeInsertMarkup(mountImage, afterNode, child._mountIndex);
},

function makeInsertMarkup(markup, afterNode, toIndex) {
  // NOTE: Null values reduce hidden classes.
  return {
    type: 'INSERT_MARKUP',
    content: markup,
    fromIndex: null,
    fromNode: null,
    toIndex: toIndex,
    afterNode: afterNode
  };
}

ReactMultiChild@renderers/shared/stack/reconciler/ReactMultiChild.js

然后进入VI)

processUpdates: function(parentNode, updates) {
  // scr: DEV code
...

for (var k = 0; k < updates.length; k++) {
    var update = updates[k];
    switch (update.type) {
      case 'INSERT_MARKUP':
          insertLazyTreeChildAt(
            parentNode,
            update.content,
            getNodeAfter(parentNode, update.afterNode),
          );
          break;
      // scr: code that is not applicable
...

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

DOMChildrenOperations@renderers/dom/client/utils/DOMChildrenOperations.js

所以这次的底牌是DOMLazyTree.insertTreeBefore()。从{第三篇}中,我们可以知道这个函数会调用HTML DOM API

parentNode.insertBefore(tree.node, referenceNode); 

那含key节点有什么区别呢?

Diffing含key节点

例子二,

...
render() {
  return (
    <ul>
    {
      this.state.data.map(function(val, i) {
        return <li key={val}>{ val }</li>;
      })
    }
    </ul>
  );
}
...

整个逻辑在ReactDOMComponent.flattenChildren()之前,是和无key节点一模一样的。而在这个将数组转化为包含键值对信息的对象的函数中,节点所包含的key会被显示使用,

function getComponentKey(component, index) {
  if (component && typeof component === 'object' && 
      component.key != null) {
    // Explicit key
    return KeyEscapeUtils.escape(component.key);
  }
  // code that is not applicable
...
}

getComponentKey@shared/utils/traverseAllChildren.js 

所以在接下来的比较中(ReactChildReconciler.updateChildren()),新旧两颗树则通过key而对其了,

从而ReactReconciler.receiveComponent()的递归调用中不会对(key:onetwo)生成任何实际的DOM操作, 因为它们的内容是相同的。所以这次唯一需要的DOM操作是

parentNode.insertBefore(tree.node, referenceNode);

用来将key为new的节点添加到DOM树中。

比之前省了两次。

打包带走

class App extends Component {
  constructor(props) {
    super(props);
    this.state = {
      mutate: false,
    };

    this.timer = setInterval(
      () => this.tick(),
      5000
    );
  }

  tick() {
    this.setState({
      mutate: true,
    });
  }

  render() {
    return (
      <ul>
        { this.state.mutate &&
        <li>New</li>
        }
        <li>One</li>
        <li>Two</li>
      </ul>
    );
  }
}

export default App;

上面的代码也会改变DOM树的结构,为啥这里不需要给节点添加key呢?答案在代码里。


结语(对,暂时就写到这里了)

有目的的读代码就像查找算法,如果数组排过序了,理论上会比无序数组快O(n) - O(log n)。这个连载旨在将React代码理顺,以便下次你读它时能享受到O(log n)的速度。

好了,这次先写到这。如果您觉得这篇不错,可以点赞或关注这个专栏。

感谢阅读!👋