跳到主要内容

1 篇博文 含有标签「React 19」

查看所有标签

React 19:把属性 diff 从渲染阶段挪进 commit 阶段 —— prepareUpdate/updatePayload 的终结

· 阅读需 8 分钟

很多 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)做的事情是:逐属性比较 oldPropsnewProps,把"要变更的属性"压进一个扁平数组

['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–18React 19
diff 发生在哪渲染阶段(completeWork)commit 阶段(mutation)
产物扁平数组 [k,v,k,v,…]无(边 diff 边改 DOM)
存哪workInProgress.updateQueue不存
提交阶段照单执行现场计算 + 应用
bailoutpayload===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 of prepareUpdate(), behind a flag."

收益(为什么这么做)

  1. 省内存分配:不再每次渲染为每个有变化的元素分配一个数组;diffProperties 里数组是 [] 起步、可能扩容,元素多时可能产生多个临时对象。commit 阶段全部省掉。
  2. 总工作量更少:diff 只在一处做一遍,不再"渲染算一遍 + commit 应用一遍"。
  3. 为未来优化铺路:既然在 commit 阶段做单次循环,就可以"在一个循环里把需要的所有属性读出来",避免对 props 的多态(polymorphic)读取——这是后续重构的基础。
  4. 统一 host config:React Native(生成 payload 再应用)和 React Fabric(渲染阶段做)各有一套,这个改动把它们统一成"单一宿主配置",更一致。

代价(作者列出的 downsides)

  1. children-only 也会触发 commit 更新:如果只有 children 变了,v18 里 diffProperties 会跳过 children、返回 null → 不 markUpdate;v19 只能靠引用比较,children 属性对象变了就会排一个 commit 更新(虽然遍历本来就会经过它,额外开销不大)。
  2. commit 阶段更重:对一棵"大部分没变"的大树,commit 停留时间变长。
  3. 失去深度 bailout:v18 的 payload === null 是一种"精确知道啥也没变"的信号;v19 没了这层,特殊场景要做的活变多。
  4. 代码重复:每个特殊 case(input/select/textarea…)都要自己复制一遍"清理旧属性"的循环。

落地过程:先是 flag,后 Ship

  1. 2023-04 PR #26583ca41adb8c1 把这个行为放到 diffInCommitPhase 开关后面;
  2. 随后 PR #274097f6201889e "Ship diffInCommitPhase"——Meta 内部性能测试结果中性,于是默认开启;
  3. 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 初始就是 nullReactFiber.js:161),而 v19 里没有任何代码会去给 HostComponent 设置或读取它——completeWork 的 HostComponent 分支(ReactFiberCompleteWork.js:1337)只做一件事:updateHostComponentmarkUpdate(workInProgress) 打一个 Update flag。属性 diff 要的 old/new props 直接来自 current.memoizedPropsworkInProgress.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 / Offscreenretry 队列(offscreenQueue.retryQueueReactFiberCompleteWork.js:1964
HostComponentnull,无用途

换句话说,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.js diffProperties
  • React 19.2.7 ReactFiberCompleteWork.js / ReactFiberConfigDOM.js updateProperties