上次我们走完了从setState()
到单个DOM更新的流程,并简单的分析了diffing算法。这个分析显然不够,因为diffing算法从一开始就是为应对更加复杂的情况而设计的。
本篇我们会用两个例子进一步考察diffing算法。更具体点,我们来看这个算法如何处理DOM树的结构变化。
注意,本文用到的例子借鉴了官方文档。这个文档也对diffing算法做了比较上层的描述。所以如果您对本文的主题不太了解,也可以先读一下。
例子一,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秒钟后触发,然后开始界面更新流程,
对这个数据结构有个印象,我们跳过和{上一篇}重复的逻辑过程(大部分是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
的子ReactDOMComponent
s{第五篇};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
再递归调一次自己;当它被后续调用时(参数children
是ReactElement
),它会调用前面提到的回调函数。这个回调函数内部再......
“把单个的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:one
和 two
)生成任何实际的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)
的速度。
好了,这次先写到这。如果您觉得这篇不错,可以点赞或关注这个专栏。
感谢阅读!👋