在 render 阶段的 beginWork 方法中,通过调用
instance.render()-ClassComponentComponent(props, secondArg)-FunctionComponent
方法拿到最新的 JSX 数据。根据最新的 JSX 类型是 单节点类型还是多节点类型进行进行不同的 diff 处理。
对应的源码如下所示:
// ReactFiberBeginWork.js
function beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
// ...
switch (workInProgress.tag) {
// ...
case FunctionComponent: {
const Component = workInProgress.type;
const unresolvedProps = workInProgress.pendingProps;
const resolvedProps =
workInProgress.elementType === Component
? unresolvedProps
: resolveDefaultProps(Component, unresolvedProps);
return updateFunctionComponent(
current,
workInProgress,
Component,
resolvedProps,
renderLanes,
);
}
case ClassComponent: {
const Component = workInProgress.type;
const unresolvedProps = workInProgress.pendingProps;
const resolvedProps =
workInProgress.elementType === Component
? unresolvedProps
: resolveDefaultProps(Component, unresolvedProps);
return updateClassComponent(
current,
workInProgress,
Component,
resolvedProps,
renderLanes,
);
}
// ...
}
}
不论节点类型是 FunctionComponent 还是 ClassComponent 或者是 HostComponent 等等,最后都会执行 reconcileChildFibers 方法来对新生成的 JSX 进行 diff 处理。
// ReactChildFiber.js
function reconcileChildFibers(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChild: any,
lanes: Lanes,
): Fiber | null {
// 处理单个节点
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
return placeSingleChild(
reconcileSingleElement(
returnFiber,
currentFirstChild,
newChild,
lanes,
),
);
}
// 处理同一层级多个节点
if (isArray(newChild)) {
return reconcileChildrenArray(
returnFiber,
currentFirstChild,
newChild,
lanes,
);
}
}
return deleteRemainingChildren(returnFiber, currentFirstChild);
}
注意:类型是单节点还是多节点,是根据最新生成的
JSX节点类型来判断的。
算法优化
我们知道,要完全对比两棵树,最小的算法复杂度是 O(n^3)。这意味着什么?
假如我们一棵树有 1000 个节点,那么我们要进行的比较的就会进行 1000000000 次。对于复杂页面来说,超过 1000 个节点非常容易。要进行如此庞大的计算肯定是无法接受的。
所以 React 使用 diff 算法对其进行了优化,将复杂度从 O(n^3) 降低到了 O(n)。刚才也说了,最小的复杂度是 O(n^3),如果要完全对比两棵树,是不可能将它优化到 O(n) 的。为了达到这样的优化,React 在以下方面做了一些限制:
限制一:只对同级元素进行diff。如果一个DOM元素在前后两次更新中跨越了层级,那么React不会尝试复用它。限制二:两个不同类型的元素会产生不同的树。如果元素由DIV变成P,React会销毁DIV及其子孙元素,并新建P及其子孙元素。限制三:开发者可以通过key来暗示哪些子元素在不同的渲染下能够保持稳定。
O(n^3)是怎么计算出来的可以参考以下链接: github.com/Advanced-Fr…
单节点 diff
生成的最新的 JSX 如果是单节点,就会进入 reconcileSingleElement 方法中。这个方法有以下几个判断:
key相同,type相同。复用当前节点,并标记删除当前节点的兄弟节点。key相同,type不同。删除当前节点及当前节点的兄弟节点。key不同。删除当前节点,继续对比最新的JSX的节点和当前节点的兄弟节点。
对应的源码如下所示:
// 单节点 diff
function reconcileSingleElement(
returnFiber: Fiber, // workInProgress
currentFirstChild: Fiber | null, // current.child
element: ReactElement, // 使用最新数据生成的 React element 元素
lanes: Lanes,
): Fiber {
// 获取 React element 元素上的 key 属性
const key = element.key;
// current 树上的 Fiber 节点
let child = currentFirstChild;
while (child !== null) {
// key 相同时
if (child.key === key) {
const elementType = element.type;
if (elementType === REACT_FRAGMENT_TYPE) {
// 如果当前的类型为 Fragment,删除这个节点的兄弟节点
if (child.tag === Fragment) {
// 前后都是 Fragment 类型,在父节点的 deletions 上添加标记当前节点的兄弟节点为删除
deleteRemainingChildren(returnFiber, child.sibling);
// 节点复用
const existing = useFiber(child, element.props.children);
existing.return = returnFiber;
return existing;
}
} else {
if (
child.elementType === elementType ||
// Keep this check inline so it only runs on the false path:
(__DEV__
? isCompatibleFamilyForHotReloading(child, element)
: false) ||
(typeof elementType === 'object' &&
elementType !== null &&
elementType.$$typeof === REACT_LAZY_TYPE &&
resolveLazy(elementType) === child.type)
) {
// 在父节点的 deletions 上添加标记当前节点的兄弟节点为删除
deleteRemainingChildren(returnFiber, child.sibling);
// 复用之前 current 树上相对应的 fiber,并使用最新的 props 更新 fiber 上的 pendingProps 属性
const existing = useFiber(child, element.props);
existing.ref = coerceRef(returnFiber, child, element);
existing.return = returnFiber;
return existing;
}
}
// key 相同,类型不同,删除当前节点
deleteRemainingChildren(returnFiber, child);
break;
} else {
// key 不同,直接删除节点
deleteChild(returnFiber, child);
}
child = child.sibling;
}
// currentFiber 没有内容时,走新建流程
if (element.type === REACT_FRAGMENT_TYPE) {
const created = createFiberFromFragment(
element.props.children,
returnFiber.mode,
lanes,
element.key,
);
created.return = returnFiber;
return created;
} else {
// 当 key 或者类型不相等时,会根据新创建的 React element 元素创建新的 Fiber 节点
const created = createFiberFromElement(element, returnFiber.mode, lanes);
created.ref = coerceRef(returnFiber, currentFirstChild, element);
created.return = returnFiber;
return created;
}
}
注意:这里的删除指的是在父节点的
deletions属性中添加当前要删除节点,起标记作用,并没有在这里做任何的删除操作。
多节点 diff
多节点类型的 diff 要比单节点 diff 复杂些。
在了解多节点 diff 前,首先思考一个问题,什么情况下会产生节点为 Array 的数据?
我们要明白一个本质,那就是 diff 其实是 diff 当前 workInProgress 的 child 节点。搞明白了这一点,想知道什么情况下会产生类型为 Array 的就容易了。
- 当前节点下有多个子节点的情况下。在当前节点进行
beginWork时,进行到reconcileChildFibers方法时会进行多节点diff。 - 当前节点类型为
Fragment并且Fragment下面有多个子节点时。进行到reconcileChildFibers方法时会进行多节点diff。
在 reconcileChildFibers 的开始,有这样一个判断和赋值:
function reconcileChildFibers(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChild: any,
lanes: Lanes,
): Fiber | null {
const isUnkeyedTopLevelFragment =
typeof newChild === 'object' &&
newChild !== null &&
newChild.type === REACT_FRAGMENT_TYPE &&
newChild.key === null;
if (isUnkeyedTopLevelFragment) {
// 将 Fragment 下面的 children 赋值给 newChild 变量
newChild = newChild.props.children;
}
// ...
}
在 reconcileChildFibers 方法中,首先会判断当前节点是不是 Fragment 并且 key 值为 null,如果满足条件,newChild 就赋值为 newChild.props.children,而 newChild.props.children 就是 Fragment 节点的子节点,如果 Fragment 下面有多个子节点,那对当前节点进行 diff 时也会进入多节点 diff。
有一个点需要注意:
参与比较的双方
oldFiber代表current fiberNode,其数据结构是链表,newChildren代表JSX对象,其数据结构是数组。由于oldFiber是链表,所以无法借助 “双指针” 从数组首尾同时遍历以提高效率。同样的道理,Vue的Diff算法中有一个步骤是 “倒叙遍历VNode数组”,在React中也是不适用的。
多节点的 diff 会经过三轮遍历。
-
第一轮遍历
key相同,type相同。可以复用。key相同,type不同。不可以复用,将oldFiber标记为deletion,新创建newFiber。key不同。退出第一轮循环。
-
第二轮遍历
oldFiber遍历完,newChildren未遍历完。进入第二轮遍历。- 将剩下的
newChildren标记为Placement。即插入Tag。
-
第三轮遍历
oldFiber和newChildren都未遍历完。- 进行节点移动或插入操作。
第一轮遍历
遍历 newChildren,将 newChildren[newIdx] 与 oldFiber 比较,判断是否可复用。
key相同,type相同。可以复用。
判断 key 相同时,执行 updateElement 对比类型。
// updateSlot
function updateSlot(
returnFiber: Fiber,
oldFiber: Fiber | null,
newChild: any,
lanes: Lanes,
): Fiber | null {
// ...
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE: {
// 判断 key 是否相等
if (newChild.key === key) {
// 对类型进行对比,类型相同则更新并且重用旧 Fiber,不相同则根据 React element 重新创建一个新的 Fiber
return updateElement(returnFiber, oldFiber, newChild, lanes);
} else {
return null;
}
}
}
// ...
}
对比类型,类型相同,复用节点。
function updateElement(
returnFiber: Fiber,
current: Fiber | null,
element: ReactElement,
lanes: Lanes,
): Fiber {
const elementType = element.type;
if (current !== null) {
// 类型相同,复用节点
if ( current.elementType === elementType) {
// Move based on index
const existing = useFiber(current, element.props);
existing.ref = coerceRef(returnFiber, current, element);
existing.return = returnFiber;
return existing;
}
}
}
key相同type不同导致不可复用,会将oldFiber在父节点的deletions属性中添加标记,继续第一轮循环。
key 相同,类型不同时,在 updateElement 方法中会执行
function updateElement(
returnFiber: Fiber,
current: Fiber | null,
element: ReactElement,
lanes: Lanes,
): Fiber {
// ...
// Insert
const created = createFiberFromElement(element, returnFiber.mode, lanes);
created.ref = coerceRef(returnFiber, current, element);
created.return = returnFiber;
return created;
}
调用 createFiberFromElement 新建一个 Fiber 节点并返回。然后在 reconcileChildrenArray 的第一次循环中,执行以下逻辑:
if (shouldTrackSideEffects) {
if (oldFiber && newFiber.alternate === null) {
deleteChild(returnFiber, oldFiber);
}
}
如果 oldFiber 存在,并且 newFiber 也有,但是 newFiber.alternate === null。这说明新的节点在旧的 Fiber 树中不存在,所以会执行 deleteChild(returnFiber, oldFiber); 将当前旧的节点在父节点的 deletions 属性中添加标记。
然后,执行
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx); // 标记节点插入
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
oldFiber = nextOldFiber;
在 placeChild 方法里面,根据 oldIndex 与 lastPlacedIndex 的值标记当前新的节点是否需要插入。
key不同导致的不可复用,立即跳出遍历,第一轮遍历结束。
如果 key 值不同,updateSlot 就会返回 null。在第一次循环中,有这样的判断:
const newFiber = updateSlot(
returnFiber,
oldFiber,
newChildren[newIdx],
lanes,
);
if (newFiber === null) {
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
break;
}
如果返回的 newFiber 为 null,就跳出循环。
如果 newChildren 遍历完(即 newIdex === newChildren.length)或者 oldFiber 遍历完,则跳出遍历,第一轮遍历结束。
当第一次遍历完后,如果 newChildren 遍历完,oldFiber 没遍历完,将 oldFiber 中没遍历完的节点添加删除标记。
if (newIdx === newChildren.length) {
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
如果 newChildren 没遍历完,oldFiber 遍历完,就会进行第二轮遍历。
第二轮遍历
如果 oldFiber 遍历完,但 newChildren 没遍历完。进行第二轮遍历,将剩下的 newChildren 进行新建操作。
// oldFiber 为 空
if (oldFiber === null) {
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
if (newFiber === null) {
continue;
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx); // 插入新增节点
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
return resultingFirstChild;
}
如果 oldFiber 不为空。根据 oldFiber 的 key 或者 index 为 key,当前节点为 value,构建 Map 对象。
function mapRemainingChildren() {
existingChildren.set(existingChild.index, existingChild)
}
构建好 Map 后,进入第三轮遍历。
第三轮遍历
根据 newFiber 的 key 去 existingChildren 中取:
- 能取到。复用当前节点。
- 不能取到。新建一个节点。
生成节点后,如果当前节点是复用的之前的节点,将当前的 key 从 existingChildren 对象中删除。然后根据新生成的节点的 index 与 lastPlacedIndex 进行比较,判断是执行移动还是插入操作。
// 第三次遍历,节点移动
for (; newIdx < newChildren.length; newIdx++) {
// 根据 key 取出 Map 中对应的旧的Fiber 与 React element 做类型的比较
// 如果类型相同则使用 React element 的数据更新 Fiber 节点上的属性进行重用,不同,则会根据 React element 的数据重新创建一个新的 Fiber 做插入操作
const newFiber = updateFromMap(
existingChildren,
returnFiber,
newIdx,
newChildren[newIdx],
lanes,
);
if (newFiber !== null) {
// newFiber.alternate 不为 null,表示是重用的节点,需要将 existingChildren 中重用的节点删除掉
// 遍历结束后 existingChildren 中剩下的节点,则是需要删除的
if (shouldTrackSideEffects) {
if (newFiber.alternate !== null) {
// 在调用 updateFormMap 方法时,会根据 key 取出相对应的 Fiber
// 调用 updateFromMap 方法完成后,对应 key 的 Fiber 值被重用了,所以需要删除 Map 中使用过的 key 对应的值
existingChildren.delete(
newFiber.key === null ? newIdx : newFiber.key,
);
}
}
// 将 newIdex 赋值给 workInProgress 树上的 Fiber 节点的 index 属性,代表当前元素在列表中的位置(下标)
// 判断 current 树上元素的 Index 是否小于 lastPlacedIndex,是则表示该元素需要移动位置了,否则表示不需要移动位置
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx); // 标记为插入的逻辑
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
}
当第三轮遍历结束后,清空 existingChildren 对象。
if (shouldTrackSideEffects) {
// existingChildren 中剩下的 Fiber,表示 current 树上存在,但是 workInProgress 树上不存在的元素
// 将剩下的 Fiber 添加到父 Fiber 节点的deletions 属性中,并且在 flags 集合中添加删除标识,在 commit 阶段会将这些元素进行删除
existingChildren.forEach(child => deleteChild(returnFiber, child));
}
注意:mapRemainingChildren 这个方法生成的 map 对象使用的是 key 或者 index 为 key。如果我们在使用时,key 值存在重复,这里的 map.set(existingChild.key, existingChild) 就可能存在着设置覆盖的问题,相同的 map 值只会存在一个,更新时就可能就会出现异常。
比如我有以下的数据结构:
this.state = {
// 原数据
list: [
{ label: '问题1', value: 1 },
{ label: '问题2', value: 2 },
{ label: '问题3', value: 2 },
{ label: '问题4', value: 2 },
{ label: '问题5', value: 2 },
]
}
<ul>
{
list.map((item, index) => (
<li key={item.value}>{item.label}</li>
))
}
</ul>
展示如下所示:
当更新时,重新设置 list 的数据如下:
updateList = () => {
const newList = [
{ label: '问题6', value: 1 },
{ label: '问题7', value: 4 },
{ label: '问题8', value: 3 },
]
this.setState({
list: newList,
})
}
展示如下:
为什么会这样?
当第二轮遍历结束后,根据 key 值生成 Map 对象,由于原数据的有 4个 key 值为 2 的数据。所以 existingChildren 只会有一个值,这个值就是原数据中最后一个 value = 2 的值,也就是展示的 问题5:
当第三轮遍历结束后
问题6的key值在oldFiber中存在,节点可以复用。问题7、问题8的key在oldFiber中不存在,执行新建并插入到节点最后的逻辑。- 删除
existingChildren中还剩余的值。由于里面只有一个key = 2的map(问题5),所以会删除问题5。此时diff结束,问题2、3、4没有删掉,还在页面中,也就是我们看到的,新列表只有问题6、7、8,但是会展示问题6、2、3、4、7、8的原因。
通过以上的学习,我们从源码的角度了解了 diff 算法的具体实现。当然,这么多的文字可能头都看晕了,下面用几个示例图来描述一下 diff 执行过程。
举个例子
假如 oldFiber 有 5个 节点 ABCDE,将节点更新为 ADBCE。
-
newChildren第一次遍历A节点key相同,type相同。可以复用,lastPlacedIndex = 0。
-
D节点key不同。退出第一轮遍历。此时oldFiber和newChildren都不为空,进入第三轮遍历。 -
D节点在oldFiber中存在,oldIndex = 3>lastPlacedIndex = 0,可以复用,不用移动。更新lastPlacedIndex = 3。 -
B节点在oldFiber中存在,oldIndex = 1<lastPlacedIndex = 3,可以复用,需要移动。此时lastPlacedIndex = 3。 -
C节点在oldFiber中存在,oldIndex = 2<lastPlacedIndex = 3,可以复用,需要移动。此时lastPlacedIndex = 3。 -
E节点在oldFiber中存在,oldIndex = 4>lastPlacedIndex = 3,可以复用,不用移动。此时lastPlacedIndex = 4。
假如我们将 ABCDE 更新为 EABCD,来看看是如何执行的:
-
对
newChildren进行第一轮遍历。E与A节点的key值不同。退出第一轮遍历。
-
oldFiber和newChildren都不为空,进行第三轮遍历。 -
E节点在oldFiber中存在,oldIndex = 4>lastPlacedIndex = 0。可以复用,不用移动。更新lastPlacedIndex = 4。 -
A节点在oldFiber中存在。oldIndex = 0<lastPlacedIndex = 4。可以复用,需要移动。此时lastPlacedIndex = 4。 -
B节点在oldFiber中存在。oldIndex = 1<lastPlacedIndex = 4。可以复用,需要移动。此时lastPlacedIndex = 4。 -
C节点在oldFiber中存在。oldIndex = 2<lastPlacedIndex = 4。可以复用,需要移动。此时lastPlacedIndex = 4。 -
D节点在oldFiber中存在。oldIndex = 3<lastPlacedIndex = 4。可以复用,需要移动。此时lastPlacedIndex = 4。
可以看到,虽然我们只想将最后一个移动到第一个,但是这样操作后,能复用并且不用移动的节点只有 E 一个,其余的节点都需要移动。开发时,如果要性能最大化,这样的操作需要尽量少用。
假如我们将 ABCDE 更新为 FABCD,来看看是如何执行的:
-
对
newChildren进行第一轮遍历。key值不同,退出第一轮遍历。F与A节点的key值不同。退出第一轮遍历。
-
oldFiber和newChildren都不为空,进行第三轮遍历。 -
F节点在oldFiber中不存在,执行新增操作。 -
A节点在oldFiber中存在。oldIndex = 0===lastPlacedIndex = 0。可以复用,不用移动。此时lastPlacedIndex = 0。 -
B节点在oldFiber中存在。oldIndex = 1>lastPlacedIndex = 0。可以复用,不用移动。此时lastPlacedIndex = 1。 -
C节点在oldFiber中存在。oldIndex = 2>lastPlacedIndex = 1。可以复用,不用移动。此时lastPlacedIndex = 2。 -
D节点在oldFiber中存在。oldIndex = 3>lastPlacedIndex = 2。可以复用,不用移动。此时lastPlacedIndex = 3。
可以看到,除了第一个 F 节点需要插入外,其余的节点都是可以复用,并且不需要移动的。
注意
1. 如果节点的展示是有条件判断的,初始时不展示,但它 Fiber 结构中的 index 是会占位的,后面的节点 index 再顺序增加。
比如:
render() {
const { num } = this.state;
return (
<>
{
num > 1 &&
<span>新节点</span>
}
<h1>{num}</h1>
<button onClick={() => this.setState({ num: num + 1 })}>点击我+1</button>
</>
)
}
这里面的 h1 节点对应 Fiber 数据结构中的 index === 1。这个在 reconcileChildFibers 中会用到。
总结
- 如果节点下的子节点是
string、number、boolean类型的值。children不会再进行beginWork,也就不会再进行diff。 - 只对同级元素进行
diff。如果一个DOM元素在前后两次更新中跨越了层级,那么React不会尝试复用它。 - 两个不同类型的元素会产生不同的树。如果元素由
DIV变成P,React会销毁DIV及其子孙元素,并新建P及其子孙元素。 - 开发者可以通过
key来暗示哪些子元素在不同的渲染下能够保持稳定。 key值如果重复,diff结束后无法正确的删除掉不需要的子节点,可能会导致渲染上的异常。- 在
diff过程中,只能节点进行标记新增、移动、删除,真正执行节点并修改UI展示的,还是在commit阶段。 diff的三次循环,循环的顺序特别重要。大多数情况下,要么直接更新所有节点,要么给列表的最后再添加一些新节点,那种要将后面的节点移动到前面来的操作相对较少。所以将对oldFiber循环放在第一个,以达到复用的最大化,尽可能地减少diff的次数。- 如果一个节点的展示是有条件判断的,某些条件下,该节点可能不展示,但节点的
index仍然保留占有位,后面的节点的index会自动+1,后面的节点不会因为前面节点由于条件判断不展示而index就根据不展示节点再前面的那个节点的index + 1。比如A,false,C,虽然false节点不展示,但是C节点的index = 2,这个在多节点diff时需要用到。 - 正常节点下面有多个节点的情况,没有设置
key时,key值默认是null,更新后,key值也为null,null === null为true,也就相当于前后的key值是相等的。 diff的本质是对当前节点的children节点进行对比。