在 render
阶段的 beginWork
方法中,通过调用
instance.render()
-ClassComponent
Component(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
节点进行对比。