- 文档
- 前端
【免费下载链接】just-react
「React技术揭秘」 一本自顶向下的React源码分析书
本篇技术指南聚焦「React 技术揭秘」(just-react)仓库中 docs/diff/one.md 讲解的核心主题——单一节点(单元素)的 Diff 算法,即reconcileSingleElement如何判断新旧JSX对应的DOM 节点是否可以复用。文中将结合本仓库 docs/diff/prepare.md、docs/process/beginWork.md 等章节,从调用入口、判定流程、删除语义到练习题,完整梳理单节点 Diff 的源码实现与设计动机。读完本文,你将能够徒手写出reconcileSingleElement的核心判定逻辑,并准确解释key与type在节点复用中的各自作用。
从reconcileChildFibers到reconcileSingleElement
在 docs/diff/prepare.md 中我们了解到,Diff 算法是Reconciler的核心:对于update的组件,beginWork会将其与该组件上次更新时对应的Fiber 节点(即current fiber)进行比较,比较的结果生成新的Fiber 节点(即workInProgress fiber)。
Diff 的入口函数是reconcileChildFibers,它会根据newChild(即本次更新的JSX 对象)的类型分发到不同的处理函数:
function reconcileChildFibers( returnFiber: Fiber, currentFirstChild: Fiber | null, newChild: any, ): Fiber | null { const isObject = typeof newChild === 'object' && newChild !== null; if (isObject) { // object 类型,可能是 REACT_ELEMENT_TYPE 或 REACT_PORTAL_TYPE switch (newChild.$$typeof) { case REACT_ELEMENT_TYPE: // 调用 reconcileSingleElement 处理 // ...其他 case } } if (typeof newChild === 'string' || typeof newChild === 'number') { // 调用 reconcileSingleTextNode 处理 // ...省略 } if (isArray(newChild)) { // 调用 reconcileChildrenArray 处理(多节点 Diff,见 docs/diff/multi.md) // ...省略 } // 以上都没有命中,删除节点 return deleteRemainingChildren(returnFiber, currentFirstChild); }按照同级节点的数量,Diff 被划分为两类:
- 单节点 Diff:
newChild类型为object、number或string,代表同级只有一个节点,进入reconcileSingleElement/reconcileSingleTextNode; - 多节点 Diff:
newChild类型为Array,同级有多个节点,进入reconcileChildrenArray(详见 docs/diff/multi.md)。
本文讨论的正是第一种情况:以类型object(REACT_ELEMENT_TYPE)为例,它最终会进入reconcileSingleElement。
reconcileSingleElement做了什么
reconcileSingleElement是单节点 Diff 的核心函数,它接收三个参数:
returnFiber:当前正在执行beginWork的父级Fiber 节点;currentFirstChild:上一次更新中第一个子Fiber 节点(单链表头);element:本次更新的ReactElement(即JSX 对象)。
其完整执行流程可以概括为两步:
- 遍历上一次更新的兄弟 fiber 链,寻找本次
element能否复用的DOM 节点; - 根据能否复用决定返回:能复用则返回复用的
fiber,不能复用则删除旧节点并新建Fiber(源码中创建新Fiber的部分省略)。
function reconcileSingleElement( returnFiber: Fiber, currentFirstChild: Fiber | null, element: ReactElement ): Fiber { const key = element.key; let child = currentFirstChild; // 首先判断是否存在对应 DOM 节点 while (child !== null) { // 上一次更新存在 DOM 节点,接下来判断是否可复用 // 首先比较 key 是否相同 if (child.key === key) { // key 相同,接下来比较 type 是否相同 switch (child.tag) { // ...省略 case default: { if (child.elementType === element.type) { // type 相同则表示可以复用 // 返回复用的 fiber return existing; } // type 不同则跳出 switch break; } } // 代码执行到这里代表:key 相同但是 type 不同 // 将该 fiber 及其兄弟 fiber 标记为删除 deleteRemainingChildren(returnFiber, child); break; } else { // key 不同,将该 fiber 标记为删除 deleteChild(returnFiber, child); } child = child.sibling; } // 创建新 Fiber,并返回 ...省略 }可以看到,这个函数做的事情就是拿着本次更新的element,遍历上一次更新的fiber单链表,判断能否复用,并在决定不复用时清理旧节点。
React 预设的三个限制(复用判定的前提)
在 docs/diff/prepare.md 中强调过:React 为了把 Diff 的算法复杂度从 O(n³) 降下来,预设了三条限制:
- 只对同级元素进行 Diff——节点跨越层级就不会尝试复用;
- 两个不同类型的元素会产生出不同的树——元素由
div变为p,React 会销毁div及其子孙节点并新建p的树; - 开发者可以通过
key prop暗示哪些子元素在不同的渲染下能保持稳定。
单节点 Diff 的代码正是这三条限制的直接体现:
从代码可以看出,React 通过先判断
key是否相同,如果key相同则判断type是否相同,只有key与type都相同时,一个DOM 节点才能复用。
删除语义的两个关键细节
代码中有两处删除调用,它们的语义差异值得细读:
- 当
child !== null且key 相同且type 不同时,执行deleteRemainingChildren(returnFiber, child),将child及其兄弟 fiber 全部标记删除; - 当
child !== null且key 不同时,仅执行deleteChild(returnFiber, child),将child这一个 fiber 标记删除,然后child = child.sibling继续遍历。
为什么会有这种差别?考虑如下例子:
// 当前页面显示的 ul > li * 3 // 这次需要更新的 ul > p本次更新只有一个p,属于单一节点的 Diff。在reconcileSingleElement中会遍历之前的 3 个fiber(对应 DOM 为 3 个li),寻找本次的p是否可以复用其中某个fiber的DOM。
- 当
key相同且type不同时:代表已经找到了本次p对应的上次的fiber,但p与li的type不同,不能复用。既然唯一可能复用的候选已经排除了,剩下的fiber就都没有机会了,所以需要把child及其所有兄弟都标记删除(deleteRemainingChildren)。 - 当
key不同时:只代表当前遍历到的这个fiber不能被p复用,后面还有兄弟fiber尚未遍历到,所以只标记当前这一个fiber删除(deleteChild),并继续向后遍历。
一句话总结:type不同意味着"没希望了,全删";key不同只是"这一个不行,再看看下一个"。
从beginWork看 Diff 的触发时机
单节点 Diff 并非凭空发生,它由 render 阶段"递"过程中的beginWork驱动。在 docs/process/beginWork.md 中可以看到,对于常见的FunctionComponent/ClassComponent/HostComponent,beginWork最终会进入reconcileChildren:
export function reconcileChildren( current: Fiber | null, workInProgress: Fiber, nextChildren: any, renderLanes: Lanes ) { if (current === null) { // 对于 mount 的组件:创建新的子 Fiber 节点 workInProgress.child = mountChildFibers( workInProgress, null, nextChildren, renderLanes, ); } else { // 对于 update 的组件:执行 Diff,将比较的结果生成新 Fiber 节点 workInProgress.child = reconcileChildFibers( workInProgress, current.child, nextChildren, renderLanes, ); } }也就是说,Diff 只发生在update(current !== null)场景;mount时走的是mountChildFibers,直接创建全新的子Fiber 节点。reconcileChildFibers与mountChildFibers逻辑基本一致,唯一区别是前者会为生成的Fiber 节点带上effectTag,而后者不会。
结合 docs/process/doubleBuffer.md 的双缓存机制可以更完整地理解:current Fiber 树对应屏幕上已显示的内容,workInProgress Fiber 树在内存中构建,构建完成后通过切换current指针完成DOM更新。reconcileSingleElement返回的"复用的 fiber"正是workInProgress Fiber 树中的节点,它通过alternate属性与current fiber相连。
实战:四道练习题巩固复用判定
请判断如下JSX 对象对应的DOM 元素是否可以复用:
// 习题1 更新前 <div>ka song</div> // 更新后 <p>ka song</p> // 习题2 更新前 <div key="xxx">ka song</div> // 更新后 <div key="ooo">ka song</div> // 习题3 更新前 <div key="xxx">ka song</div> // 更新后 <p key="ooo">ka song</p> // 习题4 更新前 <div key="xxx">ka song</div> // 更新后 <div key="xxx">xiao bei</div>公布答案:
- 习题1:不能复用。未设置
key prop时默认key = null,所以更新前后key相同(都为null),但更新前type为div、更新后为p,type改变则不能复用。 - 习题2:不能复用。更新前后
key改变("xxx"→"ooo"),不需要再判断type,直接判定不能复用。 - 习题3:不能复用。更新前后
key改变("xxx"→"ooo"),不需要再判断type,直接判定不能复用。注意本题type也变了,但判定在key这一层就已经结束。 - 习题4:可以复用。更新前后
key与type都未改变,判定为可复用;但children由ka song变为xiao bei,复用的DOM 节点的子元素仍然需要更新。
总结:单节点 Diff 的判定口诀
单节点 Diff(reconcileSingleElement)的核心逻辑可以浓缩为一句话:
先比key,再比type;key不同逐个删,key同type不同全删重建,key、type都相同才复用。
后续你可以继续阅读 docs/diff/multi.md 学习同级多个节点(Array类型)的 Diff:那里会引入两轮遍历,并用lastPlacedIndex与existingChildrenMap 来判断节点的移动,是 Diff 算法中最精髓的部分。
- 文档
- 前端
【免费下载链接】just-react
「React技术揭秘」 一本自顶向下的React源码分析书
相关推荐
React 多节点 Diff 深入解析:两轮遍历与 lastPlacedIndex 移动判定(just-react 源码分析)
React 多节点 Diff 深入解析:两轮遍历与 lastPlacedIndex 移动判定(just react 源码分析) 本篇文章源自开源仓库《React
文档前端戴森球计划终极工厂蓝图库:3000+设计方案快速建造高效星际工厂
戴森球计划终极工厂蓝图库:3000+设计方案快速建造高效星际工厂 还在为戴森球计划中复杂的工厂设计而烦恼吗?FactoryBluePrints蓝图库为你提供了完
游戏开发react-native-reanimated v1 节点详解:`and` 逻辑与节点的短路求值机制
react native reanimated v1 节点详解: and 逻辑与节点的短路求值机制 导读 本文深入讲解 react native reanima
移动开发前端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考