一、diff算法
1、定义
React diff算法,也称为Reconciliation协调算法,是React用来比较新旧虚拟DOM树,并找出最小差异以更新真实DOM的核心机制。
React维护了两棵虚拟DOM树:一棵表示当前显示在页面中的DOM树——current树,另一个表示内存中正在构建的树——workInProgress树;当组件的state或props发生变化时,React会生成新的虚拟DOM树,并通过diff算法比较新旧两棵树,找出需要变更的虚拟节点,最后将需要更新的节点应用到真实DOM上。
2、React diff算法的升级
传统diff算法通过循环递归的方式对节点进行依次对比,效率低下,算法复杂度达到O(n^3);React提出了只比较同层节点的思想,将算法的时间复杂度从O(n^3)变成了O(n);
diff算法采用的是深度优先遍历的方式;【React的核心就是虚拟DOM + diff算法】
3、React diff算法的对比策略
React提供了三种对比策略,分别是:Tree diff、Component diff、Element diff;
(1)Tree diff —— 树层级的对比
它只会比较同一层级的节点,不会进行跨层级的比较;React会对两棵树的每一层进行遍历,比较相同父节点下的所有子节点;
- 新旧两棵树逐层对比,找出需要更新的节点;
- 如果节点是组件,就使用Component diff对比策略;
- 如果节点是元素,就使用Element diff对比策略;
(2)Component diff —— 组件层级的对比
如果是同类型组件,即拥有相同的类或构造函数,会进行更细致的比较;
如果是不同类型组件,React会删除旧组件及其所有子组件,并创建新的组件及其子组件;
- 如果节点是组件,就查看组件类型;
- 类型不同,直接替换;类型相同,则只更新属性;
- 进入组件内部做Tree diff;
(3)Element diff —— 元素层级的对比
如果元素类型相同,React会比较它们的属性,并只更新发生变化的属性;
如果元素类型不同,React会删除旧元素并创建新元素;
对于同一层级的子节点,React会进行精细的对比,包括节点的增加、删除、移动和更新;
- 如果节点是原生标签,则查看标签名;
- 标签名相同,则只更新属性;
- 标签名不同,则直接替换;
- 进入标签后代做Tree diff;
二、diff算法原理
1、原理
React的diff算法采用仅右移策略,即对元素发生的位置变化,只会将其移动到右边,右边移完了,元素就有序了;
2、对比过程
diff操作包含节点新增、节点移动、节点删除;
在React中,一个fiber对象的子节点可能是单个子节点,也可能是多个子节点,diff算法会调用不同的函数去处理这些节点。
(1)单节点操作
单节点操作会通过调用 reconcileSingleElement 函数处理,单节点是指新的节点为单节点,但是oldFiber节点可能会有多个;
【可能的单节点操作场景如下】
- 节点属性props变化 —— 节点更新操作
- 旧节点:
<div className='a' key='a'>hello</div>; - 新节点:
<div className='b' key='a'>hello</div>; - 对比过程:key值相同,节点属性发生变化,则只需更新节点属性即可;
- 旧节点:
- 多个旧节点,一个新节点 —— 根据key或tag匹配到节点后,删除多余节点;
- 旧节点
<div className='a' key='a'>hello</div><div className='b' key='b'>hello</div><div className='c' key='c'>hello</div>
- 新节点
<div className='a' key='a'>hello</div>
- 旧节点
- 无旧节点,一个新节点
- 新节点
<div className='a' key='a'>hello</div>
- 新节点
【单个节点更新的源码解析】
// 单节点更新
function reconcileSingleElement(
returnFiber: Fiber, // 父节点
currentFirstChild: Fiber | null, // 父节点的第一个子节点
element: ReactElement, // 新创建的元素节点
lanes: Lanes, // 优先级
): Fiber {
const key = element.key; // 获取元素的key属性
let child = currentFirstChild;
while (child !== null) { // 存在旧节点,与新节点进行比较
if (child.key === key) { // 如果旧节点的key属性与新的节点的key属性相同,表示当前属于节点更新操作
const elementType = element.type; // fiber对应的DOM元素的节点类型:div span
if (elementType === REACT_FRAGMENT_TYPE) {
// 若为fragment
if (child.tag === Fragment) {
// 如果标签类型属于Fragment
deleteRemainingChildren(returnFiber, child.sibling); // 删除剩余的子节点
// 基于旧的fiber节点与新的fiber节点创建一个新的fiber节点
const existing = useFiber(child, element.props.children);
existing.return = returnFiber;
return existing;
}
} else {
if ( child.elementType === elementType ||
(typeof elementType === 'object' &&
elementType !== null &&
elementType.$$typeof === REACT_LAZY_TYPE &&
resolveLazy(elementType) === child.type)
) {
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;
}
// 不存在旧节点时,直接创建
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;
}
}
(2)多节点操作
多节点的操作包括:节点新增、节点删除、节点移动、节点更新;多节点操作通过 reconcileChildrenArray 函数处理,它接受的参数 newChildren 是一个数组类型;
节点的更新、节点的新增、节点的删除都比较好理解,重点需要理解的是节点的移动;
【示例】
- 旧Fiber链表:A - B - C - D - E - F
- 新Fiber链表:A - B - D - C - E
从上述示例可以看出:节点C与节点D交换位置,节点F被删除;
【如何判断节点是否是固定节点?】
- 旧节点的获取:newFiber.alternate;
- 如果旧节点的 index 大于 lastPlacedIndex,说明该节点在固定节点的右边,即:该节点位置不变;
- lastPlacedIndex的值为oldFiber.index;
- 如果旧节点的 index 小于 lastPlacedIndex,说明该节点在lastPlacedIndex的左边,即:该节点需要移动;
- lastPlacedIndex的值不变;
- 如果旧节点的 index 大于 lastPlacedIndex,说明该节点在固定节点的右边,即:该节点位置不变;
【节点移动过程详解】
- 开始遍历,A、B节点为固定节点,则lastPlacedIndex的值为1;
- lastPlacedIndex的值通过调用placeChild函数得出;
- 遍历D节点时,
- 调用updateSlot函数创建的newFiber节点为null,因为新Fiber节点的key与旧Fiber不同,所以返回null;
- newFiber的值为null,退出本次循环;
- 最右侧的固定节点仍然为B,lastPlacedIndex的值不变,仍为1;
- 此时,新旧节点都还未结束遍历,执行移动操作;
- 先将剩余的旧的未遍历的节点存储到一个map对象中,用Fiber节点的key或index属性作为键名,方便遍历时查找;
- 继续遍历D节点
- lastPlacedIndex的值为1,D在oldFiber中的index值为3,3 > 1,则说明D节点为固定节点,不移动;
- 更新lastPlaceIndx的值为3;
- newIndex的值为3;
- 遍历节点C
- lastPlacedIndex的值为3,C在oldFiber中的index值为2,2 < 3,则说明C节点需要右移;
- lastPlaceIndx的值不变;
- newIndex的值更新为4;
- 遍历节点E
- lastPlacedIndex的值为3,E在oldFiber中的index值为4,4 > 3,则说明E节点为固定节点,不移动;
- 更新lastPlaceIndx的值为4;
- newIndex的值更新为5;
- 到此,newChildren遍历结束;每次遍历完一个节点,都需要从existingChildren删除已经遍历过的节点;
- 继续查看existingChildren中是否还有未遍历完的节点,有,则遍历删除未遍历的节点,
- 至此,diff操作结束;beginWork阶段的工作也就此结束;
【节点移动的函数】
function placeChild(
newFiber: Fiber,
lastPlacedIndex: number,
newIndex: number,
): number {
newFiber.index = newIndex;
if (!shouldTrackSideEffects) {
newFiber.flags |= Forked;
return lastPlacedIndex;
}
// 获取旧的fiber节点
const current = newFiber.alternate;
// 判断节点是否固定
if (current !== null) {
const oldIndex = current.index;
if (oldIndex < lastPlacedIndex) {
// 这是移动操作:旧的改变位置的节点
newFiber.flags |= Placement | PlacementDEV;
return lastPlacedIndex;
} else {
// 该项可以留在原地
return oldIndex;
}
} else {
// 这是插入操作:新创建的节点
newFiber.flags |= Placement | PlacementDEV;
return lastPlacedIndex;
}
}
【节点移动的函数】
function reconcileChildrenArray(
returnFiber: Fiber, // currentFirstChild的父级节点
currentFirstChild: Fiber | null, // 当前执行更新任务的fiber节点
newChildren: Array<any>, // 新的元素节点
lanes: Lanes,
): Fiber | null {
let resultingFirstChild: Fiber | null = null; // diff后新生成的fiber链表的第一个fiber对象
let previousNewFiber: Fiber | null = null;
let oldFiber = currentFirstChild; // 当前要比对的oldFiber节点
let lastPlacedIndex = 0; // 固定节点的索引值
let newIdx = 0; // 新节点的索引值
let nextOldFiber = null; // 用于记录下一个要遍历的oldFiber节点
// 处理节点更新:依据节点是否可以复用来决定是否中断遍历
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
// 新节点遍历结束,旧节点还未结束,则需要中断遍历
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber;
oldFiber = null;
} else {
// 获取下一个需要遍历的oldFiber节点
nextOldFiber = oldFiber.sibling;
}
// 创建一个新的fiber节点:对于DOM元素来说,key与tag都相同才会复用oldFiber,否则返回null
const newFiber = updateSlot(
returnFiber,
oldFiber,
newChildren[newIdx],
lanes,
);
// newFiber为null,说明元素的key或tag不同,节点不可复用,中断遍历
if (newFiber === null) {
if (oldFiber === null) {
// oldFiber 为null说明oldFiber此时也遍历完了
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);
}
}
// 记录固定节点的索引值:placeChild是移动节点的方法,节点只更新,则只停留在原来的位置即可
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
// 最终要返回的fiber链表:只有首次渲染时,previousNewFiber才会为null
resultingFirstChild = newFiber;
} else {
// 通过sibling连接成一个单向链表
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
// 遍历下一个oldFiber节点
oldFiber = nextOldFiber;
}
// 省略节点的删除与新增代码
// ...
// 移动操作:将剩余还未遍历到的子节点存储到一个map对象中,方便快速查找,key是fiber.key
// 设置:existingChildren.set(existingChild.key | index, existingChild);
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
// 节点移动
for (; newIdx < newChildren.length; newIdx++) {
// 基于map对象中的oldFiber创建新的fiber对象
const newFiber = updateFromMap(
existingChildren,
returnFiber,
newIdx,
newChildren[newIdx],
lanes,
);
if (newFiber !== null) {
if (shouldTrackSideEffects) {
if (newFiber.alternate !== null) {
// newFiber是当前正在构建的fiber,但是它的current属性不为空,也就是说newFiber不是新增的节点
// 我们需要从子列表中删除它,并且无需将其标记为要删除的fiber
existingChildren.delete(
newFiber.key === null ? newIdx : newFiber.key,
);
}
}
// 实现真正的节点移动:这是多节点diff的核心
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
}
if (shouldTrackSideEffects) {
// 将没有遍历到的oldFiber节点标记为要删除的节点
existingChildren.forEach(child => deleteChild(returnFiber, child));
}
return resultingFirstChild;
}
3、diff中的key
diff中的key属性主要用于在子节点对比中,帮助React快速识别哪些节点是稳定的,从而可以高效地复用和移动DOM节点;
key的值应该是稳定的、唯一的,并且最好能够反映节点在列表中的位置或身份,避免使用不稳定的值作为key;
总结
React的diff算法又叫协调,通过比较同级节点,找出最小差异,并应用到真实DOM上。Diff算法采用深度优先遍历的方式,采用仅右移策略,最小范围的移动元素。