React 19:把属性 diff 从渲染阶段挪进 commit 阶段 —— prepareUpdate/updatePayload 的终结
很多 React 教程(尤其是写 React 16–18 的书)里会有这样一段:"HostComponent 在 completeWork 的更新流程中,属性发生变化时,会把 diff 的结果以 ['title', 1, 'style', {'color': '#333'}] 这样的扁平数组保存在 updateQueue 里。"
这段描述是真的,但只对 React 16–18 成立。React 19 干了一件大事:把 prepareUpdate 整个删掉,属性 diff 从"渲染阶段预计算"挪到了"commit 阶段边 diff 边应用"。这个改动同时回答了一个经典困惑——updateQueue 到底是"环形链表"还是"key-value 数组"?答案是:它俩都对,因为 updateQueue 是"多态"的,而这个改动恰好把其中一个形态清掉了。
本文扒开这个改动的全部细节:前后机制、diffProperties 怎么生成数组、为什么要删(PR #26583)。
1. React 16–18:渲染时算好 diff,commit 照单执行
React 18(packages/react-reconciler/src/ReactFiberCompleteWork.new.js)里,completeWork 处理已有 HostComponent 的更新分支长这样:
const updatePayload = prepareUpdate(
instance,
type,
oldProps,
newProps,
rootContainerInstance,
currentHostContext,
);
// 把算好的 diff 结果存进 updateQueue
workInProgress.updateQueue = (updatePayload: any);
// 有变化才打 Update 标记
if (updatePayload) {
markUpdate(workInProgress);
}
prepareUpdate(DOM 实现里叫 diffProperties)做的事情是:逐属性比较 oldProps 与 newProps,把"要变更的属性"压进一个扁平数组:
['title', 1, 'style', {'color': '#333'}]
^^^^^^ ^ ^^^^^ ^^^^^^^^^^^^^^
key val key val
规则包括:
- 新增/修改的属性 →
push(propKey, propValue) - 被删除的属性 →
push(propKey, '')(空字符串,应用时清掉) - style 特殊处理 → 先把旧 style 里的属性收集成
styleUpdates = {color: ''}(清掉消失的样式),再把新 style 差异合并进去 children/dangerouslySetInnerHTML/ 合成事件等 → 直接跳过,不走这个数组
然后 commit 的 mutation 阶段:
commitUpdate(instance, updatePayload, type, oldProps, newProps)
→ updateProperties(domElement, updatePayload, type, oldProps, newProps)
→ 两两一组 (i, i+1) 应用到 DOM
好处:diff 只在渲染阶段算一次,commit 阶段只做"照单执行"的机械活;updatePayload === null 时连 Update 标记都不打——这是一个"深度 bailout":只要属性没变,commit 阶段整条链都跳过这个节点。
2. React 19:删掉 prepareUpdate,commit 现场边 diff 边应用
React 19(v19.2.7)里,同一个更新分支变成了:
// ReactFiberCompleteWork.js
function updateHostComponent(current, workInProgress, type, newProps, renderLanes) {
if (supportsMutation) {
const oldProps = current.memoizedProps;
if (oldProps === newProps) {
return; // 引用相等 → 直接 bailout
}
markUpdate(workInProgress); // 只打一个 Update 标记,不算 diff、不存数组
}
}
commit 阶段:
// ReactFiberConfigDOM.js
commitUpdate(domElement, type, oldProps, newProps)
→ updateProperties(domElement, type, oldProps, newProps) // 边 diff 边应用
updateProperties 把 v18 的 diffProperties(生成数组)和"应用数组"合并成了单次遍历:对每个属性直接比较 lastProps vs nextProps,变了就立刻 setProp。没有中间数组,updateQueue 也不再承担 HostComponent 的属性 diff 载体。
对比:
| React 16–18 | React 19 | |
|---|---|---|
| diff 发生在哪 | 渲染阶段(completeWork) | commit 阶段(mutation) |
| 产物 | 扁平数组 [k,v,k,v,…] | 无(边 diff 边改 DOM) |
| 存哪 | workInProgress.updateQueue | 不存 |
| 提交阶段 | 照单执行 | 现场计算 + 应用 |
| bailout | payload===null 深度跳过 | 只能靠 oldProps===newProps 引用相等 |
3. 为什么要删?(PR #26583,作者 Sebastian Markbåge)
这个改动的提交信息非常坦诚,直接摆出了代价与收益:
Diff properties in the commit phase instead of generating an update payload (#26583)"This removes the concept ofprepareUpdate(), behind a flag."
收益(为什么这么做)
- 省内存分配:不再每次渲染为每个有变化的元素分配一个数组;
diffProperties里数组是[]起步、可能扩容,元素多时可能产生多个临时对象。commit 阶段全部省掉。 - 总工作量更少:diff 只在一处做一遍,不再"渲染算一遍 + commit 应用一遍"。
- 为未来优化铺路:既然在 commit 阶段做单次循环,就可以"在一个循环里把需要的所有属性读出来",避免对 props 的多态(polymorphic)读取——这是后续重构的基础。
- 统一 host config:React Native(生成 payload 再应用)和 React Fabric(渲染阶段做)各有一套,这个改动把它们统一成"单一宿主配置",更一致。
代价(作者列出的 downsides)
- children-only 也会触发 commit 更新:如果只有
children变了,v18 里diffProperties会跳过 children、返回 null → 不 markUpdate;v19 只能靠引用比较,children属性对象变了就会排一个 commit 更新(虽然遍历本来就会经过它,额外开销不大)。 - commit 阶段更重:对一棵"大部分没变"的大树,commit 停留时间变长。
- 失去深度 bailout:v18 的
payload === null是一种"精确知道啥也没变"的信号;v19 没了这层,特殊场景要做的活变多。 - 代码重复:每个特殊 case(input/select/textarea…)都要自己复制一遍"清理旧属性"的循环。
落地过程:先是 flag,后 Ship
- 2023-04 PR #26583:
ca41adb8c1把这个行为放到diffInCommitPhase开关后面; - 随后 PR #27409:
7f6201889e"Ship diffInCommitPhase"——Meta 内部性能测试结果中性,于是默认开启; - React 19 正式版:开关被彻底删除,成为唯一路径。
"Meta 测试中性"这个结论很重要——它不是一次"大幅性能优化",而是一次架构净收益:性能不变,但代码更少、分配更少、宿主配置更统一。
更深一层的动机:渲染阶段应当是"纯的"
React 19 的核心方向是默认并发(concurrent by default)。并发渲染意味着渲染随时可能被 shouldYield 打断、整棵 workInProgress 树被丢弃重来。在渲染阶段预计算 updatePayload 属于"宿主相关的副作用",如果这次渲染被放弃,那数组就是白算的。挪到 commit 阶段后:
- diff 只会在最终提交的那棵树上计算一次;
- 渲染阶段保持纯净(只做 JS 层面的树构建),宿主细节全部收敛到 commit。
4. React 19 里 HostComponent 的 updateQueue:现在是 null
既然 React 19 删掉了数组,那 HostComponent 的 updateQueue 现在是什么形态、干什么用?
答案是:null,彻底闲置。 Fiber 构造函数里 updateQueue 初始就是 null(ReactFiber.js:161),而 v19 里没有任何代码会去给 HostComponent 设置或读取它——completeWork 的 HostComponent 分支(ReactFiberCompleteWork.js:1337)只做一件事:updateHostComponent 里 markUpdate(workInProgress) 打一个 Update flag。属性 diff 要的 old/new props 直接来自 current.memoizedProps 和 workInProgress.memoizedProps,完全不走 updateQueue。
所以"19 里 HostComponent 的 updateQueue 干什么用"的答案是:什么都不干。它保留在 Fiber 结构里,纯粹是因为 Fiber 是通用数据结构——updateQueue 字段对别的 fiber 类型还有用:
| fiber 类型 | updateQueue 的形态与用途(React 19) |
|---|---|
| HostRoot | 状态更新链表(render() 的元素入队) |
| 类组件 | 状态更新链表(base + pending) |
| hook(useState/useReducer) | hook 自己的 queue(不是 fiber.updateQueue) |
| Suspense / Offscreen | retry 队列(offscreenQueue.retryQueue,ReactFiberCompleteWork.js:1964) |
| HostComponent | null,无用途 |
换句话说,React 19 把 HostComponent 的 updateQueue 从"重载字段"降级成了"空字段"——属性 diff 的载体被彻底移除,"需要更新"的信号由 Update flag 单独承担,old/new props 在 commit 时现取。
5. 那"updateQueue 是环形链表还是 key-value 数组"到底谁对?
两种说法都对,因为 updateQueue 是多态的——同一个字段在不同 fiber 类型上装的东西完全不同:
| fiber 类型 | updateQueue 装什么 | 结构 |
|---|---|---|
| HostRoot / hook / 类组件 | 状态更新(Update 节点) | 链表(pending 环形链 / base+pending 单向链) |
| HostComponent(16–18) | 属性 diff 结果 | 扁平 key-value 数组 ['title',1,'style',{…}] |
| HostComponent(19+) | 空(见上一节) | 无 |
React 19 这次改动,顺带把 HostComponent 这个"数组形态"清掉了:updateQueue 的多态程度下降,基于 19 的教材不会再出现"updateQueue 是 key-value 数组"的说法——而"环形链表"的形象反而更纯粹,因为它只属于状态/副作用队列。
6. 简化后的实现示意
把 v19 的机制抽象成伪代码,就是三步:渲染阶段只打标记,commit 阶段现场 diff:
// 渲染阶段 completeWork(更新分支)
function updateHostComponent(current, workInProgress, type, newProps, renderLanes) {
const oldProps = current.memoizedProps;
if (oldProps === newProps) {
return; // 引用相等 → bailout
}
markUpdate(workInProgress); // 只打 Update flag,不算 diff、不存数组
}
// commit 阶段 mutation
commitUpdate(domElement, type, oldProps, newProps)
→ updateProperties(domElement, type, oldProps, newProps) // 边 diff 边应用
reconciler 与宿主彻底解耦:reconciler 只负责"这个节点需要更新"(打 flag),"怎么更新"完全交给宿主在 commit 时决定。整个 reconcile 层再也看不到 prepareUpdate / updatePayload——它们已经属于历史。
7. 一句话总结
React 19 把 HostComponent 的属性 diff 从**渲染阶段的预计算(扁平数组存进
updateQueue)**挪到了 commit 阶段的现场 diff + 应用,并删掉了prepareUpdate。收益是省掉临时分配、渲染阶段保持纯净、宿主逻辑统一;代价是失去深度 bailout、commit 变重。updateQueue在 HostComponent 上从此是null,环形链表的形象也更纯粹。
参考
- React PR #26583
ca41adb8c1— Diff properties in the commit phase instead of generating an update payload - React PR #27409
7f6201889e— Ship diffInCommitPhase - React 18.2.0
ReactFiberCompleteWork.new.js/ReactDOMComponent.jsdiffProperties - React 19.2.7
ReactFiberCompleteWork.js/ReactFiberConfigDOM.jsupdateProperties
