前言
一直以来我对八股文一直是深恶痛绝的,总觉得这种东西如空中楼阁,对实际解决工程问题没有任何帮助。并且很多人只从网上搜索面试题答案死记硬背,反而可能由于该题目版本老旧而自己又没有从实际场景中理解,而导致获得了过时的甚至是错误的知识。
但随着工作的深入,我发现这种观点是有些偏颇的,因为在实际工程中,如果我们自己都不知道这个技术的运行原理,又何谈对它的深入优化呢?这在 AI 辅助工作的时代更是如此,AI 无法回答你提不出来的问题。
因此,我想新开辟一个板块,就写一些老生常谈的"八股文", 但是我会尽量从源码层面去理解这些问题,并且尽量做到一通百通,而不是死记硬背。希望这样的方式能够帮助大家更好地理解这些问题。
这一次,让我们重走西游,踏上取经路的是 React 的 Diff 算法。(注:以下代码实现均基于 React 18.3 版本实现进行)
React Diff 算法的诞生背景
要讲透 React Diff,就一定不能只讲 React Diff。我们需要知道 React Diff 算法为什么会被设计出来。
在 React 之前,我们在操作 DOM 的时候,通常是直接操作 DOM,比如我们要在一个列表中插入一个新的元素,我们会直接在 DOM 中插入一个新的元素。这样的操作会导致浏览器的重排和重绘,性能开销很大。为了解决这个问题,React 引入了 Virtual DOM。
Virtual DOM 用于描述 UI 树的理想状态,是一个纯粹的 JavaScript 对象。这样一来,React 的更新操作就从 DOM 操作中解放出来,只需要在内存中对一个
JavaScript 对象频繁进行更新即可。当需要更新 UI 时,React 拥有双缓存机制,会通过 Diff 算法比较新旧 Virtual DOM 的差异,算出需要更新的节点,将需要更新的部分一次性更新到真实的 DOM 中。
这样一来,React 不仅仅大大减少了浏览器的重排和重绘,提高了性能,同时还带来了一个巨大的好处:逻辑抽象层与视图层操作完全分离,为 React 的跨平台开发提供了可能。实际上,包括 React Native 在内的所有跨平台框架,他们在抽象逻辑层的代码,即 Virtual DOM 以及 React Diff 部分(在 React 中称为 React-Reconciler 库),都是与平台无关,完全相同且复用的。
React Diff 算法执行性能
现在我们知道了 React Diff 算法本质上就是用于比较新旧 Virtual DOM 的差异,得出需要更新的 DOM 行为。显而易见这个算法在整个 React 中的使用频率相当高,因此 React Diff 算法的执行性能是非常重要的。
我们知道,DOM 节点本质上是一个树型结构,因此通常来说,我们可以通过树的遍历算法来比较新旧 Virtual DOM 的差异。但是,即使在最前沿的算法中,将两棵树完全比对的复杂度仍为 O(n^3) (我们可以在 LeetCode 中很容易找到这样的题型,感兴趣可以自己实现一下),这显然是不可接受的。而 React Diff 算法将这一行为的时间复杂度降低到了 O(n)。
当然,科学领域里没有银弹,React Diff 能实现这么优异的性能,也是因为设计团队根据 React 本身的特点,预设了比对的 3 个限制:
- 只对同级元素 diff。若同一个 DOM 节点在更新中变更了层级,则 react 不会复用。
- 不同类型的元素变更时,元素不会复用。例如元素从 div 变成 p,react 会销毁 div 和所有子孙节点,并重新建立。
- 开发者可以对元素指定 key 属性来表示该元素能在更新后保持稳定,帮助算法优化。
正是由于 React 只针对同级同类型元素进行比对,所以 React Diff 将不会存在递归与回溯,从而保证了如此优异算法的复杂度。这就是 React Diff 算法的核心,也是八股文中真正有价值的内容,可惜不是每一个熟背的人都能真正理解。
React Diff 算法的实现
为了加深理解,我们也可以自己动手根据上述的思路实现 React Diff 算法。
通过进入 Diff 算法的 React 节点的子节点个数不同,我们可以将 React Diff 算法分为两种情况:单节点 Diff 和多节点 Diff。在实际的使用中,我们可以通过判断节点的 props.children 是否为数组来判断当前节点是单节点还是多节点。
单节点 Diff(reconcileSingleElement)
单节点 Diff 是指新节点只有一个子节点的情况(但旧节点可能有多个 children)。在这种情况下,我们只需要比较新旧节点的 props 和 children 是否相同即可。若旧的 React 节点可以被复用,将旧的 React 节点生成副本并返回。若旧的 React 节点无法被复用,则新生成一个 React 节点并直接返回,进入下一个节点判断。
那么我们如何判断旧节点是否能被复用呢?根据上面的限制我们可以得出,若当前元素的元素类型不同,则直接不复用。若相同,则遍历该旧节点的所有 children,判断元素的 key 和 type 与新节点的子元素是否相同。此时有会出现两种情况:
遍历时找到了子元素的 key 匹配,但此时元素 type 不同,表示该元素类型变更,依据限制 2,不进行复用。此时由于新节点为单 children,因此没有节点会与剩下旧子节点匹配了,直接将旧节点下的所有其他子节点标记为删除,并将新子节点标记为插入。
遍历时发现 key 不同,则表示该旧子节点不能被复用,将当前节点标记为删除,之后接着遍历后续的其它子节点去寻找是否有 key 匹配的子节点。
function reconcileSingleElement(returnFiber: Fiber, currentFirstChild: Fiber | null, element: ReactElement): Fiber {
const key = element.key;
let child = currentFirstChild;
while (child !== null) {
if (child.key === key) {
const elementType = element.type;
// Key 相同,比较 type
if (element.$$typeof === REACT_ELEMENT_TYPE) {
// type 相同 可以复用
if (child.elementType === elementType) {
// 当前节点可复用,其他兄弟节点都删除
deleteRemainingChildren(returnFiber, child.sibling);
const existing = useFiber(child, element.props);
existing.return = returnFiber;
return existing;
}
// key 相同但 type 不同,没法复用。后面的兄弟节点也没有复用的可能性了,都删除
deleteRemainingChildren(returnFiber, child);
break;
} else {
// type 不同,删除旧的,并继续比较
deleteChild(returnFiber, child);
}
child = child.sibling;
}
}
// 创建新节点
const created = createFiberFromElement(element);
created.return = returnFiber;
return created;
多节点 Diff(reconcileChildrenArray)
多节点 Diff 是指新节点有多个子节点的情况。这种情况的处理比较复杂,我们需要将新节点的每个子元素 (即 newChildren 数组) 与旧节点的所有兄弟节点 (即 old.sibling) 相比较,去寻找是否有复用的可能,此时,每一次对 old.sibling 的比较都能简化为,old.sibling 与 newChildren 数组进行 diff,判断逻辑应当与单节点 Diff 类似。
- 如果找到可复用元素,则继续遍历其他 newChildren 看是否有可复用。
- 如果 key 相同,type 不同,由规则 2,不复用。将旧节点标记删除,新节点标记新增。并继续对其他 newChildren 进行遍历。
- 如果 key 不同导致的不可复用,此时说明该节点位置变更,立即跳出遍历循环,在接下来的逻辑中处理。
- 如果 newChildren 或者旧 oldfiber.sibling 任意一个遍历完,此时可能有新增新节点,删除旧节点和没有节点变更三种可能,也在接下来的逻辑中处理
在这一轮的比较结束后,我们可以来查看并判断一下 newChildren 和 old.sibling 的状态。
- 如果两者都遍历完毕,那说明已经完成所有子节点的比对,Diff 环节结束。
- 如果 newChildren 遍历完毕,但 old.sibling 还有剩余节点,说明这些剩余节点都是需要删除的。
- 如果 old.sibling 遍历完毕,但 newChildren 还有剩余节点,说明这些剩余节点都是新增节点,需要创建并插入。
- 如果两者都没有遍历完毕,说明此时是由上一轮的条件 3 跳出循环的,说明此时有节点改变了位置。
这种情况比较复杂,也是 diff 算法处理的精髓所在。可以将剩下的 old.sibling 保存为 map,判断剩余的 newChildren 的 key 是否在 old 节点中存在。若存在则找变更位置,判断 oldIndex 与 lastPlacedIndex 的大小,lastPlacedIndex 初始为 0,若 oldIndex >= lastPlacedIndex,则节点不需要移动,将 lastPlacedIndex = oldIndex,否则将当前节点标记为向右移动。
通过这个实现我们也可以看出来,React Diff 在判断节点是否移动时,是通过从前往后遍历判断移动位置的。因此,从性能优化的角度考虑,我们要尽量减少将节点从后面移动到前面的操作。
// 若 newChildren 为数组,则需要遍历比较来更新当前 Fiber 树
// 注:该算法不能通过头尾两侧遍历来优化,因为 Fiber 树是单链表结构
function reconcileChildrenArray(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChildren: (ReactElement | string)[]
): Fiber | null {
let resultingFirstChild: Fiber | null = null;
let previousNewFiber: Fiber | null = null;
// 旧 Fiber 列表
let oldFiber = currentFirstChild;
let nextOldFiber = null;
// !!! 重要变量。遍历到的最后一个可复用 fiber 在旧节点中的索引位置
let lastPlacedIndex = 0;
// 指向当前新节点的索引位置
let newIdx = 0;
// 多节点 Diff 第一次遍历所有旧的和新的子节点,找到需要更新的节点,设置更新标记
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]);
// 如果此时新旧节点都已经遍历完毕,则直接跳出循环
if (newFiber === null) {
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
break;
}
// 标记该节点的插入状态,并返回标记顺序
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// 如果是第一个新子节点,设置 resultingFirstChild,否则将其作为上一个新子节点的兄弟节点。
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
oldFiber = nextOldFiber;
}
// 情况 1:如果新 children 列表已经遍历完成,但旧 children 列表还有剩余节点,删除这些旧的剩余节点,返回
if (newIdx === newChildren.length) {
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
// 情况 2:旧节点已经遍历完,但还剩余新节点,说明剩余的新节点都是新增节点,直接创建并插入
if (oldFiber === null) {
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = createChild(returnFiber, newChildren[newIdx]);
if (newFiber === null) {
continue;
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// 如果是第一个新子节点,设置 resultingFirstChild,否则将其作为上一个新子节点的兄弟节点。
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
return resultingFirstChild;
}
// 情况 3:新旧节点都没遍历完,需要进行 Diff 操作
// 设立一个 map 用于存储所有旧节点,方便后续查找
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
// 继续遍历剩余的新 fiber 节点,并利用 map,判断新节点为新增节点还是原有旧节点的移动导致。
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = updateFromMap(
existingChildren,
returnFiber,
newIdx,
newChildren[newIdx]
);
if (newFiber !== null) {
// 将新节点插入,并返回标记顺序
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// 如果是第一个新子节点,设置 resultingFirstChild,否则将其作为上一个新子节点的兄弟节点。
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
}
return resultingFirstChild;
}
至此我们就已经完全理解并实现了 React Diff 算法的核心逻辑。
在 React 中,Diff 算法的实现是在 React-Reconciler 库中的 ReactChildFiber 模块中。我们可以通过 阅读源码 来了解完整的 React Diff 算法的实现。
利用 React Diff 特性优化性能
在进行上面对于实现逻辑的理解中,我们对 React 的底层运行逻辑也有了更深的了解,从而也能更好的触类旁通理解一些 React 的一些性能优化技巧。
例如,我们耳熟能详的如下这些"React 开发最佳实践",其实都是基于 React Diff 算法的特性而演化出来的。
避免使用 index 作为 key
这是由于在 React Diff 中,key 是用来判断节点是否可以复用的重要依据。如果我们使用 index 作为 key,那么在节点的插入和删除时,会导致节点的 key 发生变化,从而导致 React Diff 算法无法正确判断节点是否可以复用,从而会触发不必要的重渲染,导致性能下降。
// good
const items = itemsArray.map((item, index) => (
<ListItem key={item.id} data={item} />
));
// bad
const items = itemsArray.map((item, index) => (
<ListItem key={index} data={item} />
));
对复杂组件树进行结构拆分
在 React Diff 算法中,只有同级同类型的节点才会进行比对。因此,如果我们的组件树结构过于复杂,会导致 React Diff 算法的比对过程变得复杂,从而影响性能。因此,这也是为什么 React 推崇组件封装与拆分,将同级同类型的节点提取出来,从而减少 React Diff 算法的比对复杂度,提高性能。
// 将大型组件拆分为更小的子组件
function LargeComponent({ data }) {
return (
<div>
<Header title={data.title} />
<Content items={data.items} />
<Footer info={data.info} />
</div>
);
}
避免不必要的重渲染
在 React Diff 算法中,只有节点的 props 或者 children 发生变化时,才会触发节点的重渲染。因此,我们应该尽可能地保证,当组件在不需要变化时,避免因为传入组件的 props 改变而导致不必要的重渲染。
例如我们可以使用 useMemo (React.memo) 或 useCallback 来避免触发不必要的重渲染。
const MyComponent = React.memo(
(props) => {
return <div>{props.value}</div>;
},
(prevProps, nextProps) => nextProps.value === prevProps.value
);
除此之外,包括推荐使用不可变的数据结构,避免频繁地进行 setState 操作,通过 CSS 动画来代替 JS 动画等等,都是基于 React Diff 算法的特性,为了减少不必要的 Diff 开销而推荐的性能优化技巧。
结语
所以你看,从一个 React Diff 是怎么实现的八股文中,我们可以学习并融会贯通这么多个性能优化技巧。这无疑才是我认为的真正有价值的技术技巧。
很多时候,我们抵制八股文,本质上是在抵制没有任何基础理解的,应试的死记硬背。比如几乎没有运用场景的 ie 兼容性问题,或者是已经被浏览器优化过的 css 渲染问题。而对那些我们时刻都需要使用到的技术架构,我们更需要仔细研究其核心原理,甚至自己动手实现一遍,这样才能更好地理解其运行机制,在实际工作中遇到性能问题时,有很大的帮助。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。