Diff 算法 diff 的是什么
一个 DOM 节点,在某一时刻最多会有四个节点与其相关:
DOM节点本身。current Fiber。如果该DOM节点已经在页面中渲染好了,那么current Fiber代表该DOM节点对应的Fiber节点。workInProgress Fiber。如果该DOM节点将在本次更新中渲染到页面中,workInProgress Fiber代表该DOM节点对应的Fiber节点。jsx对象。也就是class组件的render方法或者function组件返回的结果,源码里面其类型为ReactElement。
Diff 算法比较的是 2 和 4,生成 3。
React Diff 算法与普通 Diff 算法的区别
普通的 Diff 算法是将两棵树进行完全对比,即使在比较前沿的算法中其算法时间复杂度也有 O(n³),n 是树中的元素个数。如果用这个算法,那么 1000 个元素所需要的执行的计算量就是 1000³,也就是十亿的量级。这个计算量是非常恐怖的。
为了降低算法复杂度,React 的 diff 会预设三个限制:
- 只对同级元素进行
Diff。如果一个DOM节点在前后两次更新中跨越了曾经,那么React不会尝试复用他。 - 两个不同类型的元素会产生出不同的树。如果元素由
div变成p,React会销毁div及其后代节点,并新建p及其后代节点。 - 开发者可以通过
key这个属性来暗示哪些子元素在不同的渲染下能保持稳定。
对于第三点解释一下:
// 更新前
<div>
<p key="key1">p</p>
<h3 key="key2">h3</h3>
</div>
// 更新后
<div>
<h3 key="key2">h3</h3>
<p key="key1">p</p>
</div>
如果不加 key 那么根据第二条 p 变 h3,那么这个节点将被销毁,并新建。
但是现在加了 key,而且更新之后还存在,所以这个节点可以复用,只是需要交换下顺序。
经过这一系列处理之后的 Diff 算法时间复杂度可以提升到 O(n)。
源码
我们之前介绍协调的时候就说过,diff 算法就是在协调阶段使用的,beginWork 的时候会调用 reconcileChildFibers。
// path: packages/react-reconciler/src/ReactChildFiber.new.js
// 根据 newChild 类型选择不同的 diff 函数处理
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) {
newChild = newChild.props.children;
}
// 处理 object 类型
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
return placeSingleChild(
reconcileSingleElement(
returnFiber,
currentFirstChild,
newChild,
lanes,
),
);
case REACT_PORTAL_TYPE:
return placeSingleChild(
reconcileSinglePortal(
returnFiber,
currentFirstChild,
newChild,
lanes,
),
);
case REACT_LAZY_TYPE:
if (enableLazyElements) {
const payload = newChild._payload;
const init = newChild._init;
return reconcileChildFibers(
returnFiber,
currentFirstChild,
init(payload),
lanes,
);
}
}
// 数组类型
if (isArray(newChild)) {
return reconcileChildrenArray(
returnFiber,
currentFirstChild,
newChild,
lanes,
);
}
if (getIteratorFn(newChild)) {
return reconcileChildrenIterator(
returnFiber,
currentFirstChild,
newChild,
lanes,
);
}
throwOnInvalidObjectType(returnFiber, newChild);
}
// 处理字符串或者数字类型
if (typeof newChild === 'string' || typeof newChild === 'number') {
return placeSingleChild(
reconcileSingleTextNode(
returnFiber,
currentFirstChild,
'' + newChild,
lanes,
),
);
}
// 省略一些代码
// 以上情况都没命中,删除节点
return deleteRemainingChildren(returnFiber, currentFirstChild);
}
这个函数的作用总结起来:
newChild为object类型要区分是不是Array类型,Array类型代表着同级含有多个节点,其它的则代表同级只有一个节点。newChild为字符串或者数字类型的时候,代表同级只有一个节点。
同级一个节点和多个节点的处理方式有一些区别。这也是 Diff 算法重点处理的问题。
新的节点为单节点的 Diff
以 object 的 reconcileSingleElement 为例:
// path: packages/react-reconciler/src/ReactChildFiber.new.js
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement,
lanes: Lanes,
): Fiber {
const key = element.key;
let child = currentFirstChild;
// 判断是否存在对应 DOM 节点
while (child !== null) {
// 上一次更新时存在 DOM 节点
// 比较 key 是否相同
if (child.key === key) {
// key 相同再比较 type 是否相同,相同则返回可复用的 fiber
const elementType = element.type;
if (elementType === REACT_FRAGMENT_TYPE) {
if (child.tag === Fragment) {
// 将该 fiber 的兄弟 fiber 标记为删除
deleteRemainingChildren(returnFiber, child.sibling);
const existing = useFiber(child, element.props.children);
existing.return = returnFiber;
// 省略一些代码
return existing;
}
} else {
if (
child.elementType === elementType ||
(__DEV__
? isCompatibleFamilyForHotReloading(child, element)
: false) ||
(enableLazyElements &&
typeof elementType === 'object' &&
elementType !== null &&
elementType.$$typeof === REACT_LAZY_TYPE &&
resolveLazy(elementType) === child.type)
) {
// 将该 fiber 的兄弟 fiber 标记为删除
deleteRemainingChildren(returnFiber, child.sibling);
const existing = useFiber(child, element.props);
existing.ref = coerceRef(returnFiber, child, element);
existing.return = returnFiber;
// 省略一些代码
return existing;
}
}
// 代码执行到这里代表:key 相同但是 type 不同
// 将该 fiber 及其兄弟 fiber 标记为删除
deleteRemainingChildren(returnFiber, child);
break;
} else {
// key 不同,将该 fiber 标记为删除
deleteChild(returnFiber, child);
}
// 切换上一次的节点为兄弟节点
child = child.sibling;
}
// 不存在对应 DOM 节点,创建新的 fiber 节点并返回
if (element.type === REACT_FRAGMENT_TYPE) {
const created = createFiberFromFragment(
element.props.children,
returnFiber.mode,
lanes,
element.key,
);
created.return = returnFiber;
return created;
} else {
const created = createFiberFromElement(element, returnFiber.mode, lanes);
created.ref = coerceRef(returnFiber, currentFirstChild, element);
created.return = returnFiber;
return created;
}
}
实例:
// 待更新
<ul>
<li key="0"></li>
<li key="1"></li>
<li key="2"></li>
</ul>
// 更新后
<ul>
<p key="1"></p>
</ul>
更新后只有一个 p 姐弟啊,属于单节点 Diff,会走到上面代码逻辑。
在 reconcileSingleElement 函数中会遍历之前的三个 li 对应的 fiber。
当 key 不相同的时候会删除该 fiber,其还未遍历到的兄弟 fiber 待处理。
当 key 相同的时候会继续判断 type 是否相同,这里当遍历到 key="1" 的节点时就会触发这个逻辑,此时 type 不相同,会将该节点及其兄弟节点全部删除,这样兄弟节点就算没遍历到也会被删掉。
新的节点为多节点的 Diff
多节点的 Diff 会走到 if (isArray(newChild)) { ... } 这段逻辑。
// path: packages/react-reconciler/src/ReactChildFiber.new.js
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++) {
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber;
oldFiber = null;
} else {
nextOldFiber = oldFiber.sibling;
}
const newFiber = updateSlot(
returnFiber,
oldFiber,
newChildren[newIdx],
lanes,
);
if (newFiber === null) {
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
break;
}
if (shouldTrackSideEffects) {
if (oldFiber && newFiber.alternate === null) {
deleteChild(returnFiber, oldFiber);
}
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
oldFiber = nextOldFiber;
}
// 第二轮遍历:newChildren 遍历完,oldFiber 没遍历完
if (newIdx === newChildren.length) {
deleteRemainingChildren(returnFiber, oldFiber);
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;
}
return resultingFirstChild;
}
// 第二轮遍历:newChildren 和 oldFiber 都没遍历完
const existingChildren = mapRemainingChildren(returnFiber, 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;
}
}
if (shouldTrackSideEffects) {
existingChildren.forEach(child => deleteChild(returnFiber, child));
}
return resultingFirstChild;
}
Diff 主要就是为了找出新旧节点的不同之处,所以对于节点来说,无非就是三种操作:新增、删除、更新。而更新又是这三种操作中发生频率最高的。所以 React 的 Diff 算法中将遍历分成了两轮,第一轮专门用来处理更新,第二轮用来处理非更新的操作。
第一轮遍历
- 首先遍历
newChildren,比较newChildren的第一个子与oldFiber,通过key和type(updateSlot函数中完成) 判断是否可以复用。 - 如果可以复用那么
newIdx++,继续比较newChildren的下一个子与oldFiber.sibling,可以复用则继续遍历。 - 如果不可以复用,分为两种情况:①
key不同,执行break立即跳出for循环,第一轮遍历结束;②key相同type不同,执行deleteChild,将oldFiber标记为DELETION,并继续遍历。 - 如果
newChildren遍历完,或者oldFiber遍历完跳出循环,结束第一轮遍历。
如果步骤3跳出的遍历,此时 newChildren 没有遍历完,oldFiber 也没有遍历完。
如果步骤4跳出的遍历,可能 newChildren 和 oldFiber 之一遍历完,也可能都遍历完了。
/*** 步骤3跳出 ***/
// 更新之前
<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>
/*** 步骤4跳出 ***/
// 更新之前
<li key="0" className="a">0</li>
<li key="1" className="b">1</li>
// 更新之后 情况1 —— newChildren 与 oldFiber 都遍历完
<li key="0" className="aa">0</li>
<li key="1" className="bb">1</li>
// 更新之后 情况2 —— newChildren 没遍历完,oldFiber 遍历完
// newChildren 剩下 key==="2" 未遍历
<li key="0" className="aa">0</li>
<li key="1" className="bb">1</li>
<li key="2" className="cc">2</li>
// 更新之后 情况3 —— newChildren 遍历完,oldFiber 没遍历完
// oldFiber 剩下 key==="1" 未遍历
<li key="0" className="aa">0</li>
第一轮遍历结束之后,开始第二轮遍历。
第二轮遍历
第二轮遍历需要根据第一轮遍历的结果分为四种情况。
newChildren 与 oldFiber 同时遍历完
第一轮遍历就完成了组件的更新(更新由 updateSlot 函数完成),Diff 结束。
newChildren 没遍历完,oldFiber 遍历完
已有的 DOM 节点都复用了,这时还有新加入的节点,意味着本次更新有新节点插入,我们只需要遍历剩下的 newChildren 为生成的 workInProgress fiber 依次标记 Placement(placeChild 函数完成)。
newChildren 遍历完,oldFiber 没遍历完
意味着本次更新比之前的节点数量少,有节点被删除了。所以需要遍历剩下的 oldFiber,依次标记 Deletion。
newChildren 与 oldFiber 都没遍历完
这意味着有节点在这次更新中改变了位置。这是 Diff 算法 最精髓也是最难懂的部分。
步骤:
mapRemainingChildren函数将为oldFiber生成一个Map叫existingChildren,使用节点的key属性作为Map的key,Map的value为oldFiber。- 遍历剩余的
oldFiber,通过existingChildren中的key找到key相同的oldFiber(由updateFromMap函数完成)。 lastPlacedIndex为最后一个可复用节点在oldFiber的位置,用oldIndex表示oldFiber的index属性,也就是位置索引,比较lastPlacedIndex与oldIndex的大小,oldIndex >= lastPlacedIndex则该节点不需要移动,并将oldIndex赋值给lastPlacedIndex,oldIndex > lastPlacedIndex则该节点需要移动。
总结起来,我们应尽量减少将节点从后面移动到前面的操作,这样的操作对性能不友好。
比如:abcd -> acdb 是将 b 直接移动到了后面;abcd -> dabc 则是将 abc 依次移动到后面。