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

448 阅读8分钟

Last time we went through the process from setState() to the updating of a single DOM. We also analyzed the diffing algorithm, which is far from complete as the algorithm is designed for tasks that are much more complex than updating a single DOM node.

This time we are going to use two examples to examine the diffing algorithm more in depth. More specific, we look at how the algorithm deals with a mutating DOM tree.

N.b., the examples used in this article are derived from the official document which also provides a high level description of the diffing algorithm. You might want to read it first if the topic does not seem very familiar.

Example 1., diffing of key-less nodes

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;

the babeled version of render(),

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

The old & new virtual DOM tree

We know the result virtual DOM tree of the render() method is {post four} (nested calling of React.createElement())

We ignore the ReactElement’s corresponding controllers (i.e., ReactDOMComponent) for simplicity.

The figure above gives the old virtual DOM tree that is generated by the initial rendering. As in {last post}, a setState() is fired after 5 seconds, which initiates the updating process,

Figure-IFigure-I

With this data structure in mind, we skip the logic process (mostly, before transaction) that is identical to {last post}, and move directly to the diffing algorithm,

_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

The steps 1–5) are also identical to {last post}.

, which starts by creating the new DOM tree (the right one in {Figure-I}) with ReactCompositeComponent._renderValidatedComponent(). {post four}

Root nodes are the identical, so “diff” their direct children

Since the types of ReactElement[1] are the same (“ul”), the logic goes to 5) as in {last post}.

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

in {last post} step 1) updates a DOM node properties; and 2) updates its content.

But for the the root node (ReactElement[1]) the only purpose of the whole ReactDOMComponent.updateComponent() method call is to recurse and update ReactElement[1]’s direct children because neither the node’s properties and its content are changed.

I also extend the static call stack from {last post} as a lead:

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

As mentioned before, the recursing starts from ReactDOMComponent._updateDOMChildren(). In the following sections, we will follow the hierarchy, one function a time, and go for the bottom of the stack.

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

I fold up the content updating related code so we can focus on DOM children recursing

1) remove the children only when necessary (lastChildren != null && nextChildren == null);

2) start the recursing.

ReactMultiChild.updateChildren() I — The actual work horse

After the methods that are either alias or those with very little (preprocessing) operations, we come to the work horse that I) recurses virtual DOM children, compares the new/old versions of them and modifies ReactDOMComponent’s accordingly (we name them virtual DOM operations for simplicity); and II) commits the operations to real DOMs.

the role of this ReactMultiChild.updateChildren() is similar to that of mountComponentIntoNode() in initial rendering {post two}

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

We firstly look at the virtual DOM operations, I). Note that the two input parameters of the responsible method ReactDOMComponent._reconcilerUpdateChildren() are i) prevChildren, i.e., ReactDOMComponent._renderedChildren which is set to an object of its sub-ReactDOMComponents in initial rendering {post five}; and ii) nextNestedChildrenElements, i.e., nextProps.children passed from ReactDOMComponent._updateDOMChildren().

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

Before 2) the virtual DOM can be traversed and compared, this method 1) calls

flattenChildren() — converts ReactElement array into an object (map)

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

Here we need to pay attention to the callback passed to 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

, which set individual ReactElement with its associated key (name) in an object (map). Next we look at the traverseAllChildren() method body, to see in particular how the keys are generated.

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

We have described this method in {post five},

when it is called the first time (and the type of children parameter is array), it calls itself for every ReactElement within the array; when it is called successively (children is ReactElement), invokes the aforementioned callback that…

“set individual ReactElement with its associated key (name) in an object” as mentioned soon before.

The keys are generated with 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

which basically uses the index of the array as the key in the object (index.toString(36)), in the case that the key is not explicitly set in “key-less nodes”.

The static (sub) call stack of flattenChildren(),

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

now we have an key-value object nextChildren to be “diffed” with prevChildren.

ReactChildReconciler.updateChildren() — manipulate the virtual DOM tree

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)
},

updating is nothing more than modifying, adding, and deleting

This method traverse the nextChildren, and

1) recurse back to the and ReactReconciler.receiveComponent() to modify the content of the associated DOM nodes as in {last post} if the types of the corresponding “pre” and “next” nodes are the same (judged by shouldUpdateReactComponent() {last post}), the logic branch of which applies to

and

as the comparison is based on the counterparts’ index (that is also key);

2) re-mount the virtual DOM if types of “pre” and “next” nodes are different, or the corresponding “pre” node simply does not exist;

As in {post five}, the virtual DOM’s corresponding li node has been created in the mounting process;

3) un-mount “pre” virtual DOM(s) if they do not exist in the “next” ones.

The content updating operations are encapsulated in the recursion of ReactReconciler.receiveComponent() {last post}, whilst the operations on real DOM tree are conducted when the logic processes back in 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

This logic block iterates the nextChildren, and when necessary, it

III) mark that a node’s position has changed;

IV) mark a newly added node;

V) mark a removed node;

VI) commit the changes to the DOM tree {last post}

The branch applies here is IV) that adds the ReactElement[4] associated node to the DOM tree.

_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

And in 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

So the last card in this stack is DOMLazyTree.insertTreeBefore(). We already know from {post three} that this method calls the HTML DOM API

parentNode.insertBefore(tree.node, referenceNode);

So what happens when

Diffing nodes with keys

Example 2.

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

The process logic are the same as in key-less nodes before ReactDOMComponent.flattenChildren(), in which the designated keys instead of the array index will be used to establish the key-value object,

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

So in ReactChildReconciler.updateChildren() the comparison of the two virtual DOM trees can be better aligned,

and the recursive ReactReconciler.receiveComponent() does not incur any DOM operations by comparing nodes (key: one and two) with same content , and only the necessary DOM operation, i.e.,

parentNode.insertBefore(tree.node, referenceNode);

is conducted for the node (key: new) in ReactMultiChild.updateChildren().

As a result, keys can spare some unnecessary DOM operations for mutating a DOM tree .

Take home

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;

The above code also changes the DOM tree structure. Can you answer why the keys are not required here?


Reading source code with a purpose is like searching an array, in which, theoretically, it is O(n) - O(log n) faster when the array has already been sorted. This series aims to sort out the React code base for you, so you will be able to enjoy the O(log n) whenever having a purpose(s).