在React开发中,Diff算法是一个非常重要的概念,它负责高效地对比新旧虚拟DOM树,找出最小变化集,并据此对真实DOM进行最小化的更新操作。本文将从源码层面深入分析React(以18.2.0版本为例)中的Diff算法实现,揭示其高效运作的秘密。
Diff算法概述
React的Diff算法主要解决的是如何高效更新UI的问题。由于每次状态更新都会重新渲染整个组件树,但并非所有子组件都需要重新渲染。Diff算法通过对比新旧虚拟DOM树,找出差异,并仅对变化的部分进行DOM操作,从而提高性能。
Diff算法的策略
React的Diff算法基于以下三个策略进行优化,将时间复杂度降低至O(n):
- Tree Diff:只对同级元素进行比较,不同层级的元素不进行比较和复用。
- Component Diff:如果组件类型不同,则视为不同的树形结构,直接替换整个组件。
- Element Diff:对于同一层级的子节点,开发者可以通过
key属性来标识哪些子元素在不同渲染中可以保持稳定。
Diff算法的源码实现
入口函数:reconcileChildren
React的Diff过程从reconcileChildren函数开始。这个函数根据当前fiber节点是否存在,决定是直接渲染新的ReactElement内容还是与当前fiber进行Diff。
function reconcileChildren(current, workInProgress, nextChildren, renderLanes) {
if (current === null) {
// 当前fiber节点为空,则直接将新的ReactElement内容生成新的fiber
workInProgress.child = mountChildFibers(
workInProgress,
null,
nextChildren,
renderLanes
);
} else {
// 当前fiber节点不为空,则与新生成的ReactElement内容进行Diff
workInProgress.child = reconcileChildFibers(
workInProgress,
current.child,
nextChildren,
renderLanes
);
}
}
核心函数:reconcileChildFibers
reconcileChildFibers是Diff算法的核心函数,它根据新节点的类型(如对象、字符串/数字、数组等)调用不同的处理函数。
function reconcileChildFibers(returnFiber, currentFirstChild, newChild, lanes) {
const isObject = typeof newChild === 'object' && newChild !== null;
// 单节点
if (isObject) {
// 处理对象类型,可能是React元素或Portal等
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
return placeSingleChild(
reconcileSingleElement(returnFiber, currentFirstChild, newChild, lanes),
lanes
);
// 其他类型处理...
}
}
// 多节点
if (isArray(newChild)) {
// 处理数组类型,即多节点
return reconcileChildrenArray(
returnFiber,
currentFirstChild,
newChild,
lanes
);
}
// 其他情况处理...
}
单节点对比:reconcileSingleElement
对于单个节点的对比,React通过reconcileSingleElement函数实现。该函数遍历当前fiber节点下的所有子节点,寻找与新节点key和type都相同的子节点进行复用。
function reconcileSingleElement(returnFiber, currentFirstChild, element) {
const key = element.key;
let child = currentFirstChild;
while (child !== null) {
if (child.key === key) {
if (child.elementType === element.type) {
// 节点可复用,更新props和ref
const existing = useFiber(child, element.props);
existing.ref = coerceRef(returnFiber, child, element);
existing.return = returnFiber;
return existing;
}
// key相同但type不同,删除当前节点及其兄弟节点
break;
}
// key不同,继续遍历兄弟节点
child = child.sibling;
}
// 创建新节点
// ...省略
}
多节点对比:reconcileChildrenArray
对于多节点的对比,React通过reconcileChildrenArray函数实现。该函数会进行两次遍历,首先将旧节点列表转换为Map结构,然后遍历新节点列表,根据key值从旧节点Map中查找可复用的节点。
function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes) {
let resultingFirstChild: Fiber | null = null;
let previousNewFiber: Fiber | null = null;
let oldFiber = currentFirstChild;
let lastPlacedIndex = 0;
let newIdx = 0;
let nextOldFiber = null;
// 第一轮顺序比对,如'a', 'b', 'c' => 'a', 'c', 'b'
// 比对到'c'时,newFiber.key !== oldFiber.key,说明节点不能复用
// 经过updateSlot处理回来的newFiber为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;
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
oldFiber = nextOldFiber;
}
if (newIdx === newChildren.length) {
// 说明新节点已经遍历完了,收集剩余旧的fiber节点,标记为ChildDeletion
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
if (oldFiber === null) {
// 说明旧节点已经遍历完了,则依次创建剩余的新的fiber节点
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;
}
// 到这一步说明旧节点和新节点都不为空
// 构建一个以key为键,fiber为值的Map
const existingChildren = mapRemainingChildren(oldFiber);
for (; newIdx < newChildren.length; newIdx++) {
// 在existingChildren中找能复用的fiber
// 如果找到了,则调用useFiber方法复用节点
// 如果没有可复用的节点,则调用createFiberFromElement创建新的fiber节点
const newFiber = updateFromMap(
existingChildren,
returnFiber,
newIdx,
newChildren[newIdx],
lanes,
);
if (newFiber !== null) {
if (newFiber.alternate !== null) {
// 说明是复用节点,则将已复用的节点从map中移除
existingChildren.delete(
newFiber.key === null ? newIdx : newFiber.key,
);
}
// 更新下一个插入节点的位置
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
}
// 将剩余未复用的旧节点标记为删除
existingChildren.forEach(child => deleteChild(returnFiber, child));
return resultingFirstChild;
重要的辅助函数-placeChild:更新待插入节点的位置
// 例如'a', 'b', 'c' => 'a', 'c', 'b'
// 遍历到'c'时,oldIndex = 2, lastPlacedIndex = 1, oldIndex > lastPlacedIndex 说明不需要移动位置,更新lastPlacedIndex为oldIndex = 2
// 遍历到'b'时,oldIndex = 1, lastPlacedIndex = 2, oldIndex < lastPlacedIndex 说明需要移动位置,flags标记为Placement
// flags标记为Placement, 会在commitRoot的commitMutationEffects阶段,在commitReconciliationEffects-commitPlacement方法中依次插入
function placeChild(newFiber, lastPlacedIndex, newIndex) {
newFiber.index = newIndex;
const current = newFiber.alternate;
if (current !== null) {
const oldIndex = current.index;
if (oldIndex < lastPlacedIndex) {
newFiber.flags |= Placement;
return lastPlacedIndex;
} else {
return oldIndex;
}
} else {
newFiber.flags |= Placement;
return lastPlacedIndex;
}
}
总结
React的diff算法主要分为单节点和多节点的比对
- 单节点
- 如果key和type相同,则复用,否则删除
- 多节点:主要有两轮循环
- 第一轮顺序对比,遇到不可复用的节点时,跳出循环
- 如果新节点遍历完了,则删除剩余的老节点
- 如果旧节点遍历完了,则依次创建剩余的新节点
- 第二轮遍历,先构建一个以key为键,fiber为值的Map
- 先去map找能复用的节点
- 如果能复用则复用,并删除map中已复用的旧节点
- 如果复用的旧节点的位置index小于待插入节点的位置,说明需要移动位置,flags标记为Placement,在commitRoot操作DOM时会移动节点
- 如果不能复用,则重新创建fiber节点
- 最后将剩余的旧节点删除
以上就是React diff算法的全部解析,整体分析下来,由于受限于fiber单链表的数据结构,没法使用更高效的算法,例如双端比较、最长递增子序列等,但使用两轮遍历的方式,将整体时间复杂度降为O(n),提高了遍历的效率。