DIFF算法总体介绍
由于将树转化为另一种树需要大量时间,事实上,算法的时间复杂度为O(n^3),所以必须要想一种方法进行替代,这就是DIFF算法。
首先研究实际开发中的情况,第一,很少有在更新时节点跨层级移动的情况,即使有,也可以用CSS进行隐藏或消失。第二,当两个组件的类不相同时,它们通常不会有相同的结构,第三,在同层级的节点比较中,可以以key做唯一标识。
根据第一条,DIFF算法可以只比较同层级节点,从而将算法的时间复杂度降为O(n)。根据第二条,算法将不再对不同类型的节点进行虚拟DOM对比,而是直接替换。根据第三条,可以直接在同层级节点中进行删除,增添与移动,移动时根据key进行区分
这里讲一下节点的移动,lastIndex代指更新完成的最后一个元素,同层级节点遍历新树时,先寻找是否有相应的新节点,如果有就使用,但注意,如果index小于lastIndex,那么需要移动至相应的位置并且更新lastIndex,如果大于或等于,就不需要移动。(这里只是简单一提,在下文的源码部分会有更加详细的描述)
源码解析部分
大致介绍了DIFF算法。在这里大致看一下源码部分,以子节点为数组时举例
function reconcileChildrenArray(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChildren: Array<*>,
lanes: Lanes,
): Fiber | null {
// This algorithm can't optimize by searching from both ends since we
// don't have backpointers on fibers. I'm trying to see how far we can get
// with that model. If it ends up not being worth the tradeoffs, we can
// add it later.
// Even with a two ended optimization, we'd want to optimize for the case
// where there are few changes and brute force the comparison instead of
// going for the Map. It'd like to explore hitting that path first in
// forward-only mode and only go for the Map once we notice that we need
// lots of look ahead. This doesn't handle reversal as well as two ended
// search but that's unusual. Besides, for the two ended optimization to
// work on Iterables, we'd need to copy the whole set.
// In this first iteration, we'll just live with hitting the bad case
// (adding everything to a Map) in for every insert/move.
// If you change this code, also update reconcileChildrenIterator() which
// uses the same algorithm.
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) {
// TODO: This breaks on empty slots like null children. That's
// unfortunate because it triggers the slow path all the time. We need
// a better way to communicate whether this was a miss or null,
// boolean, undefined, etc.
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
break;
}
if (shouldTrackSideEffects) {
if (oldFiber && newFiber.alternate === null) {
// We matched the slot, but we didn't reuse the existing fiber, so we
// need to delete the existing child.
deleteChild(returnFiber, oldFiber);
}
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
// TODO: Move out of the loop. This only happens for the first run.
resultingFirstChild = newFiber;
} else {
// TODO: Defer siblings if we're not at the right index for this slot.
// I.e. if we had null values before, then we want to defer this
// for each null value. However, we also don't want to call updateSlot
// with the previous one.
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
oldFiber = nextOldFiber;
}
if (newIdx === newChildren.length) {
// We've reached the end of the new children. We can delete the rest.
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
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;
}
return resultingFirstChild;
}
// Add all children to a key map for quick lookups.
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
// Keep scanning and use the map to restore deleted items as moves.
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = updateFromMap(
existingChildren,
returnFiber,
newIdx,
newChildren[newIdx],
lanes,
);
if (newFiber !== null) {
if (shouldTrackSideEffects) {
if (newFiber.alternate !== null) {
// The new fiber is a work in progress, but if there exists a
// current, that means that we reused the fiber. We need to delete
// it from the child list so that we don't add it to the deletion
// list.
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) {
// Any existing children that weren't consumed above were deleted. We need
// to add them to the deletion list.
existingChildren.forEach(child => deleteChild(returnFiber, child));
}
return resultingFirstChild;
}
在这里给出解释,首先是方法中的四个参数,returnFiber为Fiber的父节点,而currentFiberChild就是现在Fiber节点的child,newChildren为当前新数组,而lanes则与优先级有关(Fiber任务调度)。
接下来说一下遍历过程,首先进行第一遍循环,比较的值为oldFiber与newChildren[i],通过updateSlot进行判断,如果两者的key与类型均相同,那么就进行元素复用,生成一个新Fiber,而且i++,oldFiber为oldFiber.sibling,假如无法复用,则新Fiber为null,跳出循环。当跳出循环时,共有三种情况,假如为newChildren.length,那么在此时新节点已经遍历完成,可以删除其余节点。如果olderFiber为null,那就将剩余节点全部插入。如果新节点与旧节点均未遍历结束,那么就是出现了无法复用的节点(我认为并不完全是移动的情况,假如旧节点为abcde,新节点为abcdf,那么同样由第三种情况处理,但并不是移动),现在我们可以回到上文中所提到的移动的逻辑,将所有旧节点设立为map,然后根据key值依次查询新节点是否可以从其中复用,假如可以复用,就将新节点的alternate属性设置为旧节点,并将其从map中删除,如果无法复用,则将alternate属性设置为空。然后进行placeChild,placeChild中oldIndex代表旧节点的位置,而lastPlaceIndex为最近更新节点的索引,假如新节点中的alternate为空,那么执行插入方法,假如oldIndex小于lastIndex,那么进行移动,上述两种情况的lastPlaceIndex不会发生变化,而如果oldIndex大于等于lastIndex,那么不会移动节点,而是将lastPlaceIndex更新为oldIndex(此处移动的逻辑已经在上文的总体介绍中说过,这里详细解释),然后将map中剩余节点删除即可。
总结
这里结合我的react源码分析(3)-Fiber节点,树的介绍以及渲染过程总览中对Fiber节点的相关内容讲解了react需要重新渲染时的DIFF算法的原理以及DIFF算法下Fiber节点的插入删除与更新。