前言
本次React源码参考版本为17.0.3
。这是React源码系列第八篇,建议初看源码的同学从第一篇开始看起,这样更有连贯性,下面有源码系列链接。
热身准备
背景
为了避免大量,频繁操作DOM,React
引入了虚拟DOM,在16
版本之后又用fiber
树替代了虚拟DOM树。但是当发生更新时,如何高效将老的树替换成新的树,这是个难题,按照一些通用的算法解决方案,即使使用最优的算法,算法的复杂度依然为O(n 3 )
,n
是元素的数量。
这样的结果不是React
想要的,基于这样的背景,React
团队有了更大胆的想法,既然替换树的代价这么大,那我选择重新建一棵树,没错,简单粗暴。
React
团队的做法
-
当对比两棵树时,首先会比较两棵树的根节点。如果根节点为不同类型的元素,
React
不会考虑替换根节点再往下递归比对,而是选择放弃这棵树,重新建一棵,在向下递归也是一样的,遇到节点类型不同,就会删除这个节点和下面的所有子节点,然后重新构建; -
当对比两个相同类型的组件元素时:
class
组件实例保持不变(所以不同的渲染时state
是一致的),仅更新组件实例的props
。- 函数组件会重新执行(所以需要hooks记录状态)。
-
当对比两个相同类型的
React
元素时,React
会保留DOM节点,仅比对及更新有改变的属性; -
引入
key
,开发者手动设置元素key
值来标记元素,React
基于key
对元素进行比对;
上面的几点就是React
的diff
算法的结果,好了,弄明白了React
的diff
算法都做了些什么,接下来看下React
的diff
算法是怎么做到这几点的。
beginWork阶段
对比不同类型的元素
代码是我删减后的伪代码,仅供参考。
if (child.elementType === element.type) {
deleteRemainingChildren(returnFiber, child.sibling);
// clone current fiber并替换pendingProps为当前的element.props
var _existing3 = useFiber(child, element.props);
_existing3.return = returnFiber;
return _existing3;
}
else {
// 删除之前的fiber
deleteRemainingChildren(returnFiber, child);
// 重新根据element创建fiber
var _created4 = createFiberFromElement(element, returnFiber.mode, lanes);
_created4.return = returnFiber;
return _created4;
}
从上面的代码,第一行的判断语句child.elementType === element.type
,当React
发现更新前后的元素类型不同时,根本不会考虑其他的,直接就把之前的fiber
节点给干掉了,不用它了,自己重新根据新的fiber
重新构建。
对比相同类型的元素
当两个元素类型相同时,React
出于性能的考量会考虑复用已有的fiber
,也就是保留DOM元素,但是会替换fiber.pendingProps
。因为我们的DOM元素的信息都记录在props
上,React
直接将新的props
替换老的props
。
if (child.elementType === element.type) {
deleteRemainingChildren(returnFiber, child.sibling);
// clone current fiber并替换pendingProps为当前的element.props
var _existing3 = useFiber(child, element.props);
_existing3.return = returnFiber;
return _existing3;
}
key的使用
很简单的代码,却有很大的功劳。React
会将新老节点的key
值进行比较来确定新老节点是不是对应关系,如果是就更新,不是就返回null
跳过。
那什么情况下可能会返回null
呢?
答案是:
- 插入新的节点;
- 删除了节点;
- 节点顺序有变动;
{
if (newChild.key === key) {
if (newChild.type === REACT_FRAGMENT_TYPE) {
return updateFragment(returnFiber, oldFiber, newChild.props.children, lanes, key);
}
return updateElement(returnFiber, oldFiber, newChild, lanes);
} else {
return null;
}
}
当newFiber
返回null
会进下面的代码,这时oldFiber
会通过下面的代码再次重置回上次的值,保证key
的有效性,然后会跳出和oldFiber
匹配key
的循环。
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber;
oldFiber = null;
}
if (newFiber === null) {
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
break;
}
在跳出循环后,会去将剩下没有匹配成功的oldFiber
以key
(没有key,会使用index)为键存储到Map
结构中
var existingChildren = mapRemainingChildren(returnFiber, oldFiber)
在接下来, React
会从没有匹配成功key
的地方遍历newFiber
数组,然后从oldFiber
的Map
结构中去匹配剩下的newFiber
,做到最大程度的复用,能匹配就匹配(匹配成功后,会从existingChildren
将其删除掉),不能匹配就是新增的,会创建一个对应的节点。
for (; newIdx < newChildren.length; newIdx++) {
var _newFiber2 = updateFromMap(existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes);
if (_newFiber2 !== null) {
if (shouldTrackSideEffects) {
if (_newFiber2.alternate !== null) {
existingChildren.delete(_newFiber2.key === null ? newIdx : _newFiber2.key);
}
}
lastPlacedIndex = placeChild(_newFiber2, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = _newFiber2;
} else {
previousNewFiber.sibling = _newFiber2;
}
previousNewFiber = _newFiber2;
}
}
子节点位置比较
当React
的一个父节点是相同类型元素,替换了了props
后,会继续往下递归,每个子节点的更新方式和父节点的一样:
- 比较元素类型是否相同;
- 替换
proprs
;
值得注意的是,当有多个子节点时,可能会有增删节点,或者调换节点顺序的情况,这时候就需要考虑这些子节点位置有没有变化。
function placeChild(newFiber, lastPlacedIndex, newIndex) {
newFiber.index = newIndex;
var current = newFiber.alternate;
if (current !== null) {
var oldIndex = current.index;
if (oldIndex < lastPlacedIndex) {
// 给fiber打上Placement标记,要移动
newFiber.flags = Placement;
return lastPlacedIndex;
} else {
// 和之前一样,在同一个位置
return oldIndex;
}
} else {
// 没有老节点的情况,说明这是一个插入操作
newFiber.flags = Placement;
return lastPlacedIndex;
}
}
子节点更新完毕
当子节点已经更新完了,如果还有老节点,会直接删除,不会考虑再做比对
if (newIdx === newChildren.length) {
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
completeWork阶段
之前的文章有讲过,在completeWork
阶段会有个向上查找的过程,如果是更新,它会创建一个更新队列updateQueue
,更新的内容就是新老props
进行diff
后的结果。这里进行diff
的代码实在太长,我精简了下
function diffProperties(domElement, tag, lastRawProps, nextRawProps, rootContainerElement) {
{
validatePropertiesInDevelopment(tag, nextRawProps);
}
var updatePayload = null;
var lastProps;
var nextProps;
switch (tag) {
case 'input':
lastProps = getHostProps(domElement, lastRawProps);
nextProps = getHostProps(domElement, nextRawProps);
updatePayload = [];
break;
case 'option':
... //和上面类似
break;
case 'select':
... //和上面类似
break;
case 'textarea':
... //和上面类似
break;
default:
lastProps = lastRawProps;
nextProps = nextRawProps;
break;
}
assertValidProps(tag, nextProps);
var propKey;
var styleName;
var styleUpdates = null;
for (propKey in lastProps) {
...
}
for (propKey in nextProps) {
...
}
if (styleUpdates) {
{
validateShorthandPropertyCollisionInDev(styleUpdates, nextProps[STYLE]);
}
(updatePayload = updatePayload || []).push(STYLE, styleUpdates);
}
return updatePayload;
}
在上面代码中,有区分表单和一般的DOM元素,因为表单可以保存用户交互状态,React
需要将表单的状态组装到props
中,在下面主要是遍历lastProps
和nextProps
,区分prop
是style, dangerouslySetInnerHTML, children
和其他一些属性进行不同的处理,主要是比较lastProps
和nextProps
,将nextProps
没有而lastProps
上有的重置,nextProps
有且和lastProps
不同的加到更新里去。
总结
通篇看下来,是不是感觉React
的diff
算法也不是特别复杂?
事实就是这样的,大道至简。
做下总结:
diff
算法发生在两个阶段,分别是beginWork
和completeWork
阶段。
在beginWork
阶段,会比对更新前后的元素类型:
- 如果不同就删除更新前的元素及其子元素,然后基于新的元素重新构建;
- 如果相同,会复用DOM元素,仅替换
props
;
当父元素类型相同且有多个子节点时,会有使用key
的场景,React
会基于key
尽可能的复用oldFiber
并保证较好的性能。
在completeWork
阶段,React
会将新老节点的props
进行一次diff
,通过遍历比对,找出其中的变化添加到更新队列中,在commit
阶段完成更新渲染。
想了个口诀: 求同不存异,相同就比key,最后比props
看完这篇文章, 我们可以弄明白下面这几个问题:
React
为什么要做diff
算法?React
的diff
算法是怎么实现的?React
的diff
算法发生在哪个阶段?React
的元素的key
是做什么的?React
是怎么通过key
实现高效diff
的?
系列文章安排:
- React源码系列之一:Fiber;
- React源码系列之二:React的渲染机制;
- React源码系列之三:hooks之useState,useReducer;
- React源码系列之四:hooks之useEffect;
- React源码系列之五:hooks之useCallback,useMemo;
- React源码系列之六:hooks之useContext;
- React源码系列之七:React的合成事件;
- React源码系列之八:React的diff算法;
- React源码系列之九:React的更新机制;
- React源码系列之十:Concurrent Mode;
参考: