Skip to content

第三章:重新计算全部界面,怎样没有慢到不可用

React 的开发体验建立在一句看似奢侈的话上:状态变化后,再调用一次 render。要让它成立,运行时至少要解决三个问题:

  1. 两次 render 返回的新旧对象之间,哪些代表同一个组件?
  2. 哪些变化只是 JavaScript 中间结果,哪些必须真正触碰 DOM?
  3. 更新期间的事件、selection、生命周期回调和错误怎样保持一致?

“Virtual DOM”常被用作统一答案,但它更像一个传播标签。React 真正依赖的是一组协议:element 是不可变描述,reconciler 用启发式规则恢复身份,renderer 把确定的变化提交到宿主环境,transaction 和事件系统保护提交过程。

Element 不是组件实例

JSX 最终产生 React element。它通常包含 typekeyrefprops,描述“希望出现什么”,但不拥有 DOM 节点,也不等同于用户 class 的实例。每次 render 都可以创建新的 element 对象;React 通过它们决定是否复用已有实例和宿主节点。

这种分离极其关键。若 JSX 直接创建 DOM,render 就会立即产生不可逆副作用,无法安全比较、取消或重做。element 足够轻,才允许 React 先在内存里计算,再选择性提交。

React 0.14 后,element 与 DOM renderer 的分离在包结构上更明确;Fiber 后,它又成为 current tree 与 work-in-progress tree 之间传递输入的基础;Server Components 中,element/模块引用甚至可以部分编码进 Flight 流。

Reconciliation 是带约束的身份恢复

通用树编辑距离成本很高,React 没有试图寻找数学意义上的最优 diff,而是采用适合 UI 的启发式:

  • 根元素类型改变,旧子树通常被卸载并建立新子树。
  • 类型相同,React 尝试保留已有实例或宿主节点,只更新 props 并继续比较 children。
  • 同级列表用 key 帮助匹配,否则主要依赖位置。

这些规则把问题从任意树比较降到近似线性遍历,但也使组件状态的保留具有明确语义。状态不是跟着 JSX 文本或变量名走,而是跟着 React 在树中识别出的身份走。类型、父级位置与 key 一起决定这个身份。

因此,key 不是单纯的性能提示。列表重排时没有稳定 key,React 可能把“位置 0 的旧组件”复用于“位置 0 的新数据”,导致输入状态、动画或订阅看似串位。反过来,主动改变 key 可以要求 React 把同一位置当成新实例并重置状态。

本文判断:React 的 reconciliation 不只是优化算法,它实际上定义了一套组件身份模型。很多被称为“React 状态 bug”的问题,本质上是应用代码对身份的理解与 reconciler 不一致。

为什么旧实现被称为 Stack Reconciler

初期 React 按组件树递归调用 mount/update。JavaScript 调用栈自然保存父子返回位置:父组件 render 出子组件,进入子组件,再从子组件返回。代码直观,调试栈也接近组件嵌套。

但调用栈有一个硬约束:一旦进入深层同步递归,React 不能把当前栈完整保存起来,让浏览器先处理输入,再从任意节点恢复。它可以通过批处理减少重复更新,却不能把一次很长的 render 拆成可暂停工作。

这个限制在早期并不致命。React 的主要目标是替代难以推理的绑定网络,浏览器与应用规模也与后来不同。随着复杂交互、动画和移动设备成为主要场景,同步递归才从实现细节变成架构上限。

Transaction:把提交前后的条件收拢起来

初始代码中的 ReactReconcileTransaction.js 展示了早期 React 如何保护一次更新。Transaction 可以注册 wrapper:执行主体前依次初始化,结束后反向关闭。React 用它暂时关闭事件、保存并恢复 selection、积累 mount-ready callback 等。

它解决的不是“数据库事务式回滚”,而是把散落的前置与收尾逻辑变成一个结构化边界。无论更新主体中间经过多少组件,外界观察到的 DOM、焦点和 lifecycle 顺序都应满足约定。

Fiber 后,术语和实现发生变化,但思想仍在:render/reconciliation 阶段可以准备工作,commit 阶段才集中发生宿主 mutation、layout effect 与 ref 更新。早期 transaction 是这条边界的祖先。

合成事件不是为了发明另一套事件语法

2013 年浏览器事件差异仍然显著。React 通过顶层事件委托收集原生事件,再交给插件系统提取 SyntheticEvent,排队并按组件层级分发。初始 EventPluginHub.js 已把“注册插件、提取事件、执行 dispatch”分开。

这样做有三层收益:

  • 对应用提供一致的事件字段与传播行为。
  • 减少为每个节点单独安装监听器的成本。
  • 让 responder、change、beforeInput 等不同语义通过插件接入。

事件层也把 React 树与 DOM 树进一步区分开。Portals 后,子节点可以渲染到另一处 DOM container,但事件仍能沿 React 组件层级传播。React 17 又把顶层 listener 从 document 移到 root container,方便多个版本和渐进升级共存。事件系统因此不是外围工具,而是 renderer 语义的一部分。

批处理:减少中间状态暴露

多次 setState 若立即各自走完整更新,会产生重复 render 和可能被外界观察到的中间 DOM。React 在事件处理等受控边界内批量积累更新,统一协调并提交。早期 batching 与 transaction、更新队列紧密相连;React 18 的 automatic batching 又把范围扩展到更多异步边界。

批处理不会保证读取到“刚刚 set 的新 state”。它改变的是更新被调度和提交的时机。开发者看到的是状态快照,而不是一个同步可变字段。class 时代这一点容易被 this.state 的对象外观掩盖;Hooks 时代,闭包快照让它变得更加明显。

DOM 最小化并不等于总工作最小化

React 可能重新执行许多组件,创建许多 element,再发现最终 DOM 无需变化。用单一 benchmark 比较“更新了几个节点”容易认为这很浪费。但 React 优化的是另一种总成本:

  • 应用代码不必维护细粒度依赖图。
  • 每次更新的控制流更集中。
  • 运行时可以统一批处理、错误处理、调度和跨平台输出。
  • 纯计算比 DOM mutation 更容易取消、重复和在服务器执行。

当然,这个交换并非总赢。很大的树、昂贵的用户计算、频繁变化的 context、缺乏稳定 key 或不必要的对象创建都可能造成性能问题。React 因此先后提供 shouldComponentUpdatePureComponentmemouseMemouseCallback,最终又尝试用 Compiler 自动处理部分缓存。

本文判断:React 没有消除依赖追踪,而是先选择较粗的组件树作为依赖边界,后来再通过 bailout、lanes、Suspense 和 compiler 逐步恢复精度。这样做让正确模型先成立,再逐层购买性能。

早期契约带来的长期后果

到 2015 年,React 已形成几项很难再撤回的公共承诺:

  • UI 由 component render 描述。
  • state 与组件身份关联。
  • render 可能被再次调用,不应产生副作用。
  • DOM 更新由 React 统一提交。
  • 用户可以通过 key、ref 和 lifecycle 影响身份与外部同步。

这些承诺让 API 具有连续性,也给内部实现留下压力。Stack Reconciler 可以继续变快,却无法在不改变调用模型的前提下暂停递归;生命周期允许在提交前执行副作用,又与未来可重放 render 冲突;DOM 与 core 混在同一包,也阻碍其他 renderer 成为一等公民。

解决这些问题之前,React 还要面对另一个空缺:它只管理组件树,却不规定应用数据怎样流动。下一章从 Flux 开始,看一个“只做 View”的库如何把更大的架构问题交给生态系统。

Engineering history, reconstructed from primary sources.