React Diff
传统的Diff & React中的Diff
传统的diff算法计算一棵树变成另一棵的复杂度是O(n^3)
为什么传统的diff算法的复杂度是O(n^3)?对于旧树上的点A来说,它要和新树上的所有点比较,复杂度为O(n),然后如果点A在新树上没找到的话,点A会被删掉,然后遍历新树上的所有点找到一个去填空,复杂度增加为了O(n^2),这样的操作会在旧树上的每个点进行,最终复杂度为O(n^3)。
React的Diff算法为什么会是O(n)?
其实,React为了降低算法复杂度,React的diff会预设三个限制:
- 只对同级元素进行
Diff。如果一个DOM节点在前后两次更新中跨越了层级,那么React不会尝试复用他。 - 两个不同类型的元素会产生出不同的树。如果元素由
div变为p,React会销毁div及其子孙节点,并新建p及其子孙节点。 - 开发者可以通过
key prop来暗示哪些子元素在不同的渲染下能保持稳定。
前置知识
- Scheduler(调度器): 排序优先级,让优先级高的任务先进行 reconcile
- Reconciler(协调器): 找出哪些节点发生了改变,并打上不同的 Flags(旧版本 react 叫 Tag)
- Renderer(渲染器): 将 Reconciler 中打好标签的节点渲染到视图上
export const reconcileChildFibers = ChildReconciler(true); // update阶段
export const mountChildFibers = ChildReconciler(false); // mount阶段
其实React的Diff算法核心是在reconcileChildFibers这个函数中, 那上面两行代码和Diff有什么关联呢?通过react源码可以看出,其实在ChildReconciler函数中通过闭包函数返回了reconcileChildFibers,上面两个导出的函数一个用于mount阶段,一个用来update阶段。相关源码
react fiber树是如何形成的?
首先,我们要了解一个fiber节点的所有属性,下面只展示和Diff相关的节点属性,fiber节点的所有属性详见
export type Fiber = {
return: Fiber | null, // 指向父节点
child: Fiber | null, // 指向孩子节点
sibling: Fiber | null // 指向兄弟节点
}
reconcile构建的过程源码
if (oldFiber === null) {
// If we don't have any more existing children we can choose a fast path
// since the rest will all be insertions.
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
if (newFiber === null) {
continue;
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
// TODO: Move out of the loop. This only happens for the first run.
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
if (getIsHydrating()) {
const numberOfForks = newIdx;
pushTreeFork(returnFiber, numberOfForks);
}
return resultingFirstChild;
最后会生成类似下图所示的结构
react 双缓存技术
双缓存是指存在两颗 Fiber 树,current Fiber 树描述了当前呈现的 dom 树,workInProgress Fiber 是正在更新的 Fiber 树,这两颗 Fiber 树都是在内存中运行的,在 workInProgress Fiber 构建完成之后会将它作为 current Fiber 应用到 dom 上。
而React Diff就发生在这里面,简单来说就是,在 mount 时,会根据 jsx 对象(Class Component 的 render 函数或者 Function Component 的返回值),构建 Fiber 对象,形成 Fiber 树, 然后这颗 Fiber 树会作为 current Fiber 应用到真实 dom 上,在 update(像状态更新)的时候,会根据状态变更后的 jsx 对象和 current Fiber 做对比形成新的 workInProgress Fiber, 然后 workInProgress Fiber 切换成 current Fiber 应用到真实 dom 就达到了更新的目的,而这一切都是在内存中发生的,从而减少了对 dom 好性能的操作。
React Diff源码解读
React的Diff发生在reconcile阶段,会根据newChild(即JSX对象)类型走不同的逻辑进行处理,进行不同地Diff分支,主要可分为以下三种:普通文本,数字节点进行Diff,对单节点进行Diff,对多节点进行Diff
相关源码
// Handle object types
// 单节点
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,
);
}
}
// 文本节点/数字节点
if (
(typeof newChild === 'string' && newChild !== '') ||
typeof newChild === 'number'
) {
return placeSingleChild(
reconcileSingleTextNode(
returnFiber,
currentFirstChild,
'' + newChild,
lanes,
),
);
}
文本节点的Diff 相关源码
function reconcileSingleTextNode(
returnFiber: Fiber, // current Fiber return
currentFirstChild: Fiber | null, //current Fiber
textContent: string, //JSX
lanes: Lanes, //优先级 透传
): Fiber {
// 判断currentfiber是不是文本节点,如果是,先删除当前fiber节点的兄弟节点, 然后通过useFiber进行fiber节点文本替换
if (currentFirstChild !== null && currentFirstChild.tag === HostText) {
deleteRemainingChildren(returnFiber, currentFirstChild.sibling);
const existing = useFiber(currentFirstChild, textContent);
existing.return = returnFiber;
return existing;
}
// 如果current fiber 不是文本节点,但是JSX对象是文本节点,则先删除当前的current fiber,通过createFiberFromText创建新的文本节点并返回
deleteRemainingChildren(returnFiber, currentFirstChild);
const created = createFiberFromText(textContent, returnFiber.mode, lanes);
created.return = returnFiber;
return created;
}
单节点的Diff相关源码
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement,
lanes: Lanes,
): Fiber {
const key = element.key;
let child = currentFirstChild;
while (child !== null) {
// TODO: If key === null and child.key === null, then this only applies to
// the first item in the list.
if (child.key === key) {
const elementType = element.type;
if (elementType === REACT_FRAGMENT_TYPE) {
...
} else {
if (
child.elementType === elementType || ...) {
deleteRemainingChildren(returnFiber, child.sibling);
const existing = useFiber(child, element.props);
existing.ref = coerceRef(returnFiber, child, element);
existing.return = returnFiber;
return existing;
}
}
// Didn't match.
deleteRemainingChildren(returnFiber, child);
break;
} else {
deleteChild(returnFiber, child);
}
child = child.sibling;
}
}
-
进入这个函数,已经确定
newChildren是单节点了。 -
先判断key是否相等,再判断type是否相等
- 1.当child !== null且key相同且type不同时执行deleteRemainingChildren将child及其兄弟fiber都标记删除。
- 2.当child !== null且key不同时仅将child标记删除。
-
可以复用,删除
oldFiber链表剩余的链表节点;不可以复用,删除oldFiber链表的全部节点,标记为ChildDeletion, 创建新的fiber节点
举个例子:
当时 ul => li * 3 更新为 ul => p
因为newChild 是 p标签,属于单节点的diff, 所有进入以上的逻辑,遍历之前的3个fiber节点(对应的DOM为3个li),寻找本次更新的p是否可以复用之前的3个fiber中的某个。
当key相同且type不同时,代表我们已经找到本次更新的p对应的上次的fiber,但是p与li type不同,不能复用。既然唯一的可能性已经不能复用,则剩下的fiber都没有机会了,所以都需要标记删除。
当key不同时只代表遍历到的该fiber不能被p复用,后面还有兄弟fiber还没有遍历到。所以仅仅标记该fiber删除。可能该fiber节点的兄弟节点可以复用。
多节点的Diff相关源码
function reconcileChildrenArray(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChildren: Array<*>,
lanes: Lanes,
): Fiber | null {
let resultingFirstChild: Fiber | null = null;
let previousNewFiber: Fiber | null = null;
let oldFiber = currentFirstChild;
let lastPlacedIndex = 0;
let newIdx = 0;
let nextOldFiber = null;
// 第一次遍历
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
...
}
// oldFiber和 newChildren 同时遍历完 或者 newChildren遍历完, 将剩余的oldFiber标记为Deletion
if (newIdx === newChildren.length) {
deleteRemainingChildren(returnFiber, oldFiber);
...
}
// 初次渲染&newChildren没有遍历完,oldFiber遍历完
if (oldFiber === null) {
...
}
// map, 处理newChildren没有遍历完&oldFiber也没有遍历完的情况
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
// 第二次遍历
for (; newIdx < newChildren.length; newIdx++) {
...
}
if (shouldTrackSideEffects) {
// 删除没有复用的oldFiber节点map
existingChildren.forEach(child => deleteChild(returnFiber, child));
}
return resultingFirstChild;
}
需要注意的变量有
- lastPlacedIndex 表示的是最后一个可复用的节点在
oldFiber中的位置索引 - oldIndex 表示
遍历到的可复用节点在oldFiber中的位置索引。 - 如果
oldIndex < lastPlacedIndex,代表本次更新该节点需要向右移动。 lastPlacedIndex初始为0,每遍历一个可复用的节点,如果oldIndex >= lastPlacedIndex,则lastPlacedIndex = oldIndex。- resultingFirstChild 返回的workInProgress fiber的第一个节点
- previousNewFiber 中间变量 通过sibling指针连接上一个fiber节点和下一个fiber节点
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
多节点的Diff分为两次遍历,第一次遍历优先处理节点更新的情况(包括节点类型变化&节点属性变化) 第一轮遍历步骤如下:
let i = 0,遍历newChildren,将newChildren[i]与oldFiber比较,判断DOM节点是否可复用。- 如果可复用,
i++,继续比较newChildren[i]与oldFiber.sibling,可以复用则继续遍历。 - 如果不可复用,分两种情况:
key不同导致不可复用,立即跳出整个遍历,第一轮遍历结束。key相同type不同导致不可复用,会将oldFiber标记为DELETION,并继续遍历
- 如果
newChildren遍历完(即i === newChildren.length - 1)或者oldFiber遍历完(即oldFiber.sibling === null),跳出遍历,第一轮遍历结束。
重点关注updateSlot方法
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber;
oldFiber = null;
} else {
nextOldFiber = oldFiber.sibling;
}
// 是否需要新建一个fiber
const newFiber = updateSlot(
returnFiber,
oldFiber,
newChildren[newIdx],
lanes,
);
// key 不同导致第一次遍历结束
if (newFiber === null) {
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
break;
}
if (shouldTrackSideEffects) {
// 将oldFiber标记为Deletion
if (oldFiber && newFiber.alternate === null) {
deleteChild(returnFiber, oldFiber);
}
}
// 为newFiber标记成Placement
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
oldFiber = nextOldFiber;
}
对于第一次遍历的情况,有如下四种情况
-
newChildren遍历完,oldFiber也遍历完
- 那就是最理想的情况:只需在第一轮遍历进行组件更新 。此时
Diff结束。
- 那就是最理想的情况:只需在第一轮遍历进行组件更新 。此时
-
newChildren没有遍历完,oldFiber遍历完
- 已有的
DOM节点都复用了,这时还有新加入的节点,意味着本次更新有新节点插入,我们只需要遍历剩下的newChildren为生成的workInProgress fiber依次标记Placement。
- 已有的
-
newChildren遍历完,oldFiber没有遍历完
- 意味着本次更新比之前的节点数量少,有节点被删除了。所以需要遍历剩下的
oldFiber,依次标记Deletion。
- 意味着本次更新比之前的节点数量少,有节点被删除了。所以需要遍历剩下的
-
newChildren没有遍历完,oldFiber也没有遍历完
- 意味着有节点在这次更新中改变了位置。
// oldFiber和 newChildren 同时遍历完 或者 newChildren遍历完, 将剩余的oldFiber标记为Deletion
if (newIdx === newChildren.length) {
deleteRemainingChildren(returnFiber, oldFiber);
if (getIsHydrating()) {
const numberOfForks = newIdx;
pushTreeFork(returnFiber, numberOfForks);
}
return resultingFirstChild;
}
// 初次渲染 & 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;
}
if (getIsHydrating()) {
const numberOfForks = newIdx;
pushTreeFork(returnFiber, numberOfForks);
}
return resultingFirstChild;
}
针对于newFiber和newChildren节点都没有遍历完 举个例子,考虑如下代码:
// 之前
<li key="0">0</li>
<li key="1">1</li>
<li key="2">2</li>
// 之后
<li key="0">0</li>
<li key="2">1</li>
<li key="1">2</li>
第一个节点可复用,遍历到key === 2的节点发现key改变,不可复用,跳出遍历,等待第二轮遍历处理。
此时oldFiber剩下key === 1、key === 2未遍历,newChildren剩下key === 2、key === 1未遍历。
由于有节点改变了位置,所以不能再用位置索引i对比前后的节点,那么如何才能将同一个节点在两次更新中对应上呢?
我们需要使用key。
为了快速的找到key对应的oldFiber,我们将所有还未处理的oldFiber存入以key为key,oldFiber为value的Map中。源码如下:
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
function mapRemainingChildren(
returnFiber: Fiber,
currentFirstChild: Fiber,
): Map<string | number, Fiber> {
const existingChildren: Map<string | number, Fiber> = new Map();
let existingChild = currentFirstChild;
while (existingChild !== null) {
if (existingChild.key !== null) {
existingChildren.set(existingChild.key, existingChild);
} else {
existingChildren.set(existingChild.index, existingChild);
}
existingChild = existingChild.sibling;
}
return existingChildren;
}
接下来遍历剩余的newChildren,通过newChildren[i].key就能在existingChildren中找到key相同的oldFiber。也就是第二次遍历,关键lastPlacedIndex变量的使用。
- lastPlacedIndex 表示的是最后一个可复用的节点在
oldFiber中的位置索引 - oldIndex 表示
遍历到的当前可复用节点在oldFiber中的位置索引。
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = updateFromMap(
existingChildren,
returnFiber,
newIdx,
newChildren[newIdx],
lanes,
);
if (newFiber !== null) {
if (shouldTrackSideEffects) {
if (newFiber.alternate !== null) {
existingChildren.delete(
newFiber.key === null ? newIdx : newFiber.key,
);
}
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
}
function placeChild(
newFiber: Fiber,
lastPlacedIndex: number,
newIndex: number,
): number {
newFiber.index = newIndex;
if (!shouldTrackSideEffects) {
newFiber.flags |= Forked;
return lastPlacedIndex;
}
const current = newFiber.alternate;
if (current !== null) {
const oldIndex = current.index;
if (oldIndex < lastPlacedIndex) {
// This is a move.
newFiber.flags |= Placement;
return lastPlacedIndex;
} else {
// This item can stay in place.
return oldIndex;
}
} else {
// This is an insertion.
newFiber.flags |= Placement;
return lastPlacedIndex;
}
}
遍历newChildren的时候,每当遍历一个newFiber,就会去existingChildren这个map对象中去找是否有可复用的oldFiber
- 没有找到,就通过
createFiberFromElement去创建一个新的Fiber节点,然后在placeChild方法中标记为Placement。 - 找到了,说明可以复用oldFiber节点。在
updateElement中判断type是否相等,type相等,通过useFiber复用之前的Fiber节点,不相等的话则通过createFiberFromElement去创建一个新的Fiber节点。通过判断当前的workInProgress Fiber节点的alternate是否指向current Fiber来判断是否复用之前的Fiber节点,也就是判断当前Fiber节点的alternate节点是否为null,如果是复用之前的Fiber节点,需要在existingChildren Map删除对应的item。最后通过比较lastPlacedIndex 和 oldIndex来判断当前这个Fiber节点是否想要移动。
举个例子:
更新前 a -> b -> c -> d -> e -> f (key为自己)
更新后 a -> c -> b -> e -> g (key为自己)
第一次遍历newChildren时,发现c这个节点的key('c')发生了变化,立马跳出第一轮循环,
newChildren和oldFiber都没有遍历完,将oldFiber放到一个map中{"b": b, "c": c, "d": d,
"e": e, "f": f},然后进行第二次遍历,遍历到'c'这个节点,从map中找到了,判断是否是复用先前
的Fiber节点,是的话先删除map中的item,然后在`placechild`中判断是否要标记Placement, 当前
oldIndex = 2 > lastPlacedIndex = 0(初始值为0),不需要标记成Placement,将
lastPlacedIndex赋值为2,继续遍历b, 发现b的oldIndex = 1 < lastPlacedIndex = 2,标记
成Placement.以此类推,e的oldIndex = 4 > lastPlacedIndex = 2,不需要标记成Placement.
g节点没有在map中找到,会直接创建一个新的Fiber节点,在标记为Placement.