ursb.me / notes
FIELD NOTE / 07 前端框架 Framework Internals 2026

一次 setState
一生

The life of a setState
in React.

一次 setState 从触发到屏幕上像素更新,要经过 Reconciler 的两次树遍历、Scheduler 的 31 条优先级车道、Commit 的三个子阶段,最后才落到 DOM。
这是 React 19 渲染流水线的全景手册。

From a setState call to a pixel changing on screen: two tree traversals in the reconciler, 31 priority lanes in the scheduler, three commit sub-phases, then DOM. This is a field map of the React 19 rendering pipeline.

React 渲染流水线 · render → commit → scheduler React render pipeline · render → commit → scheduler ▸ live pulse
jsxelementfiberbeginWorkdiffcompleteWorkbeforemutationlayoutlaneslicesuspense
CHAPTER 01

三个公式 — React 到底是什么

Three formulas — what React really is

UI = f(state) · diff(prev, next) · schedule(work)

UI = f(state) · diff(prev, next) · schedule(work)

把 React 拆到只剩骨架,它就是三个公式。第一个说明它写什么、第二个说明它怎么算、第三个说明它什么时候算。三句话之外的东西——JSX 语法糖、Hooks API、Suspense、Server Components——都是这三句话的不同表达。

Strip React to the bone and you get three formulas. The first says what it writes, the second how it computes, the third when it computes. Everything else — JSX, Hooks, Suspense, Server Components — is just a different surface of those three lines.

FORMULA · the three lines of React
UI = f( state ) — 视图是状态的纯函数 ② ΔDOM = diff( UIprev, UInext ) — 只把变化的部分提交 work = schedule( work, priority ) — 高优打断低优 把这三个等式跑在一帧 16.7 ms 里,就是 React 19。

公式①:声明式的代价

Formula ①: the price of declarative

React 的祖传卖点是声明式——你说"长这样",它去搞定"怎么变成这样"。代价是:每次状态变了,理论上需要把整棵 UI 重新算一遍。这条公式只关心"正确性"——和最终 DOM 应该长什么样。它不解决性能,那是公式②要做的事。

React's lineage sells declarative: you say what it looks like, React figures out how to get there. The cost: every state change theoretically re-evaluates the entire UI. This formula is about correctness, not performance — that's what formula ② is for.

公式②:diff 不是 vDOM

Formula ②: diff is not vDOM

很多人把"虚拟 DOM"和"diff"画等号——这是个误会。虚拟 DOM 只是一种表示(JS 对象描述 UI 树),diff 才是算法。React 16 之后这个算法跑在 Fiber 之上,已经不能简单叫"diff"了——它是一棵链表化的树 + 可中断的递归 + effect 收集。但公式仍然成立:你要的是最小变更集合

People often equate "virtual DOM" with "diff" — they aren't. vDOM is a representation (JS objects describing the UI tree); diff is the algorithm. After React 16, the algorithm runs over Fiber, which is more than just diff: a linked-list-tree + interruptible recursion + effect collection. The formula still holds though: the goal is the minimal patch set.

公式③:调度才是 React 17+ 真正的革命

Formula ③: scheduling is the real React 17+ revolution

公式①和②自 2013 年就有了。真正让 React 16 → 18 重写一遍的是公式③:同一个任务的不同部分可以有不同优先级。一次setState 触发的 render 不再是"一口气跑完"——它会被切片、被打断、被丢弃、被重排,然后再继续。这就是 Concurrent Mode 的本质。

Formulas ① and ② have been here since 2013. What forced the full rewrite from React 16 → 18 was ③: different parts of the same task may have different priorities. A render triggered by setState is no longer "one shot, run to completion" — it can be sliced, interrupted, discarded, reordered. That is Concurrent Mode.

FIELD NOTE · 三个公式的归属 ①属于 react 包(核心,定义 createElement / Component);②属于 react-reconciler(Fiber 算法);③属于 scheduler(独立的微型时间切片器,可以脱离 React 使用)。这三个包加在一起一共 ~50 KB gzip。后面每一章都会落到这三个包里的某个文件——React 19 源码的全部秘密都在 packages/ 这个目录里。 Formula ① lives in react (the core — createElement, Component); ② in react-reconciler (the Fiber algorithm); ③ in scheduler (a tiny standalone time-slicer, usable without React). Combined: ~50 KB gzip. Every chapter that follows lands somewhere in one of these three packages.
CHAPTER 02

家谱 — React 的 13 年

A family tree — 13 years of React

从 Jordan Walke 的内部 hack 到 RSC 的标准化

From Jordan Walke's internal hack to the RSC standard

2013 年 5 月,Jordan Walke 在 JSConf US 上演示了一个奇怪的库——它把 HTML 写在 JS 里,每次状态变化重新算整个界面。台下一半人摇头:"这违反 MVC 教义"。十三年后,React 几乎成了前端唯一不需要解释的名字。

这十三年最关键的分水岭

May 2013, JSConf US: Jordan Walke showed a strange library that wrote HTML inside JS and re-evaluated the whole UI on state change. Half the room shook their heads — "this violates MVC". Thirteen years later React is one of the few front-end words you don't need to explain.

The pivotal forks of those thirteen years:

2013 2016 2017 2019 2022 2024-26 React 0.3 Stack · sync React 16 Fiber (rewrite) React 16.8 Hooks React 17 no new features React 18 Concurrent · Lanes React 19 RSC · Actions · use + function components + async rendering + server components
FIG 02·1 React 13 年的五个分水岭。实线为主版本演进,虚线为关键特性。 Fig 02·1 · Five forks in 13 years. Solid arrows: major versions. Dashed: pivotal features.

五次分裂

Five forks

01
2013 · React 0.3 出世
2013 · React 0.3 ships
JSConf US。第一版 Stack Reconciler——递归式同步遍历,无法被打断。这条体质决定了三年后必须重写。
JSConf US. First version: a recursive synchronous Stack Reconciler, uninterruptible. That single body type forced the rewrite three years later.
02
2017 · Fiber 重写
2017 · The Fiber rewrite
React 16 把递归改成链表化的工作循环——beginWork + completeWork + workLoop。第一次允许 render 阶段被打断重启。两年时间,三万行代码。
React 16 turned recursion into a linked-list work loop: beginWork + completeWork + workLoop. For the first time, render could be paused and resumed. Two years, ~30,000 lines.
03
2019 · Hooks 革命
2019 · The Hooks revolution
React 16.8。函数组件第一次拥有了状态——靠的不是闭包黑魔法,是Fiber 节点上的一条链表。当时几乎没人意识到 Hooks 是 Fiber 的副产品。
React 16.8. Function components got state — not via closure magic, but via a linked list on the Fiber node. Few people at the time realised Hooks were a Fiber by-product.
04
2022 · Concurrent 上桌
2022 · Concurrent goes mainstream
React 18 把 Fiber 三年来的"可以中断"变成"真的会中断"——31 条 Lanes、Time Slicing、Transitions、useDeferredValue。新 root API(createRoot)开关全部并发能力。
React 18 turned three years of "can interrupt" into "actually interrupts" — 31 lanes, time slicing, transitions, useDeferredValue. The new root API (createRoot) flipped concurrency on.
05
2024-26 · Server Components 标准化
2024-26 · Server Components go standard
React 19。组件第一次被官方分成 Server / Client / Shared 三类。Server Component 不送 JS 到客户端——直接发送序列化后的 Fiber payload。use、Actions、useOptimistic、Compiler 同步落地。
React 19. Components are officially split into Server / Client / Shared. Server Components ship no JS to the client — they stream a serialized Fiber payload. use, Actions, useOptimistic, the Compiler — all in.
DESIGN NOTE · 一个反常识的事实 DESIGN NOTE · A counter-intuitive fact 这十三年里,React 的公开 API没怎么变——你 2013 年学的 this.setState({...}) 至今依然能跑。变的是底层引擎。这是 React 团队最罕见的能力:他们能把发动机换三次而方向盘不动 In thirteen years, the public surface barely changed — the this.setState({...}) you learned in 2013 still runs today. What changed was the engine. This is React's rarest skill: replace the engine three times, keep the steering wheel still.
CHAPTER 03

为何重写 — Stack Reconciler 的死结

The rewrite — why Stack had to die

递归不可中断 · 浏览器卡顿 · Andrew Clark 的两年

Recursion can't pause · jank · Andrew Clark's two years

React 16 之前的协调器叫 Stack Reconciler——名字来自它本质上是 JS 调用栈上的递归遍历。父组件 render 完了,递归调用每个子组件的 render,再递归子组件的子组件……。优雅,但有一个致命缺陷:JS 栈无法被打断

这是 React 16 重写的唯一原因。

The reconciler before React 16 was called Stack Reconciler — because it was, structurally, recursion on the JS call stack. Parent renders, recursively call children's render, then grandchildren's… Elegant, with one fatal flaw: the JS stack can't be paused.

That is the only reason React 16 was rewritten.

死结的样子

The deadlock

One frame · 16.7 ms @ 60 fps Stack reconciler · 17.4 ms render (uninterruptible) ▶ dropped frame user click @ 6 ms → blocked until 17.4 ms Fiber · 5 ms slices · interruptible click ▲ render yields, click handled within 5 ms
FIG 03·1 同一棵树两种跑法。上:Stack 一口气跑 17 ms,用户点击被阻塞。下:Fiber 切成 5 ms 片,每片之间检查是否要让步——点击立刻被处理。 Fig 03·1 · The same tree, two ways. Top: Stack runs in 17 ms straight, user click is blocked. Bottom: Fiber in 5 ms slices, yielding between slices — click is handled within one slice.
STACK (React 0–15)
递归 · 同步 · 不可中断
Recursive · sync · uninterruptible

processUpdates 一路递归下去,JS 引擎不会让出主线程。中间用户点了按钮,浏览器收到 input 事件但无法分发——它在排队等 React 跑完。组件树深一点,掉帧。

processUpdates recurses down. The JS engine never yields the main thread. If the user clicks mid-render, the input event queues — it has to wait. Deeper trees = dropped frames.

FIBER (React 16+)
链表 · 切片 · 可中断
Linked-list · sliced · interruptible

每个组件不再是栈帧,而是堆上的 Fiber 节点workLoop 每跑完一个 Fiber 调一次 shouldYield()——超过 5 ms 就把控制权交回浏览器,等下一个 idle callback 再继续。

Each component is no longer a stack frame but a heap-allocated Fiber node. workLoop calls shouldYield() after every Fiber — if >5 ms it hands control back to the browser, resumes on the next idle callback.

重写的代价

The cost of rewrite

Andrew Clark(现 Vercel)2015 年开始独立做 Fiber——一开始叫 "ReactFiber",作为实验分支跑了两年。这段时间他写下了 React 史上最重要的代码注释,至今仍在 ReactFiberBeginWork.js 顶部:

Andrew Clark (now at Vercel) started Fiber solo in 2015. The branch ran as an "experiment" for two years. In that time he wrote the most important comment in React's history — still at the top of ReactFiberBeginWork.js:

// This is the function where most of the work happens.
// We will reach into the WorkInProgress fiber, render its children,
// and return the next unit of work to be performed.
function beginWork(current, workInProgress, renderLanes) {
  // ... 2,500 lines of switch(fiber.tag) ...
}

两年里他重写了三次。React 16.0 在 2017 年 9 月 26 日发布——发布那天 GitHub issue 上排着 1500 个等待迁移的项目。但API 完全没变。这就是 Andrew 在 React Conf 2017 talk 里说的那句话:"我们换了引擎,但你不需要换驾照。"

Three full rewrites in two years. React 16.0 shipped September 26, 2017 — 1500 projects queued up to migrate on launch day. But the public API didn't change. As Andrew put it at React Conf 2017: "We changed the engine, you don't need a new driver's license."

CHAPTER 04

三层架构 — core / reconciler / renderer

Three layers — core / reconciler / renderer

为什么同一套 React 能同时跑在 DOM、Native、Three.js 上

how one React runs on DOM, Native, Three.js — all at once

React 不是一个包,是三层包的组合。这种分层让"怎么算"和"往哪儿落"彻底解耦——同一个 Fiber 算法可以驱动 DOM、可以驱动 iOS UIView、可以驱动 Three.js Mesh、可以驱动一个画布。

React isn't one package — it's three layers. The split decouples "how to compute" from "where to commit". The same Fiber algorithm drives DOM, iOS UIView, Three.js mesh, even a canvas.

LAYER 1 · CORE
react
~7 KB gzip。只导出 createElement / Component / Hooks 签名 / Suspense。不知道 DOM 存在 ~7 KB gzip. Exports createElement / Component / Hooks API / Suspense. Doesn't know DOM exists.
LAYER 2 · RECONCILER
react-reconciler
~25 KB gzip。Fiber 算法 + Scheduler 接口。通过 HostConfig 接收平台细节。这层是 React 真正的"大脑"。 ~25 KB gzip. The Fiber algorithm + scheduler hooks. Takes platform details through a HostConfig. This is the real brain.
LAYER 3 · RENDERER
react-dom · native · three
~15 KB gzip (DOM)。实现 HostConfig 30+ 个方法:createInstanceappendChildcommitUpdate……把 reconciler 算出的差异落到具体平台。 ~15 KB gzip (DOM). Implements ~30 HostConfig methods: createInstance, appendChild, commitUpdate… Lands the diff onto the platform.

HostConfig — 三层之间唯一的合同

HostConfig — the only contract between layers

Reconciler 完全不知道自己驱动的是什么——它只调用 HostConfig 上的方法。想做一个新的 renderer?实现这张表就行(节选):

The reconciler has no idea what it's driving — it just calls methods on the HostConfig. Want a new renderer? Implement this table (excerpt):

// react-reconciler/src/ReactFiberHostConfig.js · contract
interface HostConfig<Type, Props, Container, Instance, ...> {
  // — 创建/插入/更新/删除
  createInstance(type, props, root, ctx, fiber): Instance;
  createTextInstance(text, root, ctx, fiber): TextInstance;
  appendInitialChild(parent, child): void;
  appendChild(parent, child): void;
  insertBefore(parent, child, before): void;
  removeChild(parent, child): void;
  commitUpdate(inst, payload, type, oldP, newP): void;

  // — 调度接入
  scheduleTimeout(fn, delay): TimeoutID;
  cancelTimeout(id): void;
  noTimeout: -1;
  supportsMicrotasks: boolean;

  // — 平台行为
  supportsMutation: boolean;   // DOM = true
  supportsPersistence: boolean; // Fabric = true (immutable trees)
  supportsHydration: boolean;   // SSR
}
DOM
react-dom
supportsMutation
React Native
react-native (Fabric)
supportsPersistence
SSR
react-dom/server
stream → string
Three.js
react-three-fiber
community renderer
FIELD NOTE · 一个被忽视的事实 FIELD NOTE · An overlooked fact react-reconciler公开 npm 包,任何人都可以 npm i react-reconciler 写一个自己的 renderer。react-three-fiberreact-pdfreact-canvas 都是这么干的——React 这个名字其实指一种 API + 一个调度器,而非"把 JS 渲染到网页"。 react-reconciler is a public npm package. Anyone can npm i react-reconciler and write their own renderer. That's how react-three-fiber, react-pdf and react-canvas work. "React" really means an API and a scheduler, not "JS to web".
横向对比 · Preact 用 30KB 实现 React API SIDE-BY-SIDE · Preact does it in 30KB Preact 实现了几乎完整的 React API(包括 hooks、Suspense、Context)只用 ~30 KB gzip,大约是 React + ReactDOM 的 40%。怎么做到的?① 没有 Fiber——回归到类似 React 15 Stack 的递归 reconciler。② 没有 lane scheduler——所有更新都是同步。③ 没有 RSC。④ JSX 直接转 h(type, props, ...children) 函数(更紧凑)。代价:concurrent 特性全部缺席, 大型应用上输入会卡。但对 landing page、admin tool、嵌入式 widget 这种小场景, 30KB vs 80KB 的差别值钱。Preact 的存在告诉你:React 的多数 KB 用在 concurrent + scheduling 上, 不在协调本身。 Preact ships almost the full React API (hooks, Suspense, Context) in ~30 KB gzip — about 40% of React + ReactDOM. How? ① No Fiber — back to a React-15-style recursive reconciler. ② No lane scheduler — every update is sync. ③ No RSC. ④ JSX transpiles directly to h(type, props, ...children) (tighter). Cost: no concurrent features; input lag on big apps. But for landing pages, admin tools, embedded widgets, 30 KB vs 80 KB matters. Preact's existence proves: most of React's bytes go to concurrent + scheduling, not reconciliation itself.
CHAPTER 05

流水线全景 — render · commit · scheduler

Pipeline overview — render · commit · scheduler

一张图把 13 步串起来

all 13 stages in one diagram

把 React 19 一次更新摊开,得到 3 个阶段、13 个步骤。Render Phase(蓝)算该改什么;Commit Phase(铜)真的动手改;Scheduler(紫)决定什么时候算、什么时候改。三者不是串行,而是互相穿插——Render 可以被 Scheduler 打断、暂停、丢弃、重启,Commit 一旦开始则一气呵成。

Lay out one React 19 update and you get 3 phases, 13 steps. Render (blue) decides what should change; Commit (copper) actually changes it; Scheduler (violet) decides when to compute, when to commit. Not strictly sequential — Scheduler can pause/resume/discard/restart Render. Once Commit starts, it runs to completion.

RENDER PHASE · 协调 COMMIT PHASE · DOM SCHEDULER · 优先级 06 · JSX → Element 07 · Element → Fiber 08 · Double Buffer 09 · beginWork (递) 10 · Reconciliation 11 · completeWork (归) 12 · Hooks List 13 · Before Mutation 14 · Mutation (DOM) 15a · Layout (sync) 15b · Passive (async) 16 · Lanes 17 · Time Slicing 18 · Suspense effect list priority gate flush gate DOM mutated → browser paints next frame
FIG 05·1 React 19 渲染流水线 · 13 步 / 3 阶段 · 紫色虚线是 scheduler 干预 render/commit 的位置 Fig 05·1 · React 19 pipeline · 13 steps in 3 phases · violet dashes mark where the scheduler gates render & commit.

三个阶段的边界条件

Boundary conditions of the three phases

phasecan interrupt?side-effects?runs inpkg
Renderyes (concurrent)NO — must be pureJS task, slicedreact-reconciler
Commit · BeforeMutationnoread DOM onlymicrotaskreact-reconciler
Commit · MutationnoDOM writes heremicrotaskreact-dom
Commit · Layoutnosync, blocks paintmicrotaskreact-reconciler
Commit · Passiveyes (re-scheduled)async, after paintscheduled taskscheduler
SchedulernoMessageChannel portsscheduler
关键边界 KEY BOUNDARY Render 必须是纯函数——它可能被跑两次(StrictMode)、被丢弃、被打断后重启。任何有副作用的代码(DOM 读、setState 写、setTimeout、订阅)必须落在 useEffect / useLayoutEffect 里。这是 React 18 Concurrent 之后所有"奇怪 Bug"的根因——你以为 render 是一次,但它可能跑两次三次。 Render must be pure. It may run twice (StrictMode), be discarded, be paused and restarted. Any side-effect (DOM read, setState write, setTimeout, subscribe) must live inside useEffect / useLayoutEffect. This is the root of nearly every "weird bug" after React 18 Concurrent — you assumed render runs once; it may run two or three times.
MAIN-LINE ✦

The Counter — 一段贯穿全文的例子

The Counter — the through-line

9 行代码 · 一次点击 · 13 步流水线

9 lines · one click · 13 stages

接下来 13 章每一章都会挂一张 SPECIMEN 卡片——以同一个 Counter 为标本,逐帧记录它在那一个阶段里的状态、数据结构变化、调度决定。读完一遍,你就跟着这个 Counter 走完了它从诞生消亡的一生。下面是它的全部代码:

The next 13 chapters each carry a SPECIMEN card — using the same Counter as the test specimen, recording its state, data-structure changes and scheduling decisions at that exact stage. By the end you'll have followed this Counter through its whole life: from birth to death. Here it is in full:

// Counter.jsx — our specimen for the rest of this article
import { useState, useEffect } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  useEffect(() => { document.title = `#${count}`; }, [count]);

  return (
    <div className="counter">
      <h1>Count: {count}</h1>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

Counter 的一生 · 四幕

The four acts of Counter

为了让接下来 13 章的 SPECIMEN 卡片有同一条时间线,把 Counter 这个标本切成四个时间点。每一章会在其末尾的 SPECIMEN 卡片上注明它讨论的是哪一幕——这样你既能纵向看"原理",也能横向看"Counter 此刻活成什么样"。

To give the SPECIMEN cards a single shared timeline, the Counter's life is sliced into four moments. Each chapter's specimen card labels which act it captures — so you can read down for theory, and across for what the Counter looks like right now.

ACT I
出生 · Mount
Birth · Mount
createRoot(...).render(<Counter/>)。一棵全新的 Fiber 树从无到有,所有 DOM 节点离屏创建。
createRoot(...).render(<Counter/>). A fresh Fiber tree from nothing; every DOM node created off-document.
ACT II
第一次更新 · Update #1
First click · Update #1
用户点击 +1。setCount(1) 入队,SyncLane 调度,复用 fiber,1 次 DOM 写
User clicks +1. setCount(1) enqueues, SyncLane scheduled, fibers reused, 1 DOM write.
ACT III
连点 · Update #2
Rapid click · Update #2
3 ms 内再点一次。两次 setState 被合并进同一次 render——batching 在 task 边界生效。
Another click within 3 ms. Both setStates coalesced into one render — batching at task boundary.
ACT IV
谢幕 · Unmount
Curtain · Unmount
用户切到别的路由。ChildDeletion,递归卸载 ref、跑 effect cleanup,最后从 DOM 摘除。
User routes away. ChildDeletion; recursive ref-detach, effect cleanup, then DOM removal.

第一击的时间线 · ACT II 详解

Timeline of ACT II — the first click

用户点击按钮。React 19 在约 1.2 ms内跑完这整套流水线(在 M2 MacBook + Chrome 130 上的实测中位数)。下面这张图把每一步对应到具体时间:

User clicks. React 19 runs the full pipeline in ~1.2 ms (median on M2 MacBook + Chrome 130). Here's each step against the clock:

t=0 0.3ms 0.7ms 1.0ms 1.2ms click schedule begin/root Counter div h1 button complete (bubble up) before mutation layout passive browser paints frame SCHEDULER RECONCILER (render) RECONCILER+DOM (commit) 用户感知延迟:从点击到 h1 文本变化 ≈ 1.05 ms · useEffect 在 paint 之后才跑 Perceived latency click→repaint ≈ 1.05 ms · useEffect fires after paint
FIG ✦ 点击 +1 后 React 在 ~1.2 ms 内完成全流水线。Passive Effects(useEffect)被刻意推迟到 paint 之后——这就是为什么 document.title 的更新比 DOM 慢一帧。 Fig ✦ · React's full pipeline in ~1.2 ms after a click. Passive Effects (useEffect) are deliberately deferred past the paint — that's why document.title updates one frame late.
本例的几个事实 FACTS ABOUT THIS EXAMPLE 这个 Counter 在 React 19 + ReactDOM 19 下编译运行:① 9 行 JSX 在编译期已被替换为 jsxRuntime.jsx(...) 调用;② setCount 不会立即触发 render——它把更新塞进 dispatch 队列,由 scheduler 决定何时跑;③ 单次点击在 React 19 默认是 SyncLane(同步优先级),不会被切片;④ useEffect 永远在 commit 之后异步执行——这就是它和 useLayoutEffect 的本质区别。 Compiled under React 19 + ReactDOM 19: ① the 9 lines of JSX are rewritten to jsxRuntime.jsx(...) calls at build time; ② setCount doesn't render immediately — it enqueues to a dispatch queue, scheduler decides when; ③ a single click is SyncLane by default in 19 (no slicing); ④ useEffect always runs asynchronously after commit — that's the real difference vs useLayoutEffect.

Counter 长大 — 从 Ch10 起多出一个 TodoList 子组件

Counter grows up — adopts a TodoList child from Ch10 onward

上面那段 Counter 是同一个标本的最简形态。但 React 里有些路径——keyed list diffuseContext 传播useTransition 降优先级Suspense throw promiseRSC + Server Action——必须有≥ 2 层组件 + 列表 + 异步才会激活。所以从 Ch10 起,Counter 多出一个 TodoList 子组件(4 行代码)。名字不变,标本不换——同一棵 Fiber 树长出几片新叶子。每一章的 SPECIMEN 卡上会标 v1 (简版 5 fibers) 还是 v2 (含 TodoList 50+ fibers),你随时知道现在站在哪一帧。

The Counter above is the simplest form of one and the same specimen. But some React paths — keyed list diff, useContext propagation, useTransition demotion, Suspense throws, RSC + Server Action — only light up when you have ≥ 2 component levels + a list + async. So from Ch10 onward, Counter grows a TodoList child (4 extra lines). Same name, same specimen — a single Fiber tree sprouting more leaves. Each chapter's SPECIMEN card labels which version it captures: v1 (5 fibers) or v2 (with TodoList, 50+ fibers). You always know which frame you're standing in.

// Counter.jsx — v2 · same component, one new child & one Suspense wrapper
import { useState, useEffect, useTransition, useContext, use, Suspense } from 'react';
import { ThemeContext } from './contexts';
import { addTodoAction } from './actions';       // Server Action — appears only from Ch20

function Counter({ todosPromise }) {
  const theme = useContext(ThemeContext);                  // ← Ch12B context propagation
  const [count, setCount] = useState(0);
  const [filter, setFilter] = useState('all');              // ← new in v2
  const [pending, startTransition] = useTransition();        // ← Ch17 demotion path
  useEffect(() => { document.title = `#${count}`; }, [count]);

  return (
    <div className={`counter counter--${theme}`}>
      <h1>Count: {count}</h1>
      <button onClick={() => setCount(count + 1)}>+1</button>

      /* TodoList child below — same Counter, more leaves */
      <Suspense fallback={<p>loading…</p>}>
        <TodoList todosPromise={todosPromise} filter={filter} setFilter={(f) => startTransition(() => setFilter(f))} />
      </Suspense>
    </div>
  );
}

// TodoList — pure child, lights up list-diff + Suspense + Server Action paths
function TodoList({ todosPromise, filter, setFilter }) {
  const todos = use(todosPromise);                // ← Ch18 throws if pending
  const visible = filter === 'all' ? todos : todos.filter(t => t.status === filter);

  return <ul>
    {visible.map(t => <li key={t.id}>{t.text}</li>)}    /* ← Ch10 keyed-list diff path */
    <form action={addTodoAction}><input name="text" /></form>  /* ← Ch21 Action */
  </ul>;
}
COUNTER · v1
5 fibers · 9 行 · Ch06-Ch09
最简形态,只有一个 Counter 组件+ 1 个 state + 1 个 effect。用于讲render / commit / useState 链表这些核心机制——简单到能背,trace-card 给具体数字。
Plainest form — one Counter component, one state, one effect. Used for render / commit / useState list core mechanisms. Simple enough to memorize; trace cards give concrete numbers.
COUNTER · v2
50+ fibers · +14 行 · Ch10 起
同一个 Counter,长出 TodoList 子组件 + useTransition + <Suspense>。激活 keyed-list diff (Ch10) / context (Ch12B) / transition (Ch17) / Suspense (Ch18) / Action (Ch20-21) 路径。
Same Counter — now with a TodoList child, useTransition, and <Suspense>. Lights up keyed-list diff (Ch10), context (Ch12B), transition (Ch17), Suspense (Ch18), Action (Ch20-21) paths.
为什么是同一个标本而非两个 Why one growing specimen, not two 读者读复杂章节最怕的是失去坐标: "刚才那个 Counter 哪去了?" "TodoBoard 跟 Counter 什么关系?" 把 v2 设计成v1 加 4 行子组件(而不是另起一棵 Fiber 树),让坐标从头到尾不变。chromium-renderer 的"The Card"、WASM 的"Hot Loop" 都是这个套路——一个例子扛到底。后面 Ch10/12·B/17/18/20 的 SPECIMEN 卡片标 v2 时,你心里知道:还是那个 Counter,只是它学会了一个新动作。 The biggest fear in advanced chapters is losing your bearings: "where did Counter go?" "how does TodoBoard relate to Counter?" Designing v2 as v1 + 4 lines of child component (not a fresh Fiber tree) keeps your coordinates fixed. chromium-renderer's "The Card" and WASM's "Hot Loop" both follow this rule — one specimen, end to end. When Ch10/12·B/17/18/20 specimen cards label themselves v2, you know: same Counter, just learned a new trick.
CHAPTER 06

JSX → React Element — 编译期就完成的一步

JSX → React Element — done at compile time

jsx() 不是 createElement · 不是 vDOM 的等价物

jsx() is not createElement; it is not the virtual DOM

阶段
Phase
RENDER · 起点
运行在
Runs in
babel / swc (build)
输入
Input
JSX syntax
输出
Output
React Element 对象
I · Mount II · Click III · Click² IV · Unmount

JSX 是个语法,不是运行时。它在编译期就被转写为函数调用——React 17 之前是 React.createElement(...),17 之后是 react/jsx-runtime 里的 jsx() / jsxs()。前者每次调用都查 React 这个全局变量;后者直接 import,体积更小、可以做 dev-only 检查。我们的 Counter 编译后长这样:

JSX is syntax, not runtime. The compiler rewrites it to function calls — React.createElement(...) before React 17, jsx() / jsxs() from react/jsx-runtime after. The latter is imported directly, smaller, with dev-only checks. Our Counter compiles to:

// after babel @babel/preset-react · 'automatic' runtime
import { jsx as _jsx, jsxs as _jsxs } from 'react/jsx-runtime';

function Counter() {
  const [count, setCount] = useState(0);
  useEffect(() => { document.title = `#${count}`; }, [count]);

  return _jsxs("div", {
    className: "counter",
    children: [
      _jsx("h1", { children: ["Count: ", count] }),
      _jsx("button", {
        onClick: () => setCount(count + 1),
        children: "+1"
      })
    ]
  });
}

Element 长什么样

What an Element actually is

_jsx() 返回的不是 DOM 节点,是一个普通 JS 对象——React 把它称作 Element。Element 是 React 整个体系里唯一你能直接看见的数据结构:

_jsx() doesn't return a DOM node — it returns a plain JS object React calls an Element. Elements are the only data structure in React you ever directly handle:

// the actual shape — react/src/ReactElement.js
{
  $$typeof: Symbol(react.element),  // 防 XSS · JSON 序列化不能复刻
  type: "div",                  // host string · or function component · or class
  key: null,
  ref: null,
  props: {
    className: "counter",
    children: [ /* nested Elements */ ]
  },
  _owner: null                    // dev only · debug helper
}
FIELD NOTE · $$typeof 的来历 FIELD NOTE · why $$typeof exists 2018 年 Dan Abramov 给 React 加了 $$typeof 这个 Symbol——专门为了防 XSS。攻击者如果能塞一段 JSON 到你的 props 里,他构造不出 Symbol(Symbol 在跨 realm 时唯一),React 会拒绝渲染。这是类型安全在 React 里的第一次真实落地。 Dan Abramov added $$typeof as a Symbol in 2018 — purely an XSS guard. An attacker who controls JSON in your props can't fabricate a Symbol (Symbols are realm-unique), and React refuses to render forgeries. The first place "type safety" actually lands in React's runtime.

Element 是不变的快照

Elements are immutable snapshots

每次 render,Counter() 都返回全新的 Element 对象。Element 是不可变的——它只描述"此刻 UI 应该是什么样",不持有状态、不持有 DOM 引用。状态和 DOM 引用都活在下一章要讲的 Fiber 节点上。

Every render, Counter() returns fresh Elements. They are immutable — they describe what the UI should look like right now. State and DOM refs live on the Fiber nodes (next chapter).

REACT ELEMENT
不可变的描述
Immutable description

每次 render 重新创建。轻量(4-5 个字段)。只有 type / props / key / ref。没有状态。是 React 的"消息"。

Recreated every render. Tiny (4-5 fields). Just type / props / key / ref. No state. It's React's "message".

FIBER NODE
可变的活体
Mutable instance

在内存里常驻。约 60 个字段。持有 state、hooks 链表、DOM 引用、effect flags、调度信息。是 React 的"实体"。

Persists across renders. ~60 fields. Holds state, hooks list, DOM ref, effect flags, scheduling info. It's React's "entity".

很多博客文章把 Element 称作 "vDOM 节点"——技术上没错,但容易让人以为 React 在内存里维护着一棵 vDOM 树。没有这棵树。React 只有:(a)你刚刚 return 出来的 Element 们,(b)一棵 Fiber 树。Element 用完就丢,Fiber 才是常驻的"虚拟世界"。

Many articles call Elements "vDOM nodes" — technically true, misleading in practice. People imagine React keeps a "vDOM tree" in memory. It doesn't. React holds (a) the Elements you just returned and (b) a Fiber tree. Elements are throwaway. Fibers are the persistent "virtual world".

CHAPTER 07

Fiber 节点 — 链表化的树

Fiber node — a tree as a linked list

child · sibling · return —— 三根指针撑起 React 半壁江山

child · sibling · return —— three pointers, half of React

阶段
Phase
RENDER · 数据结构
源码
Source
packages/react-reconciler/src/ReactFiber.js
字段数
Fields
~60
每个组件一个
Per component
~600 B
I · Mount II · Click III · Click² IV · Unmount

Fiber 是 React 16 引入的底层数据结构——名字来自 OS 的"纤程":比线程更轻、可协作调度的执行单元。每个 React 组件实例对应一个 Fiber 节点,每个原生 DOM 元素也对应一个 Fiber 节点。这些节点不是普通的树——它们用链表连起来。

Fiber is the data structure React 16 introduced — name borrowed from OS-level fibers (lighter than threads, cooperatively scheduled). Each component instance maps to one Fiber. So does each host DOM element. They aren't a normal tree — they're connected by linked-list pointers.

三根指针构成一棵树

Three pointers make a tree

HostRoot tag = 3 <Counter /> tag = 0 · FunctionComp <div> tag = 5 · HostComp <h1> tag = 5 <button> tag = 5 child sibling return POINTERS child sibling return 遍历顺序 · DFS ① HostRoot ② Counter ③ div ④ h1 → return ⑤ button → return ⑥ div → return ⑦ done
FIG 07·1 Counter 的 Fiber 树 · 三根指针让""在内存里变成"链表",DFS 不再需要递归 · 这张图的可视化语言直接传承自 Lin Clark 在 ReactConf 2017 的 A Cartoon Intro to Fiber——她第一次把 Fiber 链表的"cooperative scheduling"画成可读的卡通。 Fig 07·1 · The Counter's Fiber tree · three pointers turn the tree into a linked list; DFS no longer needs recursion. The visual vocabulary descends from Lin Clark's A Cartoon Intro to Fiber · ReactConf 2017 — the talk that first made Fiber's "cooperative scheduling" legible as cartoon.

Fiber 字段地图

Field map of a Fiber

React 19 的 Fiber 类约有 60 个字段,按用途可以分成四簇——这是它能同时承担结构、状态、调度、提交四件事的根因:

A React 19 Fiber has ~60 fields, grouped into four clusters — it's how one struct can carry structure, state, scheduling, commit info all at once:

// react-reconciler/src/ReactFiber.js · simplified
type Fiber = {
  // ────── ① 结构 · 链表树
  tag: WorkTag,          // 0 = FunctionComponent, 5 = HostComponent, 13 = Suspense...
  key: null | string,
  type: any,              // "div" | Counter | Suspense ...
  stateNode: any,         // 真实 DOM 节点(HostComponent)/ class 实例(ClassComponent)
  child: Fiber | null,    // 第一个子
  sibling: Fiber | null,  // 下一个兄弟
  return: Fiber | null,   // 父 (注意:不叫 parent!)

  // ────── ② 状态 · 此次 render 的输入输出
  pendingProps: any,       // 这次要 render 的 props
  memoizedProps: any,      // 上一次提交时的 props
  memoizedState: any,      // hooks 链表头(function comp)
  updateQueue: any,        // 未处理的 setState 队列

  // ────── ③ 调度 · 这棵子树的优先级
  lanes: Lanes,           // 自己有更新的 lanes(位掩码)
  childLanes: Lanes,      // 子孙有更新的 lanes(剪枝用)

  // ────── ④ 提交 · 给 commit phase 的清单
  flags: Flags,           // Placement / Update / Deletion / Snapshot ...
  subtreeFlags: Flags,    // 子孙是否有 flags(剪枝用)
  deletions: Array<Fiber>,
  alternate: Fiber | null // 双缓冲的另一棵 (下一章)
};
FIBER · 完整字段地图 (React 19 · ~60 字段 · 按用途 4 簇) ① STRUCTURE · 链表树 tag key elementType type stateNode return child sibling index ref refCleanup alternate ★ 12 个字段 树形结构 · 双缓冲 · ref 绑定 ② STATE · 这次 render 的输入输出 pendingProps memoizedProps memoizedState updateQueue dependencies contextDependencies stateNode (class) callbackNode 8 个字段 props 旧/新 · hooks 链表头 · queue · context 订阅 ③ SCHEDULING · 这棵子树的优先级 lanes childLanes mode callbackPriority pingedLanes / suspendedLanes (root) 5 个字段 (root 多 8 个) lane 位掩码 · mode 决定 sync/concurrent ④ COMMIT · 给 commit phase 的清单 flags subtreeFlags deletions updatePayload nextEffect (legacy 16/17) 5 个字段 flags 自底向上 bubble · 子节点删除清单 ⑤ DEV / PROFILER · 仅 dev / Profiler 启用时 (~16 字段) _debugSource _debugOwner _debugStack (R19) actualDuration selfBaseDuration / actualStartTime 总计约 60 个字段 · ~600 字节/fiber · 每个 mount 加 ~2 字段
FIG 07·2 Fiber 节点的完整字段地图。蓝色 = 树结构(让 React 能遍历),铜色 = 状态(让 React 能记忆),紫色 = 调度(让 React 能切片),绿色 = commit 标记(让 React 能落地)。最下方虚框是 dev/Profiler 字段——生产环境不存在。 Fig 07·2 · Complete Fiber-field map. Blue = structure (React can traverse), copper = state (React can remember), violet = scheduling (React can slice), green = commit flags (React can land). Dashed bottom box is dev/Profiler — absent in production.
为什么不叫 parent Why "return", not "parent" Andrew Clark 用 return 而不是 parent有意的——它精确描述了"子节点完成后控制权返回到哪里"。在一棵深嵌套但有许多并列分支的树上,return 不一定就是结构上的父——它是调用栈意义上的返回点。Fiber 把递归改成了循环,但保留了递归的语义。 Andrew Clark deliberately picked return over parent — it captures "where control returns when this child finishes". In a deep tree with many siblings, return isn't always the structural parent — it's the call-stack return point. Fiber turned recursion into iteration, but kept the call-stack semantics.

Tag 全家谱 — 28 种 Fiber 类型

Tag family tree — 28 Fiber types

前面的代码里 tag 我们只举了 FunctionComponent (0)HostComponent (5) 两种。但 WorkTag 这个 enum 一共有 28 个值(React 19 起)——每一个都对应 beginWork 里 switch 语句的一个 case。理解每个 tag 在干什么,比起背 hooks API 还能让你更"看穿" React。

In earlier code we only mentioned FunctionComponent (0) and HostComponent (5). But the WorkTag enum has 28 values (as of React 19) — each one is a case in beginWork's switch. Knowing what each tag does is, frankly, more "X-ray" than memorizing the hooks API.

▸ 你直接写的

▸ Components you write

tagname你写的形式What you writecommit DOM?
0FunctionComponentfunction Counter() {}
1ClassComponentclass Counter extends Component
11ForwardRefforwardRef((p, ref) => ...)
14MemoComponentmemo(Component)
15SimpleMemoComponentmemo 包裹的函数组件(fast path)memo-wrapped FC (fast path)

▸ 宿主元素(DOM / Native)

▸ Host elements (DOM / Native)

tagname你写的形式What you writecommit DOM?
3HostRootcreateRoot(container)yes (container)
4HostPortalcreatePortal(...)yes (different parent)
5HostComponent<div> / <input> / ...yes
6HostTextJSX 里的纯文本plain text in JSXyes (TextNode)
25HostHoistable<link> / <meta> / <title> (R19)<link> / <meta> / <title> (R19)yes → <head>
26HostSingleton<html> / <body> / <head> (R19)yes (复用现有)

▸ 控制流 / 边界

▸ Control flow / boundaries

tagname作用Purposecommit DOM?
7Fragment<>...</> / <Fragment> · 透明分组transparent groupno
8Mode<StrictMode> · 激活 dev 检查activates dev checksno
13SuspenseComponent<Suspense fallback> · throw 边界throw boundaryno (only marker)
18DehydratedFragmentSSR 流式占位SSR streaming placeholderspecial
19SuspenseListComponent<SuspenseList> (R19 experimental)no
28ThrowR19 · 表示 render 时抛出的R19 · render-time throw markerno

▸ Context · 性能 · 调度

▸ Context · perf · scheduling

tagname作用Purpose何时用When seen
9ContextConsumer<Context.Consumer> · render-prop API极少(多用 useContext)rare (useContext preferred)
10ContextProvider<Context.Provider value>几乎每个 appalmost every app
12Profiler<Profiler onRender>DevTools 用used by DevTools
16LazyComponentlazy(() => import(...))代码分割code splitting
21OffscreenComponent<Activity hidden> (R19 stable) · 隐藏但保留 statehidden, state preserved路由 / tab 切换routing · tabs
23CacheComponentRSC 用 · cache() 的 fiber 表示RSC · fiber rep of cache()RSC only
24TracingMarkerComponent<unstable_TracingMarker>实验中experimental

▸ 内部 / 过渡态 / 几乎用不到

▸ Internal / transient / rarely seen

tagname用途Purpose
2IndeterminateComponent挂载初期 React 还不知道是 FC 还是 Class——render 一次后才决定 tag(→ 0 或 1)Before first render React doesn't know FC vs Class — runs once to decide tag (→ 0 or 1)
17IncompleteClassComponentclass 组件 render 抛错时的恢复态recovery state when class component render throws
22LegacyHiddenComponent即将废弃 · 被 OffscreenComponent 取代deprecated · replaced by OffscreenComponent
27IncompleteFunctionComponent函数组件 render 抛错时的恢复态recovery state for FC throws
FIELD NOTE · 三个新 tag 是 React 19 的"静默革命" FIELD NOTE · Three new tags are React 19's "quiet revolution" tag 25 HostHoistable:让 <title> / <link rel="stylesheet"> 写在任何组件深处都能自动提升到 <head>——以前需要 react-helmet。tag 26 HostSingleton<html> / <body> 现在可以写在 JSX 里,React 会复用已存在的 DOM 节点而非新建。tag 21 OffscreenComponent(React 19 之前是 unstable):<Activity hidden> 让一棵子树暂时隐藏但保留 state——为新 Router 设计的核心原语。这三个 tag 没改 React 公开 API,但把以前要"第三方库 + 黑魔法"才能实现的事变成了内置原语。 Tag 25 HostHoistable: lets <title> / <link rel="stylesheet"> written anywhere deep in the tree auto-hoist to <head> — previously needed react-helmet. Tag 26 HostSingleton: <html> / <body> can now appear in JSX; React reuses existing DOM rather than recreating. Tag 21 OffscreenComponent (now stable in R19): <Activity hidden> lets a subtree hide but preserve state — the core primitive for the new Router. None of these change React's public API, but each turns "third-party lib + dark magic" into a built-in.
CHAPTER 08

双缓冲 — current 与 workInProgress

Double buffer — current vs workInProgress

显卡借来的把戏 · alternate 指针 · 失败回滚

borrowed from GPUs · the alternate pointer · safe rollback

技术
Technique
DOUBLE BUFFER
字段
Field
fiber.alternate
为什么
Reason
可中断 · 可丢弃
代价
Cost
~2x Fiber memory
I · Mount II · Click III · Click² IV · Unmount

游戏引擎渲染时永远维护两块帧缓冲——front buffer 给屏幕看,back buffer 慢慢画。画完了把指针一换,眨眼之间。React Fiber 借了同样的把戏:内存里同时存两棵 Fiber 树——current(已经在屏幕上)和 workInProgress(正在重新计算)。它们通过 fiber.alternate 互相指向。

Game engines keep two frame buffers — front (shown) and back (drawn into). Swap the pointer when done. React Fiber stole the trick: two Fiber trees live simultaneously — current (on screen) and workInProgress (being recomputed). Linked by fiber.alternate.

BEFORE COMMIT current workInProgress alternate HostRoot Counter (0) h1 "Count: 0" HostRoot Counter (1) ✓ h1 "Count: 1" ✓ commit · pointer swap root.current = workInProgress AFTER COMMIT workInProgress (was old current) current (was wip) HostRoot Counter (0) h1 "Count: 0" HostRoot Counter (1) h1 "Count: 1" 原 current 不被销毁——它成了下一次 render 的"空白底板" Old current isn't destroyed — it becomes the next render's blank canvas
FIG 08·1 commit 前后双缓冲指针的交换。原 current 不被销毁——它成为下次 render 的预分配模板。Lin Clark 在 ReactConf 2017 第一次把这个"双树 alternate 指针"的心智模型画给世界看;本文的这张图沿用她的颜色逻辑。 Fig 08·1 · The pointer swap at commit. The old current isn't destroyed — it becomes the next render's pre-allocated template. Lin Clark at ReactConf 2017 first taught the world this "two-tree alternate-pointer" mental model; the colour logic in this figure descends from hers.

为什么要这样设计

Why this design

可中断 → 必须双缓冲
Interruptible → must be double-buffered
render 算到一半被打断 → workInProgress 树只是半成品。如果就这一棵树,半成品状态会污染当前 UI。双缓冲让"正在算的"和"正在显示的"物理隔离。
If render is interrupted mid-flight, workInProgress is half-built. With only one tree, that half-built state would pollute the live UI. Double-buffer keeps "being computed" and "being displayed" physically isolated.
可丢弃 → 失败回滚 O(0)
Discardable → O(0) rollback
Concurrent 模式下,高优更新会打断低优 render。低优算出来的半成品要全部扔掉——双缓冲下只需放弃 workInProgress 指针,current 完好无损。无任何回滚成本。
In concurrent mode a high-priority update interrupts a low-priority render. The half-built low-priority work must be thrown away — with double-buffer you just drop the workInProgress pointer; current is untouched. Zero rollback cost.
GC 友好 → 节点复用
GC-friendly → node reuse
commit 后两棵树的角色互换,对象本身被重用——下次 render 只更新字段,不分配新对象。一棵 1000 节点的树,运行多年也只占两棵的内存。
After commit the trees swap roles — the objects themselves are reused. Next render only writes new field values; no allocations. A 1000-node tree running for years still occupies just two trees' worth of memory.
CONCURRENT 真正的代价 THE REAL COST OF CONCURRENT 很多人以为 Concurrent Mode 的代价是"调度复杂"——其实是内存翻倍。React 16 之前一棵树,之后两棵。对一个 5000 节点的大型 app,约多出 3 MB JS 对象。这是 React 团队 2017 年做的显式取舍:用 3 MB 换"用户点击立刻响应"。在 2026 年的硬件上,这笔账显然划算。 People think Concurrent Mode's cost is "complex scheduling" — actually it's 2x memory. Pre-16: one tree. Post-16: two. For a 5000-node app, ~3 MB extra JS objects. React team's explicit tradeoff in 2017: 3 MB to make clicks feel instant. On 2026 hardware, an obvious win.
横向对比 · Solid.js 选择"无 vDOM, 无双缓冲" SIDE-BY-SIDE · Solid.js picks "no vDOM, no double-buffer" Solid.js细粒度响应式 替代 vDOM——它的 JSX 编译出来不是创建对象, 而是直接创建一个反应式订阅。Counter 在 Solid 里 setCount(1) 会精确地修改那个文本节点——不 reconcile, 不 diff, 不双缓冲。代价是放弃了 React 的函数体重跑心智模型——Solid 的 component 函数只在 mount 时跑一次, 之后只有反应式表达式重跑。这跟 React 是完全不同的编程模型——表面相似 (都用 JSX), 内核两个世界。Solid 的存在告诉你:React 的"render 函数"心智不是必然——它是双缓冲架构强加的约束。 Solid.js swaps vDOM for fine-grained reactivity. Its JSX compiles to direct reactive subscriptions, not object creation. setCount(1) in Solid surgically mutates only the text node — no reconcile, no diff, no double-buffer. The cost: you give up React's function-body-reruns mental model — a Solid component function runs only once at mount; thereafter only reactive expressions rerun. Same JSX surface, different programming model underneath. Solid proves: the "render function" mental model isn't inevitable — it's a constraint imposed by double-buffer architecture.

commit 那一刻 — 单个指令完成翻转

The moment of commit — a single-instruction flip

"双缓冲翻转"听起来很玄, 其实它只是一行赋值。React 在 commitMutationEffectsOnFiber 把所有 DOM 改完之后, 跑这一句:

"Double-buffer flip" sounds dramatic; in reality it's one assignment. After commitMutationEffectsOnFiber has applied all DOM mutations, React runs this single line:

// react-reconciler/src/ReactFiberWorkLoop.js · commitRootImpl()
// before this line:  root.current = oldFiberTree  (the one user is staring at)
// after this line:   root.current = newFiberTree  (the one we just built)
root.current = finishedWork;
// — and that's it. The "new screen" is now the source of truth.

这条赋值之前: oldRoot 还在屏幕上,finishedWork 是新的 wip。之后: 它们的身份完全交换——上一帧的 oldRoot 从 "current" 退役成"alternate", 下一次更新会从它身上抠节点出来当 wip 用。React 没有任何"tree merge"操作, 没有"diff apply"操作——commit 的本质就是root.current 指向另一棵树。下面这张图把这"一帧之内的指针翻转"拆出来给你看:

Before this line: oldRoot is still on screen, finishedWork is the wip. After: their identities swap completely — last frame's oldRoot retires from "current" to "alternate", and the next update will recycle nodes off it for wip. There is no "tree merge" or "diff apply" — commit's essence is simply pointing root.current at the other tree. This figure unpacks the "pointer flip within one frame":

T - 1 INSTRUCTION ── COMMIT ── T + 0 root { current: ? } TREE A · "Count: 0" HostRoot Counter · state=0 h1 "Count: 0" button "+1" TREE B · "Count: 1" HostRoot Counter · state=1 h1 "Count: 1" button "+1" fiber.alternate (always set both ways) BEFORE · current → A AFTER · current → B THE ENTIRE FLIP root.current = finishedWork; // one 64-bit pointer write · sub-nanosecond 这一刻之前: 屏幕显示 Tree A。这一刻之后: useState 第二次读会沿 Tree B 走 Before this instant the screen shows Tree A. After it, useState's second read walks Tree B.
FIG 08·2 Commit 那一刻的指针翻转。它不是一次"应用 diff",而是一次原子的根指针赋值——花费亚纳秒,但下一次 useState 读、下一次 ref.current 读、下一次 DevTools inspect 全都因为它而走向新的树。 Fig 08·2 · The pointer flip at commit. It isn't "applying a diff" — it's an atomic root-pointer assignment. Sub-nanosecond, but the next useState read, the next ref.current read, the next DevTools inspect all start walking the new tree because of this one write.

bail-out 时 — wip 节点根本不分配

On bail-out — no wip node is allocated at all

教学时常把双缓冲讲成"每次 render 都铺一棵完整的 wip 树"——这是错的。React 的 reconciler 对每一个 fiber 都要先问: "这棵子树要不要重渲?" 答案是"不"时, 它就跳过 wip 分配, 直接复用 current 节点。这是 bailoutOnAlreadyFinishedWork 干的活, 在 beginWork 入口就被检查。

A common teaching error: "every render allocates a full wip tree." Wrong. The reconciler asks for each fiber: "does this subtree need a fresh render?" When the answer is no, it skips wip allocation entirely and reuses the current node. This is what bailoutOnAlreadyFinishedWork does, checked at beginWork's entry.

UPDATE #2 ── BAIL-OUT CURRENT TREE (on screen) Parent · state changed PureChild · props == └─ <span> └─ "label" beginWork(Parent) → allocate wip Parent beginWork(PureChild) → props equal? lanes & renderLanes? = NO → bailoutOnAlreadyFinishedWork() WIP TREE (being built) Parent · (new wip) PureChild · REUSE current NO ALLOC ~600 B saved + skip child walk └─ <span> (kept) └─ "label" (kept) 这就是为什么 React.memo 真的能省 CPU——bail-out 让整棵子树都不进 wip This is why React.memo really saves CPU — bail-out keeps the whole subtree out of wip.
FIG 08·3 bail-out 路径下,PureChild 不创建 wip 节点,reconciler 直接跳过它的子树。"双缓冲铺一棵完整树"是教学谎言——真实情况是只有 dirty 路径上的 fiber 才进 wip,其余复用 current。 Fig 08·3 · On the bail-out path PureChild allocates no wip node — the reconciler skips its entire subtree. "Double-buffer means a full second tree per render" is a teaching lie. Reality: only fibers on the dirty path get wip nodes; everything else reuses current.

useState 第二次读 — 走的是 current 还是 wip?

useState's second read — does it walk current or wip?

教学上,大家都会画"useState 链表"——每个 hook 一个节点, 用 next 串起来。但很少有人讲: render 中读 hook 时走的是 wip 链表, commit 之后所有 read 都走 current 链表。这两条链表通过 fiber.alternate.memoizedState 互指——结构同构, 但内容可能不同(wip 链表里是新值, current 链表里是旧值)。

Everyone draws the "useState linked list" — one node per hook, threaded by next. Rarely explained: during render the read walks the wip list; after commit, every read walks the current list. The two lists mirror each other via fiber.alternate.memoizedState — isomorphic in shape, possibly different in content (wip holds the new value, current holds the old).

useState READ — WHICH LIST? CURRENT FIBER · Counter fiber (current) memoizedState → ● hook { value: 0 } hook (useEffect) WIP FIBER · Counter fiber (wip) memoizedState → ● hook { value: 1 } hook (useEffect) alternate (always paired) DURING RENDER useState(...) walks WIP list (right) ↑ AFTER COMMIT next render reads CURRENT list (left) ↑ commit 把 wip 提升为 current, 下一帧的 useState 读已经走"上一帧的 wip 链表"
FIG 08·4 useState 链表存在两份镜像: current fiber 上挂一条, wip fiber 上挂一条。render 阶段读的是 wip 链表(用于产生本次输出), commit 之后 wip 升任 current——下一次 render 读的是上一次 wip 上的旧值, 然后再去 dispatch 队列里取新值算 reducer。这就是"useState 第二次读"的实际路径 Fig 08·4 · useState's list lives in two mirrors: one on the current fiber, one on the wip. The render-phase read walks the wip list (producing this render's output). After commit, wip becomes current — the next render reads from last frame's wip, then drains the dispatch queue to compute the next value. This is useState's "second read" in physical detail.
CHAPTER 09

beginWork — 递的那一半

beginWork — the descent half

workLoop · performUnitOfWork · 一个组件一次循环

workLoop · performUnitOfWork · one component, one iteration

阶段
Phase
RENDER · 递
源码
Source
packages/react-reconciler/src/ReactFiberBeginWork.js
行数
LoC
~2,500
入口
Entry
switch (fiber.tag)
I · Mount II · Click III · Click² IV · Unmount

Render Phase 的主循环叫 workLoopSync / workLoopConcurrent。它不是递归——是个 while 循环。每一轮处理一个 Fiber 节点,分两步:beginWork(递)把子 Element 转成子 Fiber,completeWork(归,下一章)收集 effects。

The render-phase main loop is workLoopSync / workLoopConcurrent. Not recursion — a while loop. Each iteration handles one Fiber in two halves: beginWork (descent) turns child Elements into child Fibers; completeWork (ascent, next chapter) collects effects.

// ReactFiberWorkLoop.js · the real loop
function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

function performUnitOfWork(unitOfWork) {
  const current = unitOfWork.alternate;
  const next = beginWork(current, unitOfWork, renderLanes);

  unitOfWork.memoizedProps = unitOfWork.pendingProps;
  if (next === null) {
    completeUnitOfWork(unitOfWork);  // 没有 child → 归
  } else {
    workInProgress = next;             // 有 child → 继续递
  }
}

beginWork 的 switch

beginWork's switch

beginWork 进来第一件事就是看 fiber.tag——根据是函数组件、类组件、host 元素、Suspense、Memo……分发到不同处理函数。这就是为什么 React 源码里有那么多 updateXxx 函数:

First thing beginWork does: dispatch on fiber.tag — function vs class vs host element vs Suspense vs Memo… That's why React's source has so many updateXxx functions:

function beginWork(current, workInProgress, renderLanes) {
  // — bail-out 优化:props 没变 + 子树没更新 → 整个子树复用
  if (current !== null && oldProps === newProps && !hasContextChanged()) {
    if (!includesSomeLane(renderLanes, workInProgress.lanes)) {
      return bailoutOnAlreadyFinishedWork(...);  // 直接跳过
    }
  }

  switch (workInProgress.tag) {
    case FunctionComponent: return updateFunctionComponent(...);
    case ClassComponent:    return updateClassComponent(...);
    case HostComponent:     return updateHostComponent(...);
    case HostText:          return updateHostText(...);
    case SuspenseComponent: return updateSuspenseComponent(...);
    case MemoComponent:     return updateMemoComponent(...);
    // ... 30 more cases ...
  }
}

function updateFunctionComponent(current, wip, Comp, nextProps, renderLanes) {
  // ★ 整个函数体执行就在这一行:
  const nextChildren = renderWithHooks(current, wip, Comp, nextProps, ...);
  // ↑ Counter() 在这里被实际调用,hooks 在这里取值

  reconcileChildren(current, wip, nextChildren, renderLanes);  // 下一章
  return wip.child;
}

bail-out — 性能优化的灵魂

bail-out — the soul of performance

注意 beginWork 第一行的提前返回:如果 props 没变、context 没变、这棵子树没有任何 update lane——React 整棵子树都跳过。这就是 React.memouseMemouseCallback 起作用的位置。它们不是"缓存",是oldProps === newProps 这条件做真

Note the early return at the top of beginWork: if props are stable, context didn't change, and the subtree has no pending lane — React skips the whole subtree. This is where React.memo, useMemo, useCallback earn their keep. They aren't "caching" — they're making oldProps === newProps become true.

CASE · Counter 第二次 render
点击 +1 之后
After clicking +1

setCount(1) → workLoop 启动 → beginWork(HostRoot) → beginWork(Counter)。renderWithHooks 调用 Counter() 函数本体,得到新 Element:div{ h1{ "Count: 1" }, button }。注意:useState 拿到的值是从 fiber.memoizedState 链表里读的——闭包是没法跨 render 保存状态的,秘密都在 Fiber 上。

setCount(1) → workLoop kicks in → beginWork(HostRoot) → beginWork(Counter). renderWithHooks calls the actual Counter() body, producing fresh Elements: div{ h1{ "Count: 1" }, button }. The useState value comes from fiber.memoizedState's linked list — closures cannot persist state across renders; Fiber holds it.

CHAPTER 10

Reconciliation — O(n) diff 的三个赌注

Reconciliation — three bets behind O(n) diff

同层比较 · type 标识 · key 标识

same-level · type identity · key identity

阶段
Phase
RENDER · diff
源码
Source
packages/react-reconciler/src/ReactChildFiber.js
复杂度
Complexity
O(n)
理论上界
Theoretical lower bound
O(n³)
I · Mount II · Click III · Click² IV · Unmount

通用的两棵树之间最小编辑距离是 O(n³) 问题——n 是节点数。React 把它压到 O(n)。代价是放弃最优解,转而依赖三个工程上的赌注:

The general "minimum edit distance between two trees" is O(n³) where n is the number of nodes. React drops it to O(n) by giving up optimality and betting on three pragmatic heuristics:

BET 01
同层比较
Same-level only
不跨层 diff——节点跨层移动直接视为删除 + 创建。99% 的 UI 变化都是同层的。
No cross-level diffing — a node moving across levels is treated as delete + create. 99% of UI changes stay in one level.
BET 02
type 不同 → 整棵换
Different type → swap whole subtree
<div><span>?整棵子树全部销毁,重建。即便子树长得一模一样。
<div><span>? Whole subtree destroyed and rebuilt — even if children are identical.
BET 03
key 决定身份
Key = identity
同层列表中,key 相同的认为是同一个节点(即使位置变了),key 变了即使位置一样也算新节点
Within a list, same key = same node (even if reordered). Different key = new node (even at the same index).

单子节点 · 多子节点

Single child · multi-child

React 的 reconcile 算法分两条主路径:单子节点(reconcileSingleElement)和多子节点(reconcileChildrenArray)。前者简单,后者复杂——尤其当数组元素带 key 时。

Two main paths: single child (reconcileSingleElement) and array (reconcileChildrenArray). Single is trivial; arrays — especially keyed arrays — are where the complexity lives.

KEYED LIST DIFF · two-pass algorithm OLD A key=a B key=b C key=c D key=d NEW A key=a C key=c E key=e B key=b PASS 1 · 前缀同位比对 key=a → 复用 key=b ≠ key=c → 停 PASS 2 · 哈希表查找 existingMap = {b: f1, c: f2, d: f3} 扫 new[1..3]: c → map.get('c'), 复用 + Placement e → 新建 b → map.get('b'), 复用 + Placement map 剩 {d} → Deletion EFFECT LIST · 提交给 commit 阶段的清单 Update A Placement E Placement B (move) Placement C (move) Deletion D
FIG 10·1 带 key 的列表 diff:先前缀同位比对,遇到不一致后构造剩余节点的哈希表,最后产出 effect list。整个过程 O(n)。 Fig 10·1 · Keyed list diff: prefix compare, then a hash map of the remaining nodes, finally an effect list. O(n) total.

lastPlacedIndex · 决定谁要"真的移动"的贪心算法

lastPlacedIndex · the greedy heuristic that picks who actually moves

前一节的算法告诉你哪些 fiber 可以复用,但还没说哪些要真的在 DOM 里挪位置。复用了不等于不挪——位置变了仍需 insertBefore。React 用一个极简贪心算法决定"谁挪、谁站着":维护一个游标 lastPlacedIndex,对每个新位置上能复用的 fiber,看它在旧列表里的位置——只有逆向的才打 Placement。这个算法不是最优,但它是 O(n)、无需排序。

The previous step tells you which fibers can be reused, but not which ones need to physically move in the DOM. Reuse ≠ stay-in-place — if its slot changes, insertBefore still has to run. React uses a tiny greedy heuristic: keep a cursor lastPlacedIndex; for each reused fiber, check its position in the old list — only backwards ones get a Placement flag. Not optimal, but O(n) with no sort.

// react-reconciler/src/ReactChildFiber.js · simplified
function placeChild(newFiber, lastPlacedIndex, newIndex) {
  newFiber.index = newIndex;
  const current = newFiber.alternate;
  if (current === null) {
    newFiber.flags |= Placement;          // 新建的, 一定要 Placement
    return lastPlacedIndex;
  }
  const oldIndex = current.index;
  if (oldIndex < lastPlacedIndex) {
    newFiber.flags |= Placement;          // ★ 逆向 → 需要移动
    return lastPlacedIndex;
  } else {
    return oldIndex;                     // 顺向 → 不动, cursor 推进
  }
}
lastPlacedIndex 算法 · [A,B,C,D] → [B,C,D,A] · 只 1 次 DOM 移动 OLD (index) A idx=0 B idx=1 C idx=2 D idx=3 NEW B new[0] C new[1] D new[2] A new[3] ★ WALK-THROUGH step 1 · new[0]=B oldIdx=1, lastPlaced=0 · 1≥0 → stay, lastPlaced=1 step 2 · new[1]=C oldIdx=2, lastPlaced=1 · 2≥1 → stay, lastPlaced=2 step 3 · new[2]=D oldIdx=3, lastPlaced=2 · 3≥2 → stay, lastPlaced=3 step 4 · new[3]=A ★ oldIdx=0, lastPlaced=3 · 0<3 → PLACEMENT RESULT · effect list B no flag ✓ C no flag ✓ D no flag ✓ A Placement → insertBefore null Commit phase 只调一次 DOM APIparent.insertBefore(A, null) — 把 A 移到尾部 B / C / D 物理位置不变 · 即便逻辑上它们都"前移了 1 格" — 浏览器无需重排 VS · 朴素算法 按 index 配对 → B/C/D/A 全部认为是新位置 → 4 次 DOM 移动(4× 慢) REACT lastPlacedIndex 贪心找最长非降子序列 → 1 次 DOM 移动
FIG 10·2 lastPlacedIndex 算法。new=[B,C,D,A]:B/C/D 在 old 里的 index 都呈递增 → 不挪;只有 A 的 oldIndex=0 比 lastPlaced=3 小 → 它需要被移到尾部。一次 insertBefore 完事。 Fig 10·2 · The lastPlacedIndex algorithm. new=[B,C,D,A]: B/C/D's old indices form an increasing sequence → no moves; only A (oldIndex=0 < lastPlaced=3) gets the Placement flag and moves to the tail. One insertBefore is enough.
为什么不用最优算法(Vue 3 的 LIS) Why not the optimal algorithm (Vue 3's LIS) Vue 3 用最长递增子序列(Longest Increasing Subsequence, LIS)—— 真正最优。例如 old=[A,B,C,D,E], new=[A,C,B,D,E]:Vue 只移动 B(1 次),React 的 lastPlacedIndex 会移动 B 和 D(2 次)。LIS 是 O(n log n),React 嫌它常数太大。React 团队的论据:实际项目中"纯排列" 极少(<5%),常见是"插入 + 删除 + 少量交换"——这两种情况下贪心法和 LIS 几乎一样快。所以选了实现更简单、常数更小的贪心。这是 React vs Vue 的一个真实差异点——大部分情况两者表现一致,极少数排列场景 Vue 略胜。 Vue 3 uses Longest Increasing Subsequence (LIS) — actually optimal. Example: old=[A,B,C,D,E], new=[A,C,B,D,E]: Vue moves B once; React's lastPlacedIndex moves B and D (twice). LIS is O(n log n); React rejects it for constant factor. The team's argument: real apps rarely do "pure permutation" (<5%) — most are "insert + delete + small reorder", where greedy and LIS perform identically. So React picked the simpler, smaller-constant algorithm. A real React-vs-Vue difference — equivalent in most cases, Vue slightly wins on pure reorders.

key 错了,代价巨大

Wrong key, huge cost

ANTI-PATTERN
用 index 当 key
Using index as key

在数组开头插一项 → 所有现有节点的 index 全部 +1 → React 认为每一项都是新的。带表单输入的列表会全部失焦、丢内容、useState 全部重置。

Insert at index 0 → every existing item's index shifts → React thinks every item is new. Inputs lose focus, useState resets, scroll positions reset.

RIGHT WAY
用稳定的业务 ID
Stable domain ID

todo.iduser.uuid、任何跨 render 稳定且唯一的值。React 内部按 key 哈希查找——id 是 string/number 都行。

Use todo.id, user.uuid, any value stable across renders and unique within siblings. React hashes by key — string or number both fine.

FIELD NOTE · 同 type 不同 instance FIELD NOTE · same type, different instance 两个相邻的 <UserCard /> 即使 type 完全相同,没 key 时也按 index 配对。这是为什么 conditional list("奇数行渲染 A,偶数行渲染 B")一定要给 key——同 type 但身份不同。 Two adjacent <UserCard />s with identical type get paired by index when no key is given. That's why conditional lists ("render A on odd rows, B on even") need explicit keys — same type, different identity.
CHAPTER 11

completeWork — 归的那一半

completeWork — the ascent half

effect flags · subtreeFlags · 离屏 DOM 创建

effect flags · subtreeFlags · off-DOM creation

阶段
Phase
RENDER · 归
源码
Source
packages/react-reconciler/src/ReactFiberCompleteWork.js
本阶段干
Does
create DOM (offline)
flag 上卷
Flag bubbling
subtreeFlags |= flags
I · Mount II · Click III · Click² IV · Unmount

当 workLoop 走到一个没有 child 的 Fiber(叶子)或它的 child 已经完成,它进入 completeUnitOfWork——这是""。归阶段做三件事:

When workLoop reaches a Fiber whose children are all done (or a leaf), it enters completeUnitOfWork — "ascent". Ascent does three things:

创建 DOM(如果是 HostComponent)
Create DOM (HostComponent)
内存里document.createElement(type)。注意:这时还没插入页面。completeWork 也调 appendInitialChild 把子 DOM 挂到自己上——但整棵子树仍然在文档树之外。这就是为什么大列表 mount 时浏览器 layout 只触发一次
Calls document.createElement(type) in memory. Not yet attached to the document. completeWork also calls appendInitialChild to wire children to the new node — but the whole subtree remains off-document. That's why large mounts trigger layout once.
计算 props 差异 (updatePayload)
Diff props (updatePayload)
对 update 情况调 prepareUpdate——React 不直接套用新 props,而是先算出一个"差异对象"挂在 fiber.updateQueue 上。Commit 阶段才真去改 DOM——这种预计算让 commit 跑得飞快。
For updates, calls prepareUpdate — React doesn't apply new props directly; it computes a diff payload attached to fiber.updateQueue. The actual DOM write happens in commit. This pre-computation makes commit lightning-fast.
上卷 subtreeFlags
Bubble subtreeFlags
每个 fiber.flags 上卷到 父 fiber.subtreeFlags。Commit 阶段只需要看根的 subtreeFlags——是 0 就整棵子树跳过。这是 React 17→18 的一个关键性能优化:替换掉了旧的"effect list 链表"。
Each fiber.flags bubbles up into parent.subtreeFlags. Commit phase only checks the root's subtreeFlags — if 0, the whole subtree is skipped. A key React 17→18 optimization that replaced the old "effect list" linked structure.
SUBTREE-FLAGS BUBBLE UP · 自底向上 · commit 阶段的剪枝路径 HostRoot subtreeFlags |= Update ● Counter subtreeFlags |= Update ● div subtreeFlags |= Update ● h1 subtreeFlags |= Update ● button subtreeFlags = 0 ✓ skipped "Count: " flags = 0 "1" text flags = Update ★ (源头) ▲ bubble COMMIT 怎么读这棵树 ① 从 HostRoot 开始 检查 subtreeFlags & Mask ② 非 0 → 下到 child 沿 child 链继续 ③ 0 → 整棵子树跳过 button 不被遍历 ✓ ④ 到达 "1" 文本节点 执行 textNode.nodeValue = "1" React 18+ 用 subtreeFlags 取代 React 16/17 的 effect list 链表 真正改的 DOM 节点只有 1 个 · 但 commit 知道 "从这条路下去找"
FIG 11·1 "1" 文本节点上的 Update flag 沿 fiber.return 向上 bubble,每经过一个节点都把 subtreeFlags 染上 Update。Commit 阶段反向走这条路:subtreeFlags = 0 的子树整棵跳过——button 分支永远不会被遍历。 Fig 11·1 · The "1" text node's Update flag bubbles up via fiber.return, painting subtreeFlags on each ancestor. Commit walks back down this path: subtrees with subtreeFlags = 0 are skipped wholesale — the button branch is never visited.

Effect Flags · 一个位字段说尽一切

Effect Flags · a bitmask says it all

每个 Fiber 节点的 flags 是个 32 位的位字段——一位代表一种"commit 阶段要做的事"。位运算极快,而且容易合并:

flags on every Fiber is a 32-bit bitmask — one bit per "thing to do in commit". Bitwise ops are fast and trivially combine:

// react-reconciler/src/ReactFiberFlags.js · the bitmask
export const NoFlags        = 0b00000000000000000000000000;
export const Placement     = 0b00000000000000000000000010; // 插入到 DOM
export const Update        = 0b00000000000000000000000100; // 更新属性
export const ChildDeletion = 0b00000000000000000000010000; // 子节点要删
export const ContentReset  = 0b00000000000000000000100000;
export const Callback      = 0b00000000000000000001000000;
export const Ref           = 0b00000000000000001000000000; // 要调 ref callback
export const Snapshot      = 0b00000000000000010000000000; // getSnapshotBeforeUpdate
export const Passive       = 0b00000000000000100000000000; // useEffect
export const StoreConsistency = 0b00000000000001000000000000; // useSyncExternalStore
export const LayoutMask = Update | Callback | Ref | Visibility;
export const PassiveMask = Passive | Visibility | ChildDeletion;
FLAGS · 32 位位掩码可视化 (React 19) bit → 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 Performed 1 Placement 1 Update Cloned 1 ChildDelete ContReset 0 Callback 0 DidCapture 0 FrcRender 1 Ref 1 Snapshot 1 Passive 0 Hydrating 0 Visibility 0 StoreConst 0 Forked bit → 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 0 ShouldSusp 0 DidDefer 0 FormReset 0 AffectLayout bits 20–31 · 内部 / 子树 mask / Static / Profiler 等 RefStatic · LayoutStatic · PassiveStatic · MountLayoutDev ... CATEGORY LEGEND DOM 操作 Placement / Update / ChildDeletion 同步 (commit) Ref / Snapshot / Callback 异步 (passive) useEffect 的标记 WORKED · Counter 第二次 render 的 textNode 这个 textNode 的 flags = Update flags = 0b00000000000000000000000000000100 (位 2 亮起) commit 时只需检查 (flags & Update) !== 0 —— 一个位与就够了
FIG 11·2 Flags 32 位 bitmask 可视化。低 16 位是真正的 commit 标记(DOM 操作 / 同步 / 异步),高 16 位是 React 内部 / Static / Profiler 字段。看一眼能感到为什么 React 用位运算——每个 OR/AND 只 1 个 CPU 周期。 Fig 11·2 · The 32-bit Flags bitmask. Low 16 bits are real commit markers (DOM ops / sync / async); high 16 bits are React-internal / Static / Profiler fields. You can feel why React picked bits — every OR/AND is one CPU cycle.
为什么换掉 effect list Why effect-list was replaced React 16 用链表串起所有有 flags 的 fiber——commit 时直接遍历链表,不用走树。但链表维护复杂,且和并发模式下"丢弃 wip 树"语义不兼容。18 改用 subtreeFlags 自底向上汇总——commit 时仍然是 O(变更数) 遍历,但结构简洁,丢弃 wip 树时不用同步链表。 React 16 maintained a linked list of fibers with flags — commit walked the list, not the tree. But the list was complex to maintain and didn't compose with concurrent mode's "throw away wip tree" semantics. 18 swapped to subtreeFlags bubbled bottom-up — commit is still O(changes), but the structure is simpler and throwing away the wip tree requires no extra bookkeeping.

render phase 结束

End of render phase

当 workLoop 归到 HostRoot——render phase 结束。此时内存里有一棵完整的 workInProgress Fiber 树,每个节点上挂着 flags / updatePayload,subtreeFlags 在父节点上做了"这棵子树有没有事"的总结。下一步:commit。

When workLoop ascends back to HostRoot — render phase is done. In memory: a complete workInProgress Fiber tree, each node carrying flags / updatePayload, subtreeFlags summarising "does this subtree have work". Next: commit.

CHAPTER 12

Hooks 内核 — 链表与 dispatcher

Hooks core — linked list & dispatcher

memoizedState · queue · 调用顺序的物理约束

memoizedState · queue · call-order as a physical constraint

阶段
Phase
RENDER · 函数组件状态
源码
Source
packages/react-reconciler/src/ReactFiberHooks.js
数据结构
Data structure
单链表
挂在
Lives on
fiber.memoizedState
I · Mount II · Click III · Click² IV · Unmount

函数组件没有 this,也没有持久化的实例——它只是个函数,跑完就丢。那 useState 拿到的值是从哪里来的?答案在两行代码里:

Function components have no this, no persistent instance — they're functions that run and vanish. So where does useState read its value? Two lines tell the story:

// react-reconciler/src/ReactFiberHooks.js · simplified
let currentlyRenderingFiber: Fiber;        // 当前在 render 谁
let workInProgressHook: Hook | null;     // 链表游标

function renderWithHooks(current, wip, Component, props, ...) {
  currentlyRenderingFiber = wip;
  wip.memoizedState = null;
  wip.updateQueue = null;
  workInProgressHook = null;
  ReactCurrentDispatcher.current = mount ? HooksOnMount : HooksOnUpdate;

  const children = Component(props);  // ★ 用户的 Counter() 被调用

  ReactCurrentDispatcher.current = ContextOnlyDispatcher;
  return children;
}

Counter() 里调 useState(0),实际上调的是 ReactCurrentDispatcher.current.useState——这是挂在全局变量上的指针。挂载时指向 mountState,更新时指向 updateState。两个版本做的事完全不同。

When Counter() calls useState(0), it actually calls ReactCurrentDispatcher.current.useState — a pointer on a module-level variable. On mount it points to mountState; on update to updateState. Different implementations entirely.

Hooks 在 Fiber 上的形态

Hooks on the Fiber

Counter Fiber tag = 0 type = Counter memoizedState ↓ Hook 1 · useState memoizedState = 0 queue = { pending: → } next ↓ Hook 2 · useEffect memoizedState = effect deps = [count] next = null Update Queue circular list → U1 → U2 → U1 Update lane = SyncLane action = prev => prev+1 Effect Object tag = HasEffect | Passive create = () => document.title = ... Counter 调用了几个 hook,链表上就有几个节点·顺序敏感 N hooks called → N nodes on the list · order-sensitive
FIG 12·1 Counter 的 hooks 状态。每个 useXxx 在 fiber.memoizedState 链表上挂一个节点。useState 节点同时关联一条循环更新队列。 Fig 12·1 · Counter's hook state. Each useXxx hangs a node on fiber.memoizedState. The useState node also points at a circular update queue.

"hooks 调用顺序"规则的真正原因

Why "rules of hooks" exist

React 用调用顺序来对齐 hooks——第一次 useState 拿链表第一节点,第二次 useState 拿第二节点。没有 ID,没有名字。 这就是为什么 if (...) useState(...) 会让 React 整个崩——条件分支会让链表对不上。这条规则不是约定,是 React 16.8 这套设计的物理约束

React aligns hooks by call order — first useState reads the first node, second reads the second. No IDs, no names. That's why if (...) useState(...) breaks everything — conditional branches misalign the list. This isn't a convention — it's a physical constraint of the React 16.8 design.

setState 的真实路径

The real path of setState

// setCount(1) actually goes through here:
function dispatchSetState(fiber, queue, action) {
  // ① 用调度器算出 lane(更新优先级)
  const lane = requestUpdateLane(fiber);

  // ② 构造 update 对象
  const update = { lane, action, hasEagerState: false, eagerState: null, next: null };

  // ③ 推到 hook 的循环队列尾
  enqueueUpdate(fiber, queue, update);

  // ④ 急切计算(小优化):如果新 state === 旧 state,连 schedule 都省了
  if (fiber.lanes === NoLanes) {
    const eagerState = queue.lastRenderedReducer(currentState, action);
    update.eagerState = eagerState;
    update.hasEagerState = true;
    if (objectIs(eagerState, currentState)) return;  // ← bail out
  }

  // ⑤ 通知 scheduler 安排一次 render
  scheduleUpdateOnFiber(fiber, lane, eventTime);
}
setState 同步还是异步? Is setState sync or async? 都不是。setState 既不同步也不异步,它是"排队的"。 它把 update 推入队列就返回——调用栈里立刻继续执行后面的代码。但实际的 render 由 scheduler 决定时机:可能下一个微任务(React 18+ 默认)、可能下一帧(concurrent)、可能合并多个 setState 一次跑(batching)。这是 React 18 之后 setState 行为最常被误解的点。 Neither. setState is "enqueued". The call pushes an update and returns — the caller continues running. When the actual render happens is the scheduler's call: maybe next microtask (React 18+ default), maybe next frame (concurrent), maybe coalesced with other setStates (batching). React 18's most misunderstood behavior.

三个 dispatcher — Hook 的真身在全局变量

Three dispatchers — the real useState lives on a global

你写的 useState(0) 其实是个门面。它的实现只有一行:

The useState(0) you write is just a facade. Its implementation is a single line:

// packages/react/src/ReactHooks.js
export function useState<S>(initialState) {
  const dispatcher = resolveDispatcher();        // ← reads ReactCurrentDispatcher.current
  return dispatcher.useState(initialState);     // ← jumps to whichever dispatcher is "on"
}

"哪个 dispatcher 是 on 的"由 React 在不同的时机切换全局指针 ReactCurrentDispatcher.current 决定——共有三个完全独立的实现挂在那里。读完下面这张图,你下次再看到 "Hooks can only be called inside the body of a function component" 报错时,会知道是哪个指针在哪一瞬间指错了:

"Which dispatcher is on" is determined by React switching the global pointer ReactCurrentDispatcher.current at specific moments. Three independent implementations sit behind that pointer. After reading the figure below, the next time you see "Hooks can only be called inside the body of a function component", you'll know exactly which pointer was wrong, at which instant.

ReactCurrentDispatcher.current — the pointer that flips three times per render renderWithHooks() entered first render (mount) re-render (update) function returned ContextOnlyDispatcher HooksDispatcherOnMount HooksDispatcherOnUpdate ContextOnlyDispatcher (the "trap" dispatcher) useState: throwInvalidHook() useEffect: throwInvalidHook() useMemo: throwInvalidHook() useContext: readContext(c) ✓ only context is legal HooksDispatcherOnMount (first time only) useState: mountState() // allocates Hook + queue useEffect: mountEffect() // pushEffect, set Passive flag useMemo: mountMemo() // always run factory "build the hook list" HooksDispatcherOnUpdate (every subsequent render) useState: updateState() // drain queue, replay reducer useEffect: updateEffect() // dep-compare, may skip useMemo: updateMemo() // dep-compare or rerun "walk the existing list" THE TRAP If you call useState outside the render window, the global still points at ContextOnlyDispatcherthrowInvalidHook() fires. That's the "Hooks can only be called…" error.
FIG 12·2 三个 dispatcher 在 ReactCurrentDispatcher.current 上轮转。render 之外调 hook 会撞到 ContextOnlyDispatcherthrowInvalidHook——这就是著名的 "Hooks can only be called inside the body of a function component" 报错。mount → update是同一次 render 内的二次切换:你不可能一个组件第一次跑就走 update 路径,反之亦然——这两个 dispatcher 从来不在同一帧并存 Fig 12·2 · Three dispatchers rotate on ReactCurrentDispatcher.current. Calling a hook outside the render window hits ContextOnlyDispatcher's throwInvalidHook — the canonical "Hooks can only be called inside the body of a function component" error. The mount → update switch happens within one render: a component's first render cannot hit the update path, and vice versa — these two dispatchers never co-exist in the same frame.
为什么这种"全局可变指针"的设计能被接受 Why this "mutable global pointer" design is acceptable 看起来 ReactCurrentDispatcher.current = X 像反模式——一个 mutable global,谁都能踩。React 团队 2018 年讨论时(RFC #68)考虑过 token 传参、闭包捕获等方案,最后选了全局指针,理由有三: ① 用户写 useState 时不用从 props 拿——保留了"调用就工作"的体验; ② React 是单线程的,没有竞态; ③ 出错时这个 trap 反而更容易诊断——错调 hook 立刻报错而不是默默给错值。设计的"反模式"实际上是有意为之的人体工学 It looks like an anti-pattern — a mutable global, any code can corrupt it. The 2018 RFC #68 discussion considered token passing, closure capture, and others. The team chose the global pointer for three reasons: ① user code can call useState without threading anything through props — keeps the "just-call-it" feel; ② React is single-threaded — no races; ③ the trap is actually easier to diagnose — misuse throws immediately instead of silently returning wrong values. The "anti-pattern" is in fact intentional ergonomics.
CHAPTER 12·B

Hooks 巡礼 — 另外六个

Hooks tour — the other six

useContext · useMemo · useCallback · useRef · useSyncExternalStore · useTransition · useId

useContext · useMemo · useCallback · useRef · useSyncExternalStore · useTransition · useId

阶段
Phase
RENDER · 函数组件态外
源码
Source
packages/react-reconciler/src/ReactFiberHooks.js
本章覆盖
Covers
6 个 hook
共同点
All share
同一条 hook 链表
I · Mount II · Click III · Click² IV · Unmount

Counter 只用了 useStateuseEffect——前一章把这两个的内核拆透。但 React 19 一共暴露 10+ 个 hook,理解它们的共同底层(同一条链表、同一个 dispatcher 切换机制)之后,每一个其实只是在 Hook 节点上挂不同形状的 memoizedState。这一章走 6 个最常见的剩余 hook。

Counter only uses useState and useEffect — the previous chapter cracked their kernel. But React 19 ships 10+ hooks. Once you grasp the shared substrate (single linked list, dispatcher swap), every hook is just a different shape of memoizedState on the Hook node. This chapter tours the six most-used remaining ones.

六个 Hook 在链表上的形态对照

The six hooks compared

hookHook.memoizedState 形态queue触发 re-render
useContextnull(不存值,只挂依赖)Provider value 变 → 沿树传播Provider value change → tree propagation
useMemo[deps, cachedValue]否(被动)no (passive)
useCallback[deps, cachedFn]
useRef{ current: any }永不never
useSyncExternalStore[getSnapshot, snapshot]外部 store 的 subscribeexternal store subscribestore 通知 → forceUpdate
useTransition[isPending, startTransition]SyncLane 用于 isPendingSyncLane for isPendingTransitionLane
useId":r0:"

useContext · 沿 fiber 树的依赖传播

useContext · dependency propagation through the fiber tree

useContext 是上面表里唯一不存值的 hook——它的 Hook 节点只挂一条依赖记录。值实际上活在 Provider 上方的上下文栈里,consumer 在 render 时读取的是栈顶。

useContext is the only hook in the table that stores no value — its Hook node only carries a dependency record. The actual value lives in the context stack above the Provider; consumer reads the top of stack during render.

CONTEXT PROPAGATION · Provider 变值时谁会重 render <ThemeContext.Provider> value = "dark" → "light" ★ 变了 <Layout> 不订阅 ThemeContext <Header> useContext(ThemeContext) ★ <Main> 不订阅 ✓ skipped <DeepBadge> useContext(ThemeContext) ★ propagateContextChange 沿子树 DFS 每个 fiber: contextDependencies? yes → 标记 dirty no → 自己 skip, 但继续往下走 Provider value 变化时 · React 无脑重 render 整棵子树 · 只 dirty 真订阅了的 fiber
FIG 12B·1 Provider value 变化时,React 沿子树 DFS,只把订阅了该 context 的 fiber 标记为 dirty。Layout 和 Main 没订阅 → 不重 render。Header 和 DeepBadge 订阅了 → 重 render。这就是为什么 context 不会"污染整棵子树"。 Fig 12B·1 · When a Provider's value changes, React DFS-walks the subtree and only flags fibers that actually subscribed to that context as dirty. Layout and Main don't subscribe → not re-rendered. Header and DeepBadge do → re-rendered. This is why context doesn't "poison" the entire subtree.
// 简化版 readContext —— useContext 的实现核心
function readContext(context) {
  const value = context._currentValue;
  const dep = { context, memoizedValue: value, next: null };
  if (currentlyRenderingFiber.contextDependencies === null) {
    currentlyRenderingFiber.contextDependencies = { firstContext: dep };
  } else {
    lastContextDependency.next = dep;
  }
  lastContextDependency = dep;
  return value;
}

// 当 Provider 的 value 变化, React 调:
function propagateContextChange(wip, context, renderLanes) {
  let fiber = wip.child;
  while (fiber !== null) {
    let dep = fiber.contextDependencies?.firstContext;
    while (dep !== null) {
      if (dep.context === context) {
        fiber.lanes = mergeLanes(fiber.lanes, renderLanes); // 标记 dirty
        break;
      }
      dep = dep.next;
    }
    fiber = fiber.child ?? fiber.sibling ?? fiber.return; // 继续 DFS
  }
}

useMemo / useCallback · 缓存槽的真面目

useMemo / useCallback · the cache slot, unmasked

useMemo 和 useCallback 是同一段代码——后者只是前者的语法糖(useCallback(fn, deps) 等价于 useMemo(() => fn, deps))。它们的"缓存"就是 Hook.memoizedState 里的一对 [deps, value]

useMemo and useCallback share one implementation — the latter is sugar for the former (useCallback(fn, deps)useMemo(() => fn, deps)). Their "cache" is just a [deps, value] pair inside Hook.memoizedState.

function updateMemo(nextCreate, deps) {
  const hook = updateWorkInProgressHook();
  const prev = hook.memoizedState;
  if (prev !== null && areHookInputsEqual(deps, prev[1])) {
    return prev[0];                // ★ 命中: 返回旧值, 不调 nextCreate
  }
  const nextValue = nextCreate();
  hook.memoizedState = [nextValue, deps];
  return nextValue;
}

// areHookInputsEqual 用 Object.is 逐项比较 deps —— 浅比较, 不递归
误解 · useMemo 不是"性能护身符" MYTH · useMemo isn't a "perf amulet" 每次 render 都会跑:deps 比较、调 nextCreate(如果命中失败)、写 memoizedState。本身就有开销。给一个轻量计算(a + b)加 useMemo,比直接算更慢。React Compiler(Ch21)替代手写 useMemo 的根本原因就是:编译期能确切知道哪些值不变,不用运行时再比较。 Every render runs: deps compare, call nextCreate (on miss), write memoizedState. That itself costs something. Wrapping a trivial computation (a + b) in useMemo is slower than computing it directly. The fundamental reason React Compiler (Ch21) supplants hand-written useMemo: at compile time it knows what's stable, no runtime compare needed.

useRef · 最简单的 Hook

useRef · the simplest hook

// 全部实现就这两行
function mountRef(initial) {
  const hook = mountWorkInProgressHook();
  const ref = { current: initial };
  hook.memoizedState = ref;
  return ref;
}
function updateRef(initial) {
  return updateWorkInProgressHook().memoizedState;  // 返回上次那个相同对象
}

就是一个 { current } 对象,跨 render 引用稳定。你改 ref.current 时 React 完全不知道——它不订阅、不调度、不重 render。这是 hook 里唯一合法允许 render 期间副作用的(但官方仍建议放在 useEffect 里)。

It's a { current } box, stable across renders. When you mutate ref.current, React doesn't know — no subscription, no schedule, no re-render. The only hook where render-time mutation is technically legal (though the docs still say move it to useEffect).

useSyncExternalStore · concurrent-safe 外部状态

useSyncExternalStore · concurrent-safe external state

React 18 之前订阅外部 store(Redux、Zustand)的标准做法是 useEffect + setState。Concurrent 来了之后这个 pattern 有个致命问题:同一次 render 中,不同组件可能在不同时间点读到 store——结果界面"撕裂"(tearing)。

Before React 18, subscribing to external stores (Redux, Zustand) used useEffect + setState. Concurrent mode broke this: within one render, different components might read the store at different points in time — the UI "tears".

// useSyncExternalStore 的核心: 一次 render 内 snapshot 一次, 然后保持
function useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) {
  const snapshot = getSnapshot();
  // 1. render 期间读取一次, 整个 render phase 都用这个值
  const hook = updateWorkInProgressHook();
  hook.memoizedState = [snapshot, getSnapshot];

  // 2. commit 之后 useEffect 注册 subscribe
  //    store 变化 → forceUpdate, 引发新 render, 新 render 再 snapshot
  useEffect(() => subscribe(() => {
    if (!Object.is(getSnapshot(), snapshot)) forceStoreRerender(fiber);
  }), [subscribe]);

  return snapshot;
}

// 这条 hook 是 Redux v8+/Zustand/Jotai 等所有现代外部状态库的底层 hook
为什么不能用 useEffect + useState Why useEffect + useState fails Concurrent render 可以被打断、重启。假设组件 A 先读 store 看到 v1,被打断;store 变成 v2;组件 B 紧接着读到 v2。同一帧里 A 用 v1, B 用 v2 → tearing。useSyncExternalStore 让每次 render 都 snapshot 同一个值——即使是被打断重启的 render——通过精确的"render 期间不订阅,只读快照"语义来防住这个 bug。 Concurrent render can be paused and resumed. Suppose component A reads store → sees v1, gets paused; store mutates to v2; component B reads → v2. Same frame: A uses v1, B uses v2 → tearing. useSyncExternalStore snapshots once per render — even across pause/resume — by the strict "no subscribe in render, only read snapshot" rule.

useTransition / useDeferredValue · 把 setState 降级

useTransition / useDeferredValue · demoting a setState

两个 hook 都做同一件事:把某次 setState 从默认的 SyncLane / DefaultLane 降到 TransitionLane(参见 Ch16)。区别在 API 形状:

Both do the same thing: demote a setState from the default Sync/Default lane to TransitionLane (Ch16). Difference is purely API shape:

useTransition
显式包 setState
Explicit wrap

const [isPending, startTransition] = useTransition()。任何放在 startTransition 闭包里的 setState 自动降级。同时返回 isPending 标志位(自身用 SyncLane 跑)。

const [isPending, startTransition] = useTransition(). Any setState inside the startTransition closure auto-demotes. Also returns isPending (which itself runs on SyncLane).

useDeferredValue
隐式跟踪一个值
Implicit value tracking

const deferred = useDeferredValue(value)。React 内部为 deferred 维护一个"慢一拍"的版本——value 变后,deferred 在 TransitionLane 里追上来。适合对父传来的 prop 降级。

const deferred = useDeferredValue(value). React internally keeps a "one-beat-behind" version — when value changes, deferred catches up in TransitionLane. Useful for demoting a prop from parent.

useId · SSR-safe 的确定性 ID

useId · SSR-safe deterministic IDs

React 17 之前,给 <label htmlFor> 生成稳定 id 非常痛苦——SSR 和客户端各自生成会不匹配(hydration mismatch)。useId 通过读取 fiber 树位置来生成 id:第 N 个深度 + 第 M 个位置 → 固定的字符串。服务器和客户端在同一棵树上必然产生相同 id。

Before React 17, stable ids for <label htmlFor> were painful — server and client each generated their own, mismatching during hydration. useId derives the id from the fiber tree position: depth N + position M → a fixed string. Server and client running the same tree get the same id.

// 生成出来的 id 形如 ":r0:", ":r1:", ":r2a:" ...
// 前缀 ":r" 是 HostRoot.identifierPrefix, 数字 + 字母是 fiber 在树上的路径

function Modal() {
  const id = useId();
  return <>
    <label htmlFor={`${id}-name`}>Name</label>
    <input id={`${id}-name`} />
    <label htmlFor={`${id}-email`}>Email</label>
    <input id={`${id}-email`} />
  </>;
}
// 一个 useId 调用 → 一个 id,但能拼出多个 DOM id(推荐用法)

useReducer · useState 的"大哥"

useReducer · the "elder sibling" of useState

很多人不知道:useState 的实现里真就是 useReducer,只是带了个固定的 reducer——basicStateReducer

A truth few know: useState is literally a useReducer internally — with a fixed reducer called basicStateReducer.

// ReactFiberHooks.js · useState 的真实实现
function mountState(initial) {
  const hook = mountWorkInProgressHook();
  hook.memoizedState = hook.baseState =
    typeof initial === 'function' ? initial() : initial;

  const queue = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,  // ← 写死了一个 reducer
    lastRenderedState: initial,
  };
  hook.queue = queue;
  const dispatch = dispatchSetState.bind(null, fiber, queue);
  return [hook.memoizedState, dispatch];
}

function basicStateReducer(state, action) {
  return typeof action === 'function' ? action(state) : action;
  // ★ 这就是 setState(n) 直接覆盖 vs setState(s => s+1) 函数式更新的分支
}

所以 useReducer 比 useState 更"原始"。何时选哪个?规则简单:状态更新逻辑超过一行就用 useReducer。理由:把"怎么变"集中到一个纯函数里,便于测试、便于复用、便于把 dispatch 透传给深层组件。

So useReducer is more "primitive" than useState. When to use which? Rule of thumb: if update logic is more than one line, prefer useReducer. Concentrates "how state changes" in a pure function — easier to test, reuse, and pass dispatch deep into the tree.

useImperativeHandle · 改 ref 暴露什么

useImperativeHandle · customizing what ref exposes

forwardRef 让 ref 穿过组件层级到达内部 DOM。useImperativeHandle 是 ref 的"过滤器"——它让你自定义父组件通过 ref 能拿到什么,而不是直接暴露内部 DOM。

forwardRef lets a ref pierce through component layers to inner DOM. useImperativeHandle is the filter: customize what the parent actually gets through ref, instead of exposing inner DOM raw.

const CustomInput = forwardRef(function CustomInput(props, ref) {
  const inputRef = useRef(null);

  useImperativeHandle(ref, () => ({
    // 父组件只能调这 2 个方法,看不到原 input DOM
    focus: () => inputRef.current.focus(),
    clear: () => { inputRef.current.value = ''; },
    // 不暴露 .value, .style, 等等 → 父无法直接搞乱内部
  }), []);

  return <input ref={inputRef} {...props} />;
});

// 父用法:
const ref = useRef(null);
<CustomInput ref={ref} />
ref.current.focus();   // ✓ 可以
ref.current.value = 'x'; // ✗ undefined

在 commit 阶段的精确时机:useImperativeHandleuseLayoutEffect 同步跑——在 Mutation 之后、Paint 之前。这意味着父组件 ref callback 收到的 ref.current最新版本,但只在下一个 commit

Precise commit timing: useImperativeHandle runs synchronously alongside useLayoutEffect — after Mutation, before Paint. The parent's ref callback receives the fresh ref.current, but only on the next commit.

useInsertionEffect · 给 CSS-in-JS 库的特别窗口

useInsertionEffect · the CSS-in-JS exclusive window

React 18 引入的最少为人知的 hook。它在 useLayoutEffect 之前、Mutation 之前跑——专门给 styled-components / emotion 这种动态注入 CSS的库用的。为什么需要?因为 useLayoutEffect 时 DOM 已经存在,如果在那里注入 CSS,子组件 layout effect 读 DOM 几何会读到没应用样式的版本——尺寸全错。

The least-known hook React 18 introduced. It runs before useLayoutEffect, before Mutation — exclusively for libraries like styled-components / emotion that inject CSS dynamically. Why needed? By useLayoutEffect, DOM already exists; if you inject CSS there, child layout effects reading DOM geometry see unstyled dimensions — all wrong.

// CSS-in-JS 库内部大致这么用
function useStyleInjection(css) {
  useInsertionEffect(() => {
    const style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);
    return () => style.remove();
  }, [css]);
}

// 这个 hook 你 99% 用不到 —— 它存在的全部目的是给三方库用
// 应用代码里出现 useInsertionEffect 几乎肯定是错用了

React 19 表单三件套 · Actions 时代

React 19 form trio · the Actions era

React 19 把"提交表单" 升级成了 first-class abstraction。三个 hook 配合一起干掉了一大半 redux 样板:

React 19 promotes "form submission" to a first-class abstraction. Three hooks together replace half the redux boilerplate:

hook用途返回
useFormStatus读最近父 <form> 的 action 是否在 pendingread whether the nearest parent <form>'s action is pending{ pending, data, method, action }
useActionState配合 Server Action 的 [state, dispatch] 对[state, dispatch] pair for Server Actions[state, dispatch, isPending]
useOptimistic乐观更新——在异步操作完成前先显示""结果optimistic update — show "fake" result before async finishes[optimisticState, addOptimistic]
// 一个完整的"乐观 + 服务器 action + pending 状态"组合
async function addTodo(prevState, formData) {
  'use server';
  return { todos: [...prevState.todos, await db.insert(formData.get('text'))] };
}

function TodoForm({ initialTodos }) {
  // ① 把 action 接到 React state 管理
  const [state, action, isPending] = useActionState(addTodo, { todos: initialTodos });

  // ② 乐观更新 · 让 UI 立刻反映新 todo
  const [optimistic, addOpt] = useOptimistic(state.todos,
    (curr, newTodo) => [...curr, { text: newTodo, pending: true }]
  );

  return <form action={(fd) => {
    addOpt(fd.get('text'));  // 立即乐观
    action(fd);                // 真请求
  }}>
    {optimistic.map(t => <Item data={t} />)}
    <input name="text" />
    <SubmitBtn />          // SubmitBtn 内部用 useFormStatus 读 pending
  </form>;
}

function SubmitBtn() {
  const { pending } = useFormStatus();   // 读最近的 form action 状态
  return <button disabled={pending}>{pending ? '…' : 'Add'}</button>;
}
为什么 useFormStatus 不接 form 引用 Why useFormStatus takes no form ref 看上面 SubmitBtn——它不接任何参数也不接 ref,却读到外层 <form> 的 pending。秘密:React 19 把 form action 的 pending 状态做成隐式 context——<form action> 自动建立一个 form-status context provider;useFormStatususeContext 的特化。这就是为什么它只能用在 form 内部——出了 form 边界 useContext 取不到。 Look at SubmitBtn above — no args, no ref, yet it reads the outer <form>'s pending. The trick: React 19 makes form-action pending an implicit context<form action> auto-installs a form-status context provider; useFormStatus is a useContext specialization. Hence it only works inside a form — outside the boundary useContext finds nothing.

useDebugValue · 给 DevTools 看

useDebugValue · for DevTools eyes only

最简单的 hook,唯一作用是在 React DevTools 的 Components 面板里给自定义 hook 显示一个"友好标签"。生产环境完全 noop。

The simplest hook. Its sole job: show a "friendly label" for custom hooks in React DevTools' Components panel. Completely noop in production.

function useUser(id) {
  const [user, setUser] = useState(null);
  useDebugValue(user ? `User: ${user.name}` : 'loading');
  // DevTools Components 里会显示: useUser · "User: Alice"
  // 不写 useDebugValue 就只显示: useUser
  ...
  return user;
}
CHAPTER 13

Before Mutation — commit 的第一刀

Before Mutation — first cut of commit

读 DOM · getSnapshotBeforeUpdate · 但还不能写

read DOM · getSnapshotBeforeUpdate · no writes yet

阶段
Phase
COMMIT · 1/3
源码
Source
packages/react-reconciler/src/ReactFiberCommitWork.js
可中断
Interruptible
允许
Allowed
读 DOM only
I · Mount II · Click III · Click² IV · Unmount

Commit Phase 在浏览器一个微任务内一气呵成跑完——它不可中断,因为一旦 DOM 被改了一半,UI 就会显示不一致状态。React 把 commit 拆成 3 个子阶段,每个子阶段都明确"允许做什么 / 禁止做什么":

Commit runs inside a single browser microtask — uninterruptible, because a half-mutated DOM would flash inconsistent UI. React splits commit into 3 sub-phases, each with explicit "allowed / forbidden" semantics:

13 · BEFORE MUTATION
读旧 DOM 拍快照
read DOM snapshot
getSnapshotBeforeUpdate
14 · MUTATION
改 DOM · 解绑 ref
mutate DOM · detach refs
appendChild · setAttribute
15a · LAYOUT
读新 DOM · 同步 effect
read new DOM · sync effects
useLayoutEffect · cDU

"Before Mutation" 这个名字非常准确——它的特权是:DOM 还没改,所以你能拿到"修改前的真实状态"。componentDidUpdate 之前的 getSnapshotBeforeUpdate 就跑在这里——它的经典用例是滚动位置保持

"Before Mutation" is precise — its privilege is: DOM is still old, so you can read "state before the change". getSnapshotBeforeUpdate runs here. Classic use case: preserve scroll position when prepending items:

class ChatLog extends Component {
  // 在 DOM 改之前测一下:底部还有多少滚动空间
  getSnapshotBeforeUpdate(prevProps) {
    if (prevProps.messages.length < this.props.messages.length) {
      const list = this.listRef.current;
      return list.scrollHeight - list.scrollTop;  // snapshot
    }
    return null;
  }

  // 在 DOM 改完之后用 snapshot 恢复滚动
  componentDidUpdate(prevProps, prevState, snapshot) {
    if (snapshot !== null) {
      const list = this.listRef.current;
      list.scrollTop = list.scrollHeight - snapshot;
    }
  }
}
FIELD NOTE · React 19 静默移除了一个能力 FIELD NOTE · React 19 quietly removed something React 18 的 BeforeMutation 还会跑 componentWillUpdate(已废弃但还能跑)。19 起完全移除三个 unsafe 生命周期(componentWillMount / componentWillReceiveProps / componentWillUpdate)。class 组件还存在,但这三个钩子直接报错。Hooks API 早就没对应物了——这是 React 团队"用新 API 淘汰旧 API"路径的最后一步。 React 18's BeforeMutation still ran componentWillUpdate (deprecated but functional). 19 removes all three unsafe lifecycles (componentWillMount / componentWillReceiveProps / componentWillUpdate). Class components still work, but these three throw outright. Hooks never had equivalents — the last step in "new API supplants old".

三个真实用例 · 为什么这个子阶段不能没

Three real use cases · why this sub-phase exists at all

BeforeMutation 看起来"什么都不做"——但它是唯一 同时满足两个条件的窗口:① 新 React 树已经决定好(render phase 已完成),② DOM 还没改。下面三个工业级用例只能在这里实现:

BeforeMutation looks like it "does nothing" — but it's the only window meeting two conditions: ① the new React tree is decided (render phase done), ② DOM is unchanged yet. Three production use cases that can only happen here:

滚动位置保持 · scroll restoration
Scroll restoration
最经典的用例。聊天列表追加新消息后, 你不想用户的滚动位置被打乱——想保持他正在看的那条消息在视口里。要做这件事必须在DOM 改前测旧的 scrollHeight/scrollTop, 在 DOM 改后用差值补偿。getSnapshotBeforeUpdate 就是干这事的——返回的 snapshot 自动传给 commit layout 阶段的 componentDidUpdate
The classic. When a chat list prepends new messages, you don't want the user's scroll position lost — you want to keep the currently-viewed message in viewport. Doing this requires reading old scrollHeight/scrollTop before DOM changes, then compensating after. getSnapshotBeforeUpdate exists for this — the returned snapshot is auto-passed to componentDidUpdate in the layout phase.
image preload · 防止"白闪"
Image preload · prevent "white flash"
大图替换时, 浏览器要等新图下载完才能 paint, 中间几百 ms 是空白。聪明做法: 在 BeforeMutation 里读到"即将变成的" img.src, 用 new Image() 先去预加载 ; commit Mutation 把 src 设上去时浏览器立刻 paint (因为已 cache)。这个 trick 只在 BeforeMutation 能玩——commit Layout 太晚, render phase 又不能 side-effect。
When swapping a large image, the browser must wait for the new download before painting — a few hundred ms blank. Trick: in BeforeMutation, read the "about-to-be" img.src and preload via new Image(); when commit Mutation sets the src, the browser paints immediately (already cached). Only doable in BeforeMutation — commit Layout is too late; render phase can't side-effect.
focus 重新挂载 · 输入框不丢焦点
Focus re-attachment · input doesn't lose focus
表单 wrap 在某些 UI 库里会动态加入/移除 div, 导致同一个 input 在 DOM 上"被卸再挂"——focus 丢失。修法: BeforeMutation 时记下 document.activeElement 的特征 (DOM 路径/data-test-id), Layout 阶段对照新 DOM 找到对应节点 .focus() 上去。React 内部对受控组件做了一部分这种保护, 但跨非 React 边界 (比如把 input 移进 portal) 仍需要手写。
In some UI libs, form wrappers dynamically add/remove divs, causing an input to be "unmounted then remounted" in DOM — focus lost. Fix: in BeforeMutation, snapshot document.activeElement's identity (DOM path / data-test-id); in Layout phase, locate the corresponding node in the new DOM and .focus() it. React handles some of this internally for controlled inputs, but crossing non-React boundaries (moving input into a portal) still requires manual handling.

getSnapshotBeforeUpdate 完整实战

getSnapshotBeforeUpdate · full case

class ChatLog extends Component {
  listRef = createRef();

  // BeforeMutation 子阶段调用 · DOM 还是旧的
  getSnapshotBeforeUpdate(prevProps, prevState) {
    // 检测是否在追加消息
    if (prevProps.messages.length < this.props.messages.length) {
      const list = this.listRef.current;
      // 记下"剩余可滚动距离" —— 这个值是 layout-invariant 的
      return { scrollFromBottom: list.scrollHeight - list.scrollTop };
    }
    return null;
  }

  // Layout 子阶段调用 · DOM 已改, snapshot 是上面那个返回值
  componentDidUpdate(prevProps, prevState, snapshot) {
    if (snapshot !== null) {
      const list = this.listRef.current;
      // 用 snapshot 算出新 scrollTop, 保持视口稳定
      list.scrollTop = list.scrollHeight - snapshot.scrollFromBottom;
    }
  }

  render() {
    return <div ref={this.listRef} className="log">
      {this.props.messages.map(m => <Msg key={m.id} {...m} />)}
    </div>;
  }
}
BEFORE MUTATION 子阶段 · 能做什么 / 不能做什么 render done ★ BeforeMutation Mutation Layout render phase done BeforeMutation ★ Mutation Layout ✓ 允许 ▸ 读 DOM (scrollTop, getBoundingClientRect) ▸ 读 ref.current ▸ 比较 prevProps / prevState ▸ 返回 snapshot 对象 (传给 componentDidUpdate) ▸ new Image() 触发预加载 ▸ requestAnimationFrame 排下一帧任务 ✗ 禁止 ▸ 写 DOM (innerHTML, classList, attribute) ▸ setState (会触发新一轮 render, 当前 render 还没 commit) ▸ 调用 dispatch / Redux action ▸ 修改 ref.current (会被 Layout 阶段覆盖) ▸ 跑 useEffect (它在 paint 后) ▸ fetch (不是禁止但无意义 · 那是 effect 该做的)
FIG 13·1 BeforeMutation 子阶段的精确边界。它的特权是"读旧 DOM",它的限制是"不准写"——只能拍照, 不能改照片。返回值会被 React 自动捎给 Layout 阶段, 是一个清晰的 staged communication 模式。 Fig 13·1 · BeforeMutation's exact boundary. Its privilege: "read the old DOM"; its limit: "no writing" — take photos, don't edit them. The return value is auto-piped to the Layout phase, forming a clear staged-communication pattern.

为什么 hooks 没有对应 API

Why hooks have no equivalent

很多人问: 为什么 React 19 都不出 useSnapshotBeforeUpdate? 答案在生命周期: getSnapshotBeforeUpdate 接收 prevProps, prevState 然后返回值传给 componentDidUpdate——它是跨子阶段通信的一种同步缝合。Hook 模型里没有"上一次 render 的 props/state" 这个概念 (hook 是单次 render 内的, 没有 prev/curr 视图)。React 团队的官方建议: 用 ref 自己存 prev——但精确的commit 子阶段时机仍只能靠 class 拿到。这是 hook 模型一个真正的 边界。

A frequent question: why doesn't React 19 ship a useSnapshotBeforeUpdate? Answer is in the lifecycle: getSnapshotBeforeUpdate receives prevProps, prevState and pipes a return value into componentDidUpdate — a sync stitch across sub-phases. The hooks model has no "previous render's props/state" concept (hooks are per-render, no prev/curr view). React's official advice: store prev in a ref yourself — but the precise commit-sub-phase timing remains class-only. This is a real boundary of the hooks model.

CHAPTER 14

Mutation — DOM 真的动了

Mutation — DOM actually changes

commitPlacement · commitUpdate · commitDeletion

commitPlacement · commitUpdate · commitDeletion

阶段
Phase
COMMIT · 2/3
关键函数
Key fn
commitMutationEffectsOnFiber
绑定 ref
Ref
detach 旧 ref
中间产物
Intermediate
root.current 切换
I · Mount II · Click III · Click² IV · Unmount

这是 React 整个流水线里唯一真的改 DOM 的阶段。它按 fiber.flags 分发处理:

The only phase that touches the DOM. Dispatches on fiber.flags:

flagactionreact-dom 调用
Placement插入到父 DOMinsert into parent DOMparent.appendChild(node) / parent.insertBefore(node, ref)
Update应用 updatePayloadapply updatePayloadsetValueForProperty / setValueForStyles / setTextContent
ChildDeletion移除并回收 fiberremove + recycle fiberparent.removeChild(node) + 递归卸载 ref/effect
Ref先解绑旧 refdetach old refoldRef(null) / oldRef.current = null

root.current 切换 — 一切的转折点

root.current swap — the turning point

Mutation 子阶段跑完之后、Layout 子阶段开始之前,React 做了一件极其关键的事:root.current = finishedWork。这一行让"workInProgress 树变成 current 树",双缓冲翻转。这是 React 内部"新版本上线"的瞬间——之前任何 ref 读到的都是旧版,之后任何 ref 读到的都是新版。

Between Mutation and Layout, React does the critical line: root.current = finishedWork. This flips the double buffer — "workInProgress becomes current". The instant where the new version goes live: any ref read before this points at the old tree; any ref after points at the new.

COMMIT MICROTASK · root.current 翻转的精确瞬间 t₀ +0.05 +0.10 ★ SWAP +0.20 +0.25 ms ① BeforeMutation ② Mutation · DOM 写入 ③ Layout · 同步 effect root.current → current tree (count = 0) DOM 正在被改写,但 root.current 还指着这棵 wip tree (count = 1) 已成为 new current DOM 显示 "Count: 0" (屏幕上) 写入中: "Count: 1" "Count: 1" (将 paint) root.current = wip commitMutationEffects 返回后 commitLayoutEffects 调用前 ① 读 DOM 拍快照(Counter 无操作) ② textNode.nodeValue = "1" → 浏览器已知更新(但 root.current 仍指旧树) ★ 翻转:DevTools / ref / fiber.alternate 等所有读取从此读到新树
FIG 14·1 commit 微任务里的精确时刻:DOM 已经改完,root.current 才被赋值为新树。在此之前任何同步的 ref 读、DevTools 查询都看到的是旧版本——这是 React 19 "tearing-free" 的边界。 Fig 14·1 · The exact moment inside the commit microtask: DOM is already mutated, then root.current is reassigned to the new tree. Until that line, any synchronous ref read or DevTools query still sees the old version — the boundary of React 19's tearing-free guarantee.
// react-reconciler/src/ReactFiberWorkLoop.js · the pivot
function commitRootImpl(root, recoverableErrors, transitions) {
  // PHASE 1 — Before Mutation
  commitBeforeMutationEffects(root, finishedWork);

  // PHASE 2 — Mutation
  commitMutationEffects(root, finishedWork, lanes);
  resetAfterCommit(root.containerInfo);

  // ★★★ THE SWAP ★★★
  root.current = finishedWork;

  // PHASE 3 — Layout (sync)
  commitLayoutEffects(finishedWork, root, lanes);

  // Schedule Passive Effects (async, 下一节)
  scheduleCallback(NormalPriority, flushPassiveEffects);
}
为什么 useLayoutEffect 比 useEffect 早 Why useLayoutEffect runs before useEffect 看上面这段:commitLayoutEffects(useLayoutEffect)是同步调用的——还在同一个微任务里。而 flushPassiveEffects(useEffect)只是被 schedule,等下一个 task。所以:useLayoutEffect 跑完 → 浏览器 paint → useEffect 跑。这就是为什么 useLayoutEffect 能在 paint 之前同步修改 DOM 而不出现"闪一下"——它和 mutation 在同一帧。代价是阻塞 paint。 Look at the snippet: commitLayoutEffects (useLayoutEffect) is sync — same microtask. flushPassiveEffects (useEffect) is merely scheduled for a later task. Order: useLayoutEffect → paint → useEffect. That's why useLayoutEffect can mutate DOM pre-paint without flicker — it shares a frame with mutation. The cost: it blocks paint.

Portal · 跨 container 的 commit

Portal · cross-container commit

createPortal(children, otherContainer) 让一棵子树渲染到另一个 DOM container——典型场景是 Modal / Tooltip / Dropdown,它们需要逃出父级的 overflow: hiddenz-index 栈。但 portal 是commit 阶段才生效的——render phase 里 portal fiber 看起来跟普通组件没区别。

createPortal(children, otherContainer) renders a subtree into a different DOM container — typical for Modals / Tooltips / Dropdowns that need to escape a parent's overflow: hidden or z-index stack. But portals are a commit-phase phenomenon — during render phase a portal fiber looks like any other component.

function Modal({ children }) {
  return ReactDOM.createPortal(
    <div className="modal-content">{children}</div>,
    document.getElementById('modal-root')   // ← 另一个 DOM container
  );
}
// JSX 上看 Modal 在 App 里; 但 DOM 上 modal-content 挂到 #modal-root 下
RENDER PHASE
fiber.tag = 4
portal fiber 跟普通 fiber 一样建 child / sibling / return 链表; React tree 上仍是 App 的逻辑子孙。
portal fiber builds child/sibling/return like any other; in the React tree it's still a logical descendant of App.
COMMIT PHASE
不同 container
react-dom 调 getRootHostContainer(portalFiber) 返回 #modal-root, 不是 App 的容器 → appendChild 落到 portal 自带 container。
react-dom calls getRootHostContainer(portalFiber) which returns #modal-root, not App's container → appendChild lands in the portal's own container.
EVENT BUBBLING
仍走 React tree
点击 modal 里的按钮 → 事件冒泡 React逻辑父链 → 仍能被 App 的 onClick 接住, 即便 DOM 上根本不冒泡过去。
Click inside modal → event bubbles up React's logical parent chain → still caught by App's onClick, even though DOM bubbling never crosses there.
CONTEXT
仍按 React tree
App 里的 Provider value, modal 里 useContext 仍能读到——因为 fiber.return 指向 App, 不指向 modal-root。
A Provider in App is still visible inside modal — because fiber.return points to App, not to modal-root.
FIELD NOTE · Portal 是 React 第一个"双重身份"原语 FIELD NOTE · Portal is React's first "dual-identity" primitive Portal 揭示了一个深刻设计:React tree 和 DOM tree 可以分离。这条思路后来被复用到 RSC(组件不一定送到客户端 = React tree 跨网络分离)和 Activity(隐藏树仍在 React tree 但 DOM 上 display:none)。要理解 React 19 必须先理解 Portal——它是第一个承认"React 视图 ≠ 物理视图"的原语。 Portals reveal a deep design: the React tree and the DOM tree can be decoupled. That idea is later reused in RSC (components might not ship to client = React tree crossing the network) and Activity (hidden subtree stays in React tree but is display:none in DOM). To grok React 19 you must grok Portal — it's the first primitive to admit "React view ≠ physical view".

Mutation 子阶段的精确执行顺序

Mutation sub-phase · exact execution order

Mutation 不是一个"原子操作"——它内部有严格的子步顺序。这个顺序决定了 ref 时机、cleanup 时机、portal commit 时机的所有正确性。读 React 源码时如果你看到反直觉的 bug, 99% 跟下面这张顺序表对不上有关:

Mutation isn't an "atomic operation" — internally it has a strict sub-step order. This order dictates correctness for ref timing, cleanup timing, portal commit timing. When you see counterintuitive bugs in React source, 99% is a mismatch with this table:

#子步骤对应 fiber.flagreact-dom 调用
1递归处理 ChildDeletion · 卸载子树Recursive ChildDeletion · unmount subtreeDeletion见下表展开expanded below
2解绑旧 refDetach old refsRefoldRef(null) / oldRef.current = null
3运行所有 useInsertionEffect cleanupRun all useInsertionEffect cleanupsInsertionCSS-in-JS 注入前清旧 styleclear old styles before injecting
4运行所有 useInsertionEffect createRun all useInsertionEffect createsInsertion插入新 style tag (在 DOM 改前!)insert new style tag (before DOM mutate!)
5应用所有 Placement / Update / TextUpdateApply all Placement / Update / TextUpdatePlacement, UpdateappendChild / insertBefore / setAttribute / nodeValue
6运行所有 useLayoutEffect cleanup (来自旧 effect)Run all useLayoutEffect cleanups (from old effects)Update + LayoutMask还在 mutation 阶段, 不在 layoutstill mutation phase, not layout
root.current = wip双缓冲翻转 · 见 FIG 14·1double-buffer flip · see FIG 14·1
7→ 进入 Layout 子阶段→ Enter Layout sub-phase在新 ref 绑定、useLayoutEffect create 跑、componentDidMount/cDU 跑——见 Ch15new refs attach, useLayoutEffect create runs, componentDidMount/cDU runs — see Ch15

ChildDeletion 内部子顺序

ChildDeletion · its internal order

删除一棵子树不是一个 removeChild 那么简单——React 要确保子孙的清理按照"反向 mount 顺序"跑完。一个 deletion 在 fiber 内部走这条精确路径:

Deleting a subtree is not a single removeChild — React must ensure descendants' cleanup runs in reverse mount order. A deletion follows this exact internal path:

// commitDeletionEffectsOnFiber · 深度优先 post-order
function commitDeletionEffectsOnFiber(root, returnFiber, deletedFiber) {
  // ① 先递归到最深处, 子孙先卸
  let child = deletedFiber.child;
  while (child !== null) {
    commitDeletionEffectsOnFiber(root, returnFiber, child);
    child = child.sibling;
  }

  switch (deletedFiber.tag) {
    case HostComponent:
      // ② detach ref · 安全无副作用
      safelyDetachRef(deletedFiber, returnFiber);
      break;

    case FunctionComponent:
      // ③ 运行所有 useEffect cleanup · ④ 所有 useLayoutEffect cleanup
      commitHookEffectListUnmount(HookPassive, deletedFiber, returnFiber);
      commitHookEffectListUnmount(HookLayout, deletedFiber, returnFiber);
      break;

    case ClassComponent:
      // ⑤ 调 componentWillUnmount
      safelyCallComponentWillUnmount(deletedFiber, returnFiber);
      break;
  }
}

// 整棵子树 cleanup 跑完之后, returnFiber 才调一次 removeChild
// 把整个 子树的根 DOM 节点从父 DOM 摘掉
returnFiber.stateNode.removeChild(deletedFiber.stateNode);
FIELD NOTE · 为什么 cleanup 先, removeChild 后 FIELD NOTE · Why cleanup before removeChild 想象一个 modal 内部组件订阅了 document.body 的 scroll 事件。如果 React 先 removeChild 再 cleanup, 那么 cleanup 时组件已经不在 DOM 上了——subscription 引用的 DOM 节点已被 GC, 可能引发 dangling reference。React 的策略是先把"组件还活着"那一刻的 cleanup 跑完, 之后再让 DOM 节点消失。这是 React 18 之前一个常见 memory leak 的根源——很多三方库的 cleanup 假设 DOM 还在。 Imagine a modal-internal component subscribed to document.body scroll. If React did removeChild before cleanup, by cleanup time the component is no longer in DOM — subscription's DOM-node ref is GC'd, dangling-reference risk. React's strategy: run cleanups while "the component is still alive", then let the DOM nodes vanish. A pre-React-18 memory-leak class — many 3rd-party libs' cleanup assumed DOM still around.

Portal 内部的 mutation 顺序

Mutation order inside a Portal

Portal 增加了一个反直觉点: 它的容器跟父 fiber 的容器不同。commitMutationEffectsOnFiber 处理到一个 HostPortal fiber 时, 会临时切换 container 引用到 portal 自带的 container, 然后递归处理子树。完成后切回原 container。所以一次 commit 里如果有多个 portal, container 引用会被"压栈"——React 用一个隐式 stack 管理这件事。

Portal adds a counterintuitive twist: its container differs from the parent fiber's container. When commitMutationEffectsOnFiber processes a HostPortal fiber, it temporarily swaps the container reference to the portal's own container, recurses into the subtree, then swaps back. So a commit with multiple portals "stacks" container references — React manages this with an implicit stack.

// commitMutationEffects 简化
function commitMutationEffectsOnFiber(finishedWork, root) {
  switch (finishedWork.tag) {
    case HostPortal: {
      const oldContainer = currentMutationContainer;
      currentMutationContainer = finishedWork.stateNode.containerInfo;
      // ↑ 切到 portal 的 container (#modal-root)

      recursivelyTraverseMutationEffects(root, finishedWork);
      commitReconciliationEffects(finishedWork);

      currentMutationContainer = oldContainer;  // 切回原 container
      break;
    }
    // ... 其他 tag ...
  }
}

为什么 commit 不能切片

Why commit can't be sliced

读完上面顺序表后, "commit 必须同步一气呵成" 这件事就显然了——任何中断都会让 DOM 处于半改不改 的状态 (有些子树已用新 DOM, 有些还用旧的)。用户看到的可能是: 5 个 todo 里前 3 个标了"done"样式, 后 2 个还是"open"——这就是视觉撕裂。React 18 Concurrent 让 render 可切片是因为 render 的中间状态只在内存里; 但 commit 的中间状态是用户屏幕上的——这是 React 18 RFC 里反复强调的不可突破的边界。

After reading the order table, "commit must be synchronous, all-or-nothing" becomes obvious — any interruption leaves DOM in a half-mutated state (some subtrees on new DOM, others on old). User might see: of 5 todos, first 3 styled "done", last 2 still "open" — visual tear. React 18 Concurrent slices render because render's intermediate state is memory-only; commit's intermediate state is on the user's screen. The React 18 RFC repeatedly stresses this as an unbreakable boundary.

CHAPTER 15

Layout & Passive — useLayoutEffect vs useEffect

Layout & Passive — useLayoutEffect vs useEffect

同步 vs 异步 · paint 前 vs paint 后

sync vs async · before paint vs after paint

阶段
Phase
COMMIT · 3/3 + post
layout
layout
sync · 阻塞 paint
passive
passive
async · paint 后
React 19
React 19
double-invoke (StrictMode)
I · Mount II · Click III · Click² IV · Unmount
microtask paint next task COMMIT MICROTASK · sync · uninterruptible mutation swap current useLayoutEffect ★ PAINT browser draws SCHEDULED TASK useEffect (passive) useLayoutEffect 必须返回前完成 → 你能在这里读 layout 数据并立刻 setState 修正 → 但代价是浏览器 paint 被推迟(如果你的 effect 慢,会感觉卡) useEffect 在 paint 之后跑 → 用户已经看到新 UI → 适合发请求 / 订阅 / log → 如果它再调 setState,会触发第二次 commit(双 render) 两条 effect 的物理位置 · 物理位置决定语义
FIG 15·1 useLayoutEffect 和 useEffect 在浏览器主线程上的物理位置不同——这才是它们行为差异的根因。 Fig 15·1 · useLayoutEffect vs useEffect — physically distinct positions on the main thread. The position is the difference.
useLayoutEffect
同步 · paint 前
Sync · before paint

在 commit 微任务里同步跑。能读到最新 DOM 几何(getBoundingClientRect),能立刻 setState 修正——浏览器只 paint 一次。代价:阻塞 paint,慢 effect = 慢交互。

Runs sync inside the commit microtask. Can read fresh layout (getBoundingClientRect) and setState to correct — browser paints once. Cost: blocks paint; slow effect = janky interaction.

useEffect
异步 · paint 后
Async · after paint

scheduleCallback 推到下一个 task。用户已经看到新 UI 了。适合发请求、订阅、log。如果它 setState,会触发第二轮 render——浏览器会 paint 两次(短暂闪一下)。

Posted to the next task via scheduleCallback. The user has already seen the new UI. Right for fetch / subscribe / log. If it setStates → second render → two paints (brief flash).

Cleanup 的两次时机

Cleanup runs twice

每个 effect 的 cleanup 函数在两个位置被调用:①下次 effect 触发之前(确保旧订阅被解绑),②组件卸载时。React 18 StrictMode 在开发模式下故意 mount → cleanup → mount 两遍,专门曝光那些"没好好写 cleanup"的 effect。React 19 把这个习惯保留了——它是发现 effect bug 最有效的工具。

An effect's cleanup fires at two moments: ① before the next effect (so old subscriptions detach), ② on unmount. React 18 StrictMode dev-only mounts → cleans → mounts again on purpose, to expose effects without proper cleanup. React 19 kept this — most effective tool we have for catching effect bugs.

CASE · Counter 例子里的 effect CASE · the effect in our Counter 回到本文的 Counter:useEffect(() => { document.title = '#' + count; }, [count])。流程是:① 点击 +1 → render → mutation → 屏幕显示 "Count: 1"(document.title 还是 "#0")→ ② 浏览器 paint → ③ 下一个 task:effect 跑,document.title 变成 "#1"。所以页面正文先变、tab 标题后变——你在 DevTools 里能看到这一帧的延迟。 Back to Counter: useEffect(() => { document.title = '#' + count; }, [count]). Flow: ① click → render → mutation → screen shows "Count: 1" (title still "#0") → ② browser paints → ③ next task: effect runs, title becomes "#1". So the body updates first, the tab title one frame later. You can spot the lag in DevTools.
CHAPTER 15·B

Error Boundaries — 异常的另一条上溯路径

Error Boundaries — the other exception path

getDerivedStateFromError · componentDidCatch · React 19 三回调

getDerivedStateFromError · componentDidCatch · React 19's three callbacks

阶段
Phase
RENDER 抛 + COMMIT 收
源码
Source
packages/react-reconciler/src/ReactFiberThrow.js
边界类型
Boundary kind
ClassComponent · 必须
React 19
React 19
3 root-level callbacks
I · Mount II · Click III · Click² IV · Unmount

Suspense 用 throw Promise 做控制流(Ch18)。Error Boundary 用同一套机制——只是 throw 的是 真 Error。两条路径在 ReactFiberThrow.js同一个函数 throwException 里分发:是不是 thenable?是 → 走 Suspense 路径;不是 → 走 ErrorBoundary 路径。这是 React 17 之后两套"抛出即控制流"机制在源码里的对称美。

Suspense uses thrown Promises as control flow (Ch18). Error Boundaries use the same mechanism — except they throw a real Error. Both paths dispatch from one function in ReactFiberThrow.js: throwException. Is it a thenable? → Suspense path. Otherwise → ErrorBoundary path. This is the source-code symmetry of two "throw-is-control-flow" mechanisms post-React-17.

为什么必须是 class 组件

Why it must be a class component

2026 年了 hook 几乎无所不能——但 Error Boundary 至今没有 hook 版。原因不是 React 团队懒,是语义问题:

It's 2026 and hooks do almost everything — except Error Boundaries, which still have no hook version. Not laziness — a semantic problem:

Hook 在自己这个组件 render 时才能 setup
A hook can only set up during its own render
但 ErrorBoundary 要捕获的是子孙 render 抛错——也就是说,子孙 render 时这个父 hook 已经跑完了,没机会"动态"接住。class 组件的 getDerivedStateFromError静态方法——React 在需要时直接拿类引用调用,不依赖父组件的 render 周期。
But the Boundary catches errors from descendants' render — by which time the parent hook has already returned, no chance to "dynamically" catch. The class form's getDerivedStateFromError is a static method — React calls it directly off the class reference, independent of any render cycle.
try/catch 套不住 render
try/catch can't wrap render
直觉是写 try { return <Child/> } catch (e) { ... }。但 JSX 不是函数调用——Child 真正 render 是后续 workLoop 的事,那时这层 try 早已退栈。所以接错的机制必须在更深的协调层——不是用户 JS。
The intuition is try { return <Child/> } catch (e) { ... }. But JSX isn't a function call — Child actually renders later in workLoop, by which time this try has long unwound. So the catch must live at a deeper coordination layer — not user JS.
React 团队保留 class 作为 escape hatch
React keeps class as an escape hatch
React 19 的官方说法:"会出 hook 版的" 但没时间表。class 组件目前是唯一能定义 ErrorBoundary 的方式。社区库(react-error-boundary)包了一层 API 让你不用直接写 class,但底下仍是 class。
React 19 official position: "a hook version will come" but no timeline. Class is the only way to define an ErrorBoundary today. Community libs (react-error-boundary) wrap class with a friendlier API, but underneath it's still class.

两个生命周期的精确时机

Precise timing of the two lifecycles

class ErrorBoundary extends Component {
  state = { hasError: false, error: null };

  // ★ 在 RENDER PHASE 里被调用 (必须纯, 不能 side-effect)
  static getDerivedStateFromError(error) {
    return { hasError: true, error };  // 仅返回新 state
  }

  // ★ 在 COMMIT PHASE 里被调用 (可 side-effect: log / 上报)
  componentDidCatch(error, errorInfo) {
    sentry.captureException(error, errorInfo);
    // errorInfo.componentStack: "    in Avatar\n    in UserCard\n..."
  }

  render() {
    if (this.state.hasError) return <FallbackUI />;
    return this.props.children;
  }
}
ERROR THROW & CATCH · 一次抛错的完整旅程 FIBER TREE HostRoot tag=3 App tag=0 (FC) ErrorBoundary tag=1 ★ catcher UserCard tag=0 (FC) Avatar throw new TypeError() ★ throw error 沿 fiber.return 上溯 UserCard.return=EB EB 有 gDSFE → 落点 throwException 分发 // ReactFiberThrow.js if (thenable(value)) → Suspense (Ch18) else → Error Boundary ★ ① RENDER PHASE 调 getDerivedStateFromError → EB.state = { hasError: true, error } EB fiber 标 ShouldCapture flag · workLoop 回退此子树 ② EB 用 fallback 重 render 自己 UserCard / Avatar 子树整棵被 unmount ③ COMMIT PHASE 调 componentDidCatch → sentry.captureException(error, info) errorInfo.componentStack 含完整调用链 (Owner Stacks) ④ React 19 · root.onCaughtError 也被触发 即便被 boundary 接住,root 仍可观测 · 上报集中处理
FIG 15B·1 Error 抛出后的四段路径。注意 ① 在 render phase(必须纯),③ 在 commit phase(可副作用),④ 是 React 19 的 root 级集中入口。这跟 Suspense 路径同源(FIG 18·1)——区别仅在 throw 的是 Error 还是 Promise。 Fig 15B·1 · The four-leg path of a thrown Error. ① is in render phase (must be pure), ③ in commit phase (side-effects OK), ④ is React 19's root-level central handler. Same architecture as Suspense (FIG 18·1) — only what's thrown differs (Error vs Promise).

React 19 的三个 root 级回调

React 19's three root-level callbacks

React 19 把"错误监控"从分散在各个 ErrorBoundary 提到了整个 rootcreateRoot 现在接 3 个回调:

React 19 lifts "error observation" from scattered ErrorBoundaries to the entire root. createRoot now takes 3 callbacks:

const root = createRoot(container, {
  // 没被任何 boundary 接住 · 用户 UI 会白屏
  onUncaughtError(error, errorInfo) {
    sentry.captureException(error, { ...errorInfo, severity: 'critical' });
  },

  // 被某个 boundary 接住了 · 用户看到 fallback
  onCaughtError(error, errorInfo) {
    sentry.captureException(error, { ...errorInfo, severity: 'warning' });
  },

  // 可恢复的: React 自动重试成功了 · 比如 hydration mismatch 自愈
  onRecoverableError(error, errorInfo) {
    analytics.log('recovered', error.message);
  },
});
onUncaughtError
最严重 · 白屏
Most severe · white screen

没有任何 boundary 接住 → React 卸载整棵 root → 用户看到空白。生产环境 监听这个。Sentry / Datadog 的 React 集成默认就 hook 这个回调。

No boundary caught it → React unmounts the entire root → user sees blank screen. Must be observed in production. Sentry / Datadog React integrations hook this by default.

onCaughtError
中等 · fallback 显现
Medium · fallback shown

某个 boundary 接住了 → fallback UI 出现。用户体验降级但应用还能用。dev 环境也会触发——hot reload 一旦改坏一个组件,这里会响。

Some boundary caught it → fallback UI shows. Degraded UX, still usable. Also fires in dev — hot-reload breaking a component lands here.

onRecoverableError
轻度 · 用户无感
Minor · invisible to user

React 内部自愈了:hydration mismatch 触发 re-render、suspended retry 成功、concurrent re-render after throw。日常监控里看这个的 baseline 能发现 SSR/CSR 不一致。

React self-healed: hydration mismatch triggered re-render, suspended retry succeeded, concurrent re-render after throw. Watching the baseline of this channel reveals SSR/CSR divergence bugs.

边界粒度 · 全局 boundary 是个反模式

Boundary granularity · a global boundary is an antipattern

初学者常犯的错:把整个 App 包在一个 ErrorBoundary 里。这意味着任何一个子组件出错都让整个页面 fallback。正确做法是边界细粒度化——通常一个有意义的独立功能区就是一个 boundary:

Beginner mistake: wrap the whole App in one ErrorBoundary. Now any error blows the entire page to fallback. Correct: granular boundaries — typically one per meaningful independent feature area:

// 反模式 · 一个挂全挂
<ErrorBoundary>
  <App />
</ErrorBoundary>

// 正确 · 每个独立 feature 一个
<ErrorBoundary fallback={'header 出错'}>
  <Header />
</ErrorBoundary>
<ErrorBoundary fallback={'文章加载失败'}>
  <ArticleContent />
</ErrorBoundary>
<ErrorBoundary fallback={'评论暂不可用'}>
  <CommentList />
</ErrorBoundary>
// 一个 feature 挂掉, 其他依然正常
FIELD NOTE · ErrorBoundary 接不住的几类错误 FIELD NOTE · What ErrorBoundary cannot catch 事件处理器里的错(onClick 抛错)— 不在 render 流里,try/catch 自己处理。异步代码里的错(setTimeoutfetch 的 reject)— 同上。SSR 期间的错 — React 17 之前不接、18+ 在 streaming SSR 里有自己的回调。Error Boundary 自己 render 时抛错 — 不会自捕,会冒到上一层的 boundary(如果有)。这四类 React 19 全靠 onUncaughtError 兜底。 Errors in event handlers (onClick throw) — not in render flow; you handle with try/catch. Errors in async code (setTimeout, fetch reject) — same. SSR errors — pre-17 not caught; 18+ has its own streaming-SSR callbacks. The boundary itself throwing in render — won't self-catch; bubbles to the next boundary up (if any). All four are caught by React 19's onUncaughtError as last resort.
CHAPTER 15·C

Automatic Batching — React 18 最沉默的革命

Automatic Batching — React 18's quietest revolution

从 event-boundary 到 lane-based · 跨 setTimeout/Promise 自动合并

from event-boundary to lane-based · cross-setTimeout/Promise auto-batching

阶段
Phase
COMMIT · 边界设计
源码
Source
packages/react-reconciler/src/ReactFiberWorkLoop.js
引入
Shipped
React 18.0 (2022)
破坏性
Breaking
极少
I · Mount II · Click III · Click² IV · Unmount

"Automatic Batching" 是 React 18 最不起眼的特性——发布博客只用一段话讲, RFC 没几个人读, 但它悄悄改了一件根本性的事: setState 多次调用什么时候被合并成一次 render。这个边界在 React 17 是按事件类型定的, 18 改成了按 lane 定。这个一字之差让跨 setTimeout / Promise / fetch / 第三方库回调的 setState 也能自动合并——大量手写优化代码瞬间过时。

"Automatic Batching" is React 18's least-noticed feature — the release post mentions it briefly, the RFC barely read, but it quietly changed something fundamental: when multiple setState calls coalesce into one render. In React 17 this boundary was decided by event type; in 18 it's decided by lane. That one-word swap made setStates across setTimeout / Promise / fetch / third-party callbacks also batchable — overnight obsoleting reams of hand-written optimization.

React 17 之前 · event-boundary batching

Pre-React 17 · event-boundary batching

// React 17 的 batching 边界 (简化)
function batchedEventUpdates(fn, e) {
  const prevBatching = isBatching;
  isBatching = true;                       // 开启 batch
  try {
    return fn(e);                          // 跑用户 handler
  } finally {
    isBatching = prevBatching;                // 关闭 batch
    if (!isBatching) flushSyncCallbackQueue();// 一次性 flush
  }
}

// React 17: 这个 handler 触发 1 次 render
handleClick = () => {
  setCount(c + 1);
  setName("Alice");  // 两个 setState 在同一 batch
};

// React 17: 这个 handler 触发 2 次 render (灾难)
handleClick = () => {
  fetch('/api').then(() => {
    setCount(c + 1);      // fetch.then 已经出了原 handler 范围
    setName("Alice");     // isBatching === false → 每个 setState 独立 render
  });
};

React 18 之后 · lane-based batching

React 18+ · lane-based batching

React 18 把"是不是在 batch" 这个全局开关彻底去掉了。新逻辑: setState 永远把更新推入 lane 队列, scheduler 在下一个微任务 (microtask) 决定是否 flush。多个 setState 落在同一 lane (或纠缠 lane) 自然合并——跟在不在 event handler 里无关

React 18 fully removes the "are we batching" global flag. New logic: setState always enqueues into a lane queue; scheduler decides when to flush at the next microtask. Multiple setStates landing in the same lane (or entangled lanes) naturally coalesce — unrelated to whether you're in an event handler.

// React 18+ 的 batching 核心 (简化)
function scheduleUpdateOnFiber(fiber, lane) {
  fiber.lanes |= lane;
  let root = fiber;
  while (root.return) {
    root.return.childLanes |= lane;
    root = root.return;
  }
  root.pendingLanes |= lane;

  // ★ 不立即 render! 排个微任务
  ensureRootIsScheduled(root);
}

function ensureRootIsScheduled(root) {
  if (root.callbackNode !== null) return; // 已经排过, 不重复

  const nextLane = getNextLane(root.pendingLanes);

  if (nextLane === SyncLane) {
    // SyncLane 用 microtask flush — 比 setTimeout(0) 快
    queueMicrotask(() => performSyncWorkOnRoot(root));
  } else {
    // Concurrent lanes 用 scheduler 的 MessageChannel
    root.callbackNode = Scheduler.scheduleCallback(...);
  }
}

// React 18: 现在两种写法都是 1 次 render
handleClick = () => {
  fetch('/api').then(() => {
    setCount(c + 1);
    setName("Alice");
    // 两个 setState 推入同一 lane, 同一 microtask flush → 1 次 render ✓
  });
};
BATCHING 跨异步边界 · React 17 vs React 18 React 17 · event-boundary click handler 起 fetch() batch off ~80 ms 后 promise resolve setCount render! setName render! 2 次 render ✗ React 18 · lane-based click handler 起 fetch() ~80 ms 后 promise resolve setCount setName μtask render! 1 次 render ✓ 差异本质 React 17: setState 立即决定要不要 render (看 isBatching 全局开关), 出了 event handler 边界就 fail React 18: setState 永远入队, 在下一个微任务一次性 flush——event 边界不再重要 用户感知差: React 17 写法常见 1-2 帧的"中间状态" 闪烁; React 18 写法零闪
FIG 15C·1 同样代码在 React 17 vs 18 下的执行差异。Promise 边界把 17 的 batching 打碎成两次独立 render; 18 用 microtask flush 收回了 batching。这就是 React 18 升级时极少有人写代码改动却"感觉更流畅了"的原因。 Fig 15C·1 · The same code under React 17 vs 18. The Promise boundary shatters 17's batching into two separate renders; 18 reclaims batching via microtask flush. This is why upgrading to React 18 "just felt smoother" without code changes.

flushSync · 强制冲刷 escape hatch

flushSync · the force-flush escape hatch

99% 的情况你希望自动 batching, 但有极少情况你需要"这个 setState 立刻 render 完, 我下一行代码要读新 DOM"。React 18 提供 flushSync 作为 escape hatch:

99% of the time you want automatic batching, but in rare cases you need "render this setState immediately, the next line reads the new DOM". React 18 ships flushSync as the escape hatch:

import { flushSync } from 'react-dom';

function handleScroll() {
  flushSync(() => {
    setShowDetails(true);    // 立即同步 render + commit
  });
  // 这一行执行时, <Details /> 已经在 DOM 里了
  detailsRef.current.scrollIntoView();   // 滚到刚显示的元素
}
用 flushSync 的场景
极少
Rare
① 需要立刻 scrollIntoView 到刚 setState 出现的元素;② 跟非 React 第三方库交互需要 DOM 立即更新;③ 调用 print() 前确保 UI 是最新的。
① scrollIntoView to an element just rendered via setState; ② interop with non-React libs needing immediate DOM; ③ before calling print() to ensure latest UI.
flushSync 的代价
High
强制走 sync 路径 → 没有 batching → 没有 transitions → 没有 Suspense fallback。如果它内部触发了 N 个 setState, N 次独立 render。慎用。
Forces sync path → no batching → no transitions → no Suspense fallback. If it internally triggers N setStates, N separate renders. Use sparingly.
vs queueMicrotask
推荐
Preferred
如果你只是想"等 commit 完再做事", 用 queueMicrotaskrequestAnimationFrame 而不是 flushSync——前者不破坏 batching, 后者破坏。
If you only want "do this after commit", use queueMicrotask or requestAnimationFrame instead of flushSync — the former preserves batching, the latter breaks it.

unstable_batchedUpdates · 历史的残骸

unstable_batchedUpdates · historical relic

React 17 之前, 如果你想要"在 setTimeout 里也 batch", 你得手写 ReactDOM.unstable_batchedUpdates(() => {...})。这个 API 从 React 0.13 就有, 一直 unstable 了 10 年——因为 React 团队知道它该被自动化掉。React 18 正式 ship Automatic Batching 后, 这个 API 变成no-op——它仍存在 (为了向后兼容), 但里面什么都不做。今天的代码库里如果还看到它, 是 React 16/17 时代的考古遗物, 可以放心删。

Pre-React 17, if you wanted "batch inside setTimeout too", you wrote ReactDOM.unstable_batchedUpdates(() => {...}). This API existed since React 0.13, unstable for 10 years — because the React team knew it should be automated away. After React 18 shipped Automatic Batching, this API became a no-op — it still exists (backwards compat) but does nothing inside. If you see it in modern code, it's archaeology from the React 16/17 era; safe to delete.

Counter ACT III 是 batching 的真正展示

Counter ACT III is the real batching demo

回到本文主线: Counter 的 ACT III 是"3 ms 内连点两次"。这两次 click 是两个独立 DOM event, 不是一个 handler 里的两次 setState。在 React 17 下, 它们必然产生 2 次 render。在 React 18 下, 如果两次 click 都被排到同一个 microtask 之前——它们会合并成 1 次 render。这是 lane-based batching 的隐性收益: 用户高频操作不再产生帧抖动。

Back to the through-line: Counter's ACT III is "two clicks within 3 ms". These are two separate DOM events, not two setStates in one handler. In React 17 they always produce 2 renders. In React 18, if both clicks arrive before the same microtask flush — they coalesce into 1 render. The implicit benefit of lane-based batching: high-frequency user input no longer causes frame jitter.

CHAPTER 16

Lanes — 31 条优先级车道

Lanes — 31 priority channels

用位掩码替代优先级队列

a bitmask instead of a priority queue

阶段
Phase
SCHEDULER · 优先级模型
源码
Source
packages/react-reconciler/src/ReactFiberLane.js
位宽
Width
31 bits
取代
Replaces
React 16 expirationTime
I · Mount II · Click III · Click² IV · Unmount

React 16 用过期时间(expirationTime,单位 ms)表示更新优先级——精确但实现复杂,尤其在合并多个 update 时。18 改成了 Lanes:一个 31 位的位掩码,每位代表一种优先级。低位优先级高,高位优先级低。一棵子树的"待办任务" = 它和所有子孙的 lanes 按位或。

React 16 used expirationTime (ms) to encode priority — precise but complex when merging updates. 18 replaced it with Lanes: a 31-bit bitmask, one bit per priority class. Lower bits = higher priority. A subtree's "work to do" = OR of its and all descendants' lanes.

31 条车道地图

All 31 lanes

// ReactFiberLane.js · simplified
export const NoLanes                = 0b0000000000000000000000000000000;
export const SyncLane               = 0b0000000000000000000000000000010; // click / 用户输入
export const InputContinuousLane    = 0b0000000000000000000000000001000; // mousemove / scroll
export const DefaultLane            = 0b0000000000000000000000000100000; // 普通 setState
export const TransitionLanes        = 0b0000000001111111111111111000000; // 16 条 transition 车道
export const RetryLanes             = 0b0000111100000000000000000000000; //  4 条 Suspense 重试
export const IdleLane               = 0b0100000000000000000000000000000; // 最低,可被任何事打断
export const OffscreenLane          = 0b1000000000000000000000000000000; // <Activity> 隐藏树
PRIORITY LADDER · highest at top SyncLane click · keypress · drop · 异常恢复 never sliced InputContinuous mousemove · scroll · drag Default setState in effect / network response Transition (16) startTransition · useDeferredValue · RSC 流 Retry (4) Suspense 数据回来后的重渲染 Idle 最低 · 可被任何事打断 · pre-fetch · log Offscreen <Activity hidden> 后台树
FIG 16·1 React 19 Lane 优先级阶梯。一次 update 只属于一条 lane;一个 fiber 上挂着所有未完成 lane 的位或。 Fig 16·1 · React 19's lane ladder. One update lives in one lane; a fiber holds the OR of all pending lanes.

为什么是位运算

Why bitwise

Lanes 设计的核心追求是合并和查询都要快。位运算让这两件事都是 1 个 CPU 周期

Lanes target one thing: merge + query in one CPU cycle. Bitwise wins:

operationlane codecost
合并两组优先级Merge two priority setsa | b1 cycle
检查是否有某优先级Check if a priority is present(lanes & lane) !== 01 cycle
取最高优先级Pick highest prioritylanes & -lanes1 cycle (lowest set bit)
移除已完成优先级Remove completed prioritylanes & ~done1 cycle
为什么 16 条 transition 车道 Why 16 transition lanes 同时间可能有多个 startTransition 并行进行——比如用户连续在搜索框打了三次。React 给 transition 留了 16 条独立车道——每个 transition 自动占一条。这样它们之间互不打断,但又能各自独立完成或回滚。多于 16 条会被复用(hash 进同一条)——实践中几乎不会发生。 Multiple startTransition calls can be in flight — e.g. user types in a search box three times. React reserves 16 distinct lanes; each transition claims one. They don't interrupt each other but can each independently complete or roll back. More than 16 collapses (hash-share a lane) — virtually never happens in practice.
CHAPTER 17

Time Slicing — 5ms 一个切片

Time Slicing — 5 ms a slice

MessageChannel · shouldYield · 调度器内部

MessageChannel · shouldYield · inside the scheduler

阶段
Phase
SCHEDULER · 时间切片
Package
packages/scheduler/src/Scheduler.js
切片预算
Frame budget
5 ms (concurrent)
让步机制
Yield via
MessageChannel.postMessage
I · Mount II · Click III · Click² IV · Unmount

scheduler 包是 React 团队从一开始就有分离野心的产物——它不依赖 React 任何东西,可以单独 npm 用。它只做一件事:让一段任务在不阻塞浏览器的前提下分批跑完。它的核心是两件事:用 MessageChannel 当让步机制,用 小顶堆 管理任务队列。

The scheduler package was always meant to be standalone — depends on no React internals, ships separately on npm. Its only job: run a task across batches without blocking the browser. Two pieces: MessageChannel as yield mechanism, min-heap as task queue.

为什么不用 requestIdleCallback

Why not requestIdleCallback

rIC 触发太晚
rIC fires too late
requestIdleCallback 等到浏览器真的空了才触发——可能 50ms 后。React 想要的是"每个 5ms 切片之间立刻有让步点",rIC 太懒散。
requestIdleCallback waits for actual idle — can be 50 ms away. React wants "yield immediately between 5 ms slices". rIC is too lazy.
rIC 浏览器支持差
rIC has poor browser support
2026 年 Safari 仍未实现 requestIdleCallback。MessageChannel 是 99%+ 兼容的标准 API。
As of 2026 Safari still hasn't shipped requestIdleCallback. MessageChannel has 99%+ support.
setTimeout(0) 不够快
setTimeout(0) isn't fast enough
setTimeout(fn, 0) 实际最低延迟 4ms(HTML 规范钳制),且 task 优先级低。MessageChannel 立刻在下一个 task 触发,且优先级和宏任务一致。
setTimeout(fn, 0) has a 4 ms floor (HTML spec clamp) and low task priority. MessageChannel posts to the next task with full task priority.
// scheduler/src/SchedulerPostTask.js · 简化版
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;

function requestHostCallback(callback) {
  scheduledHostCallback = callback;
  port.postMessage(null);    // 触发下一个 task
}

function performWorkUntilDeadline() {
  const startTime = getCurrentTime();
  deadline = startTime + frameInterval; // 5 ms
  try {
    const hasMore = scheduledHostCallback(deadline);
    if (hasMore) port.postMessage(null); // 再来一片
  } finally {
    needsPaint = false;
  }
}

function shouldYield() {  // React workLoop 每个 Fiber 后调一次
  return getCurrentTime() >= deadline;
}

为什么是 5ms

Why 5 ms

60 fps = 16.7 ms/帧。但浏览器自己(layout / paint / GC / 输入分发)也要时间。Andrew Clark 2019 年 PR 把切片预算定在 5 ms——大部分常见 Fiber 处理时间都在 1ms 内,5ms 能在让步前跑完一个组件树的一两层,又留足 11ms 给浏览器自己。Meta 用 200K LOC 的 React app 实测后选定的数。

60 fps = 16.7 ms/frame. The browser itself (layout / paint / GC / input dispatch) needs time too. Andrew Clark's 2019 PR fixed the slice budget at 5 ms — typical Fiber work is <1 ms each; 5 ms covers a couple of subtree levels before yielding, leaving 11 ms for the browser. Meta benchmarked this on a 200K-LOC React app and stuck with it.

A LONG-LIST RENDER (1000 nodes) · concurrent vs sync 0 16.7 33.4 50.1ms 67ms SYNC render · 56 ms · 完全阻塞 · 用户点击直到 56 ms 后才响应 ✗ 3 dropped frames CONCURRENT ✓ 0 dropped frames 11 slices · 5 ms each click → handled within 5 ms 同一棵树同一工作量 · Concurrent 用 11 个 5ms 片完成 · 每片之间让步
FIG 17·1 同一棵 1000 节点的树在 sync 和 concurrent 下的执行图。Sync 56 ms 阻塞;concurrent 切成 11 片,每片之间响应用户输入。 Fig 17·1 · The same 1000-node render under sync vs concurrent. Sync blocks 56 ms; concurrent breaks it into 11 slices that yield between each.

Concurrent 的代价 — 没人公开讨论的部分

The cost of Concurrent — what nobody talks about

Concurrent Mode 的卖点都被讲烂了——可中断、可丢弃、不阻塞输入。但它的真实代价很少有人写。在 React 19 默认开启 Concurrent 三年之后,下面这四笔账值得认账:

The Concurrent sales pitch is overdone — interruptible, discardable, doesn't block input. The real costs, less so. Three years into React 19 shipping Concurrent on by default, four bills are due:

StrictMode 双 render —— 你写的每个组件 dev 模式跑两次
StrictMode double-invoke — every component runs twice in dev
为了曝光"不纯"的 render 函数,dev 模式下每次 mount 都 invoke 两次。console.log(...) 会打两次(吓到的人不计其数)、Date.now() 会跑两次、随机数生成两次值不同。这是 opt-in(但默认模板都开),关掉就丢了 Concurrent 的核心安全保证。
To expose impure render functions, dev mode invokes every mount twice. console.log(...) fires twice (catching countless devs off guard), Date.now() runs twice, random numbers diverge. Opt-in — but default templates ship it on. Disable and you forfeit Concurrent's core safety net.
tearing 的潜在风险 —— Concurrent 才有这条 bug
Tearing risk — a bug class Concurrent introduced
同一次 render 被切成几片,中间外部状态变了 → 组件 A 看 v1、组件 B 看 v2,界面"撕裂"。React 18 之前不可能发生(render 不可中断)。useSyncExternalStore(见 Ch12B)就是为这个问题设计的——但必须外部 store 用它,混用旧 hook 仍可能撕裂。Redux v7、Mobx 旧版、自写订阅 hook 都中招过。
A single render sliced across moments — external state changes mid-flight → component A sees v1, component B sees v2, UI "tears". Impossible pre-18 (render couldn't pause). useSyncExternalStore (Ch12B) is the fix — but external stores must use it; mixing old subscription patterns still tears. Redux v7, old Mobx, hand-rolled subscription hooks: all got bit.
心智负担 —— transition / deferred / Suspense 的组合复杂度
Mental load — transition / deferred / Suspense permutations
"什么时候应该用 startTransition"没有简单答案。包错了:用户输入掉到 TransitionLane 会感觉迟钝;不包:列表重渲染卡 100ms。Suspense 边界的位置选择也一样:放太外,整片白屏;放太里,spinners 太多。React 19 默认开 Concurrent 后,每个新组件都要做这些决定
"When should I use startTransition" has no clean answer. Wrap too much: user input lands in TransitionLane, feels sluggish. Wrap too little: list re-renders block for 100 ms. Same for Suspense boundary placement: too high, big white flash; too low, spinner soup. With React 19 defaulting to Concurrent, every new component faces these decisions.
小应用反而更慢 —— scheduler 自身的常数开销
Tiny apps run slower — the scheduler has fixed overhead
scheduler 包本身要做:MessageChannel 调度、shouldYield 检查、lane 计算、小顶堆维护。一棵只有 10 个 fiber 的小子树,sync 模式跑完 0.05 ms;走 concurrent 反而 0.12 ms。React 团队的策略是"小到 sync 走得动就 sync"——SyncLane 就是为此设计。但对于边界模糊的中等规模应用,concurrent 不一定是性能净赚。
The scheduler itself does work: MessageChannel posting, shouldYield checks, lane math, min-heap maintenance. A 10-fiber subtree finishes in 0.05 ms under sync; 0.12 ms under concurrent. React's strategy is "if it's small enough to fit in sync, go sync" — SyncLane exists for this. But for medium-sized apps where the boundary is ambiguous, concurrent isn't always a net win.
什么时候 Concurrent 真的更慢 When Concurrent is actually slower 实测场景:① 小组件树(<50 fiber)+ 频繁 mount/unmount —— scheduler 开销大于 render。② 紧密耦合的多个 setState 链式触发(A 的 effect setState B) —— concurrent 下每条链都要重排 lane。③ 大量 Provider 嵌套 + 频繁 value 变 —— propagateContextChange 比 sync 走得更长。React 团队不否认这些 —— 18 RFC 里 明确说:"Concurrent is the new default but not a free lunch". Real-world cases: ① small trees (<50 fibers) with frequent mount/unmount — scheduler overhead outweighs render. ② tightly coupled setState chains (A's effect setStates B) — concurrent has to reorder lanes each link. ③ deep Provider nesting with frequent value changes — propagateContextChange runs longer than sync. The React team doesn't deny this — 18 RFC explicitly says "Concurrent is the new default but not a free lunch".

Entangled Lanes · 车道纠缠

Entangled Lanes · lane entanglement

Lane 模型有个反直觉的设计:两条 lane 可以"纠缠"。意思是 React 会把它们视为同一条——一条触发,另一条必须一起跑完。这个机制存在的全部理由:避免 tearing

The lane model has a counterintuitive design: two lanes can be "entangled" — React treats them as one, so when one triggers, the other must run together. Reason for existing: prevent tearing.

最常见的纠缠场景是 useSyncExternalStore(Ch12B)。当一个外部 store 同时被同步代码和 transition 代码读时,它们不能用不同的 store 快照渲染——否则同一屏上一半组件显示旧值、一半显示新值。React 通过把这些 lane 纠缠到一起来强制一致性。

The classic case is useSyncExternalStore (Ch12B). When an external store is read by both sync code and transition code, they cannot render with different store snapshots — half the screen on old, half on new = tear. React entangles those lanes to enforce consistency.

LANE ENTANGLEMENT · 防 store tearing 没有纠缠 · 会撕裂 SyncLane CompA render store=v1 ✓ TransitionLane CompB render (later) store=v2 ✗ tear! ▼ store v1 → v2 有纠缠 · 一致 Sync + Trans CompA + CompB 同一次 render · 同一 store 快照 v1 ✓ 一致 ▼ store v1 → v2 (after both rendered) 纠缠强制:两条 lane 算同一条任务。等同时算完,store 才允许被读到下一个版本。
FIG 17·2 Lane 纠缠的本质:把多条 lane "临时合并" 成一条。一致性保证:两条原本独立的渲染必须共享同一个 store 快照——这就是 React 18 的 tearing-free。 Fig 17·2 · Entanglement essence: temporarily fuse multiple lanes into one. Consistency: two originally independent renders must share one store snapshot — this is React 18's tearing-free guarantee.
// ReactFiberLane.js · entanglement 数据结构
type FiberRoot = {
  ...
  entangledLanes: Lanes,                // 总位掩码: 哪些 lane 处于纠缠中
  entanglements: LaneMap<Lanes>,     // 每条 lane 纠缠了哪些其他 lane
};

function markRootEntangled(root, entangledLanes) {
  root.entangledLanes |= entangledLanes;
  let lanes = entangledLanes;
  while (lanes) {
    const index = pickArbitraryLaneIndex(lanes);
    const lane = 1 << index;
    root.entanglements[index] |= entangledLanes;  // 双向纠缠
    lanes &= ~lane;
  }
}

Starvation Protection · 5 秒饥饿提升

Starvation Protection · 5-second priority elevation

优先级模型有个经典问题:低优先级永远等不到。想象一棵被 startTransition 包的列表渲染——它在 TransitionLane 上跑,正常情况会被切片完成。但如果用户每秒不停打字,每次 keystroke 都把 SyncLane 优先级抬上来,TransitionLane 永远没机会获得完整的时间片——它饿死了。

A classic priority-model bug: low-priority never gets its turn. Picture a list render wrapped in startTransition — running on TransitionLane, sliced normally. If the user types continuously, each keystroke pumps SyncLane priority up; TransitionLane never gets a clean slice — it starves.

React 用过期时间解决这个:每条 lane 都有一个隐式的 expirationTime。如果某条 lane 的等待时间超过阈值,React 在下次 schedule 时把它当作 SyncLane来跑——一定一次跑完,不再切片

React's fix: expiration time. Every lane has an implicit expirationTime. If a lane has waited beyond its threshold, React schedules it as if it were SyncLane the next time — runs to completion, no slicing.

// ReactFiberLane.js · 各级 lane 的过期时间
function computeExpirationTime(lane, currentTime) {
  switch (lane) {
    case SyncLane:
    case InputContinuousLane:
      return NoTimestamp;       // 立即, 不需要过期机制
    case DefaultLane:
      return currentTime + 5000;  // 5 秒后强制完成
    case TransitionLane1:
    case TransitionLane2:
    ...
      return currentTime + 5000;  // 5 秒后强制完成
    case IdleLane:
      return NoTimestamp;       // 永不过期 (用 idle 本就是接受永等)
  }
}

function markStarvedLanesAsExpired(root, currentTime) {
  let lanes = root.pendingLanes;
  while (lanes) {
    const index = pickArbitraryLaneIndex(lanes);
    const lane = 1 << index;
    const expirationTime = root.expirationTimes[index];
    if (expirationTime !== NoTimestamp && expirationTime <= currentTime) {
      root.expiredLanes |= lane;   // ★ 标记为已过期, 下次按 SyncLane 跑
    }
    lanes &= ~lane;
  }
}

这个 5 秒不是随便定的:用户能忍受"稍微卡一下"的最大时长大约就是 5 秒——再长用户会以为应用挂了。React 用这个数字平衡两边:5 秒内尽量保持响应(切片可中断),超过就宁可卡 100ms 也要完成

5 seconds isn't arbitrary: ~5 seconds is the longest a user tolerates "a bit of lag" before assuming the app is broken. React balances both sides: within 5 s, stay responsive (slice + interruptible); beyond, better to block 100 ms than starve forever.

实战观察 · 怎么看到饥饿 FIELD · how to spot starvation Chrome Performance 里:① 找一段 transition 包的渲染长块;② 看它前面有没有大量 SyncLane 短块(用户输入);③ 如果 transition 块 突然变长 到几百 ms 且整段不让步,恭喜你抓到了一个被饥饿提升过的 lane——这就是 React 把它当 SyncLane 跑的特征。修法:① 把 transition 拆得更小(多个 startTransition),② 用 useDeferredValue 替代——同样的 priority 但更细的控制。 In Chrome Performance: ① find a long block from a transition-wrapped render; ② check if many SyncLane short blocks (user input) precede it; ③ if the transition block suddenly stretches to hundreds of ms with no yield, congrats — you've spotted a starvation-elevated lane being run as SyncLane. Fix: ① break the transition into smaller pieces (multiple startTransition), ② use useDeferredValue instead — same priority, finer control.
CHAPTER 17·B

Interruption Semantics — 中断之后, React 怎么恢复?

Interruption Semantics — how React resumes after a yield

workInProgressRoot · same-lane resume · higher-lane restart · prepareFreshStack

workInProgressRoot · same-lane resume · higher-lane restart · prepareFreshStack

问题
Problem
"yielded — now what?"
三条出路
Three exits
RESUME · RESTART · DROP
源码
Source
performConcurrentWorkOnRoot
设计
Design
lane-comparison

前一章把"怎么让出"讲完了——shouldYield() 返回 true,workLoop 退出。但下一次回来的时候,React 到底是接着画,还是从头再来? 这个问题前面被一笔带过,实际上是 Concurrent Mode 最容易踩坑的部分,也是面试时"concurrent 鸡毛蒜皮"的高频提问点。这一章把三条出路全摊开。

Last chapter explained "how to yield" — shouldYield() returns true, workLoop bails out. But when React comes back, does it resume where it left off, or start fresh? Previously brushed over. This is the trickiest part of Concurrent Mode, and a top interview-question source. This chapter lays out all three exits.

三个状态变量 — React 怎么记住"我画到哪了"

Three state variables — how React remembers "where I was"

// packages/react-reconciler/src/ReactFiberWorkLoop.js — module-level state
let workInProgressRoot: FiberRoot | null = null;             // 正在 render 哪棵 root
let workInProgress: Fiber | null = null;                  // 下一步要 begin 哪个 fiber
let workInProgressRootRenderLanes: Lanes = NoLanes;          // 这次 render 持有的车道
let workInProgressRootExitStatus: RootExitStatus = NotStartedYet; // IN-PROGRESS / SUSPENDED / COMPLETED

让步那一刻 React不清空这些变量——它们就那么挂在 module scope 上。等到下次 scheduler 又有 5 ms 给你时,performConcurrentWorkOnRoot 进来,第一件事是检查这些变量:

On yield, React does not clear these variables — they hang on module scope. When the scheduler hands back 5 ms, performConcurrentWorkOnRoot enters and inspects them first thing:

DECISION TREE · performConcurrentWorkOnRoot (next slice) scheduler resumes us checks: workInProgressRoot, RenderLanes vs nextLanes root same? lanes same? getNextLanes(root) === RenderLanes ? YES / YES ① RESUME same-lane resume action: just call workLoopConcurrent() again — workInProgress still points at the next fiber. cost: 0 — no fresh stack, all prior work preserved. "continue painting" YES / DIFFERENT ② RESTART higher-lane preempts action: prepareFreshStack(root, lanes) → throw away wip tree, start over from root cost: whatever wip work was done is wasted — but only that. "throw away, redo" NO / — ③ DROP / SWITCH root changed or no lanes action: if (workInProgressRoot !== root) prepareFreshStack on new root if (no lanes) return — done when: portal/sub-root scheduled, or batched-bail moved all work away "context switch" 读这张图记住: yield 是廉价的——但restart 是有代价的 Yields are cheap — restarts cost whatever wip was built. Concurrent Mode tries to resume, falls back to restart only when forced. React 18 中, restart 触发率 < 5% (Meta 内部数据); 大多数 yield 都走 RESUME 分支。
FIG 17·B·1 每次 yield 回来,React 走这个三叉决策树。RESUME 是默认(同 root 同 lane)——零成本接着画。RESTART 只在出现更高优先级 lane 时才发生——比如 user 在你画 transition 时按了输入键, SyncLane 立刻顶上, transition 的 wip 全扔。DROP/SWITCH 罕见——发生在多个 root (portal / sub-app) 抢调度时。 Fig 17·B·1 · On every yield-return, React walks this three-way decision tree. RESUME is the default (same root, same lane) — zero cost, just keep painting. RESTART fires only when a higher-priority lane arrives — e.g. user typed while you were mid-transition, SyncLane jumps in, transition wip is discarded. DROP/SWITCH is rare — multiple roots (portals / sub-apps) competing for the scheduler.

prepareFreshStack — restart 那一刻发生什么

prepareFreshStack — what happens at a restart

// react-reconciler/src/ReactFiberWorkLoop.js · simplified
function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
  root.finishedWork = null;
  root.finishedLanes = NoLanes;

  // 1. 把之前 wip 上的 Effects 也清掉(不然 commit 时会跑过期的 effect)
  const timeoutHandle = root.timeoutHandle;
  if (timeoutHandle !== noTimeout) {
    root.timeoutHandle = noTimeout;
    cancelTimeout(timeoutHandle);
  }

  // 2. workInProgress 重新指回 root → 整个 wip 树就 garbage 了
  //    (旧 wip fiber 还在内存里, 但通过 alternate 链已经访问不到, 等 GC)
  workInProgressRoot = root;
  const rootWorkInProgress = createWorkInProgress(root.current, null);
  workInProgress = rootWorkInProgress;
  workInProgressRootRenderLanes = subtreeRenderLanes = lanes;

  // 3. context stack / hooks dispatcher 也重置——任何"render 过程的副作用"全部回退
  resetContextDependencies();
  finishQueueingConcurrentUpdates();
  workInProgressRootExitStatus = RootInProgress;

  return rootWorkInProgress;
}

这就是为什么 "render 阶段必须是纯函数" 不是一句教学口号——而是 Concurrent 架构强制的物理约束。如果 render 中你打印了一个 console.log("hi"),那次 restart 之后这句话会被再打印一次——因为整个 render 重新跑。React 18 在 dev StrictMode 下故意 双重渲染 你的 component, 就是故意暴露这个语义: 不是 React bug, 是 Concurrent 架构的物理事实。

This is why "render must be a pure function" isn't pedagogy — it's a physical constraint of Concurrent. If render contains console.log("hi"), after a restart that line prints twice — render runs again from scratch. React 18 in dev StrictMode intentionally double-renders your components — surfacing this semantics on purpose. Not a bug, just Concurrent's physics.

三种"恢复"的真实代价 — 实测

Real cost of the three "resume" modes — measured

M2 MacBook · Chrome 130 · 500-fiber subtree 0 ms 2 ms 4 ms 0.04 ms RESUME just enter loop 5.3 ms prep + redo whole render from root RESTART higher-lane preempt 4.6 ms root + dispatcher reset, fresh render SWITCH cross-root 5.1 ms baseline (no yield) SYNC FULL reference
FIG 17·B·2 三种恢复成本对比, 500 fibers / M2 MacBook。RESUME 几乎免费(0.04 ms); RESTART 等于"从头跑一遍 render", 加上 prepareFreshStack 开销(5.3 ms vs 5.1 ms sync); SWITCH 因为还要 reset 跨 root 状态, 比 restart 稍便宜(4.6 ms)——但它本来就是不同 root。所以 Concurrent Mode 的延迟优势依赖于 RESUME 比例 ≫ RESTART 比例——大多数应用里这个比例是 95:5。 Fig 17·B·2 · Three resume-cost modes for a 500-fiber subtree. RESUME is essentially free (0.04 ms); RESTART equals "full render redo" plus prepareFreshStack overhead (5.3 ms vs 5.1 ms sync); SWITCH is slightly cheaper than restart (4.6 ms) because the cross-root reset replaces some render work. So Concurrent Mode's latency win depends on RESUME ≫ RESTART — typical apps hit a 95 : 5 ratio.
读完这一章, 你已经能回答的两个问题 After this chapter you can answer two questions ① "useTransition 把更新降到 TransitionLane 之后, 用户的 click 中断它, 这个 click 触发的 render 一定不会在已经画到一半的 wip 上继续——会从根 restart"。② "StrictMode 下 dev 时 effect 跑两次是 React 团队故意做的 Concurrent 一致性自检——如果你的 effect 没法跑两次还得到正确结果, 那么在生产 Concurrent restart 发生时也会出 bug"。 ① "After useTransition demotes an update to TransitionLane, when a user click interrupts it, that click's render cannot continue on the half-drawn wip — it restarts from the root." ② "StrictMode's double-fire of effects in dev is React's deliberate Concurrent consistency check — if your effect doesn't tolerate running twice and still produce the right result, you'll hit the same bug in production when a Concurrent restart fires."
CHAPTER REFERENCES
workLoop.js prepareFreshStack, performConcurrentWorkOnRoot · RWG #27 · concurrent rendering · Andrew Clark · React 18 keynote · StrictMode (the double-fire rationale)
workLoop.js prepareFreshStack, performConcurrentWorkOnRoot · RWG #27 · concurrent rendering · Andrew Clark · React 18 keynote · StrictMode (the double-fire rationale)
CHAPTER 18

Suspense & Transitions — 异步的 first-class

Suspense & Transitions — async as first-class

抛 Promise · render 中断 · fallback 兜底

throw a Promise · pause render · fallback shown

阶段
Phase
SCHEDULER · 异步分支
机制
Mechanism
throw promise
边界
Boundary
<Suspense fallback>
React 19
React 19
use() · 真同步 await
I · Mount II · Click III · Click² IV · Unmount

Suspense 的实现机制极其反直觉——它用异常做控制流。当一个组件需要等异步数据,它抛出一个 Promise。Promise 沿调用栈往上传,被最近的 <Suspense> 边界捕获。边界把这棵子树打上"暂停"标记,渲染 fallback 占位。当 Promise resolve 时,该子树被重新调度一次 render。

Suspense's mechanism is wildly unintuitive — it uses exceptions as control flow. When a component needs async data, it throws a Promise. The Promise bubbles up to the nearest <Suspense> boundary, which marks that subtree as "paused" and renders the fallback. When the Promise resolves, that subtree is re-scheduled.

// React 19 use() · the actual signature is just "throw if not ready"
function use(usable) {
  if (usable !== null && typeof usable === 'object') {
    if (typeof usable.then === 'function') {
      switch (usable.status) {
        case 'fulfilled': return usable.value;
        case 'rejected':  throw usable.reason;
        default:           throw usable;          // ← suspends here
      }
    }
    if (usable.$$typeof === REACT_CONTEXT_TYPE) {
      return readContext(usable);
    }
  }
}
SUSPENSE · THROW & CATCH · 异常作为控制流 FIBER TREE HostRoot tag=3 App tag=0 (FC) <Suspense> tag=13 ★ catcher Profile tag=0 (FC) UserAvatar use(promise) ★ thrower throw promise 沿 fiber.return 上溯 Profile.return = Suspense → 落点 SUSPENSE 接住之后 ① Suspense fiber 状态变更 fiber.flags |= ShouldCapture primaryChild → 标记 hidden fallbackChild → 取而代之 render ② fallback 子树被 commit → <Spinner /> 出现在屏幕 Profile / UserAvatar 子树并未真的卸载,只是 hidden promise resolve ③ RetryLane scheduled workLoop 重 render UserAvatar use() 这次直接返回 value · 渲染真实内容 ④ commit · fallback → 真实 UI 用户看到 Profile 内容; Spinner 离场 use() throw 的不是错误 —— 是 React 当作合法控制流的信号
FIG 18·1 UserAvatar 抛出 Promise → 沿 fiber.return 上溯,每经过一个非 Suspense fiber 就继续传递,直到撞上 SuspenseBoundary(tag=13)→ 切到 fallback 子树 → 等 promise → RetryLane 重 render。throw 是合法控制流,不是错误。 Fig 18·1 · UserAvatar throws a Promise → bubbles via fiber.return, passing through every non-Suspense fiber, until it hits the SuspenseBoundary (tag=13) → swap to fallback subtree → await promise → RetryLane re-render. The throw is legitimate control flow, not an error.
CASE · 一个 Suspense 边界的生命周期
从 fallback 到内容
From fallback to content

① 用户点击导航 → React 进入 transition lane render <Profile /> → ② 子组件 useUser()use(userPromise),抛出 Promise → ③ workLoop 抓到,沿 fiber.return 往上找最近 SuspenseFiber → ④ SuspenseFiber 标记自己 didSuspend=true,切到 fallback 子树 → ⑤ commit fallback → 用户看到 spinner → ⑥ Promise resolve,scheduler 给该 SuspenseFiber 加 RetryLane → ⑦ workLoop 重 render,use 直接拿到 value,返回真实内容 → ⑧ commit 替换 fallback → 用户看到 profile。

① User clicks nav → React enters transition lane, starts rendering <Profile /> → ② child calls useUser()use(userPromise) throws → ③ workLoop catches, walks fiber.return to find nearest SuspenseFiber → ④ SuspenseFiber marks didSuspend=true, swaps to fallback subtree → ⑤ commits fallback → user sees spinner → ⑥ promise resolves, scheduler adds RetryLane to that SuspenseFiber → ⑦ workLoop re-renders, use returns value, real content emerges → ⑧ commit replaces fallback → user sees profile.

Transitions — 非阻塞 setState

Transitions — non-blocking setState

startTransition 把它内部的 setState 降级到 TransitionLane——这条 lane 比用户输入低,所以它的 render 可以被打断。最关键的:它在 render 期间不会触发 Suspense 兜底。当一棵子树在 transition 中等数据,React 会继续显示旧的 UI,直到新数据准备好——这就是 React 19 推崇的"pending UI 模式",胜过 spinner。

startTransition demotes its setStates to a TransitionLane — lower than user input, so its render is interruptible. The critical property: during transition render, Suspense does not show its fallback. While the subtree awaits data, React keeps showing the old UI until the new one is ready. React 19's preferred "pending UI pattern" — beats a spinner.

function SearchBox() {
  const [query, setQuery] = useState('');
  const [pending, startTransition] = useTransition();
  const [deferred, setDeferred] = useState('');

  return <>
    <input value={query} onChange={e => {
      setQuery(e.target.value);          // ← Sync · 立即响应输入
      startTransition(() => {
        setDeferred(e.target.value);     // ← Transition · 慢的搜索结果
      });
    }} />
    {pending && <span>updating...</span>}
    <SearchResults query={deferred} /> // 用旧 query 渲染,新数据准备好后才换
  </>;
}
SUSPENSE 的真正意义 WHAT SUSPENSE REALLY IS Suspense 不是"加载状态管理"——是渲染时的可中断点。它让一棵子树可以说"我还没准备好"而不破坏整体 UI。配合 transitions,它替代了 90% 的手写 isLoading 状态,因为 React 19 之前要写:{loading ? <Spinner /> : data ? <Profile data={data} /> : null},现在写:<Suspense fallback={<Spinner />}><Profile /></Suspense> Suspense isn't "loading state management" — it's a render-time interruptibility point. A subtree can say "not ready" without poisoning the surrounding UI. Combined with transitions, it kills 90% of hand-rolled isLoading code. Before: {loading ? <Spinner /> : data ? <Profile data={data} /> : null}. Now: <Suspense fallback={<Spinner />}><Profile /></Suspense>.
CHAPTER 19

一次更新的时间线 — 把 13 步合到一张图

An update's timeline — 13 steps on one canvas

从 click 到 paint · 走完所有阶段

click → paint · every stage in one frame

把前面 13 章的每一步标到时间轴上——这是 React 19 处理一次同步用户输入(点击)的完整路径。整段过程在 M2 MacBook + Chrome 130 上的中位数约 1.2 ms。如果某一节超出预算,问题大概率出在用 console.log 标到的位置之一:

Plot every stage on a single axis — the full path of a React 19 sync user input (click). Median ~1.2 ms on M2 MacBook + Chrome 130. If a step blows its budget, the offender almost always lives at one of the labeled marks below:

FOUR-ACT TIMELINE · 同一个 Counter 的一生 (M2 + Chrome 130 · 中位数) scheduler reconciler · render react-dom · commit effects · paint ★ root.current swap ACT I · MOUNT createRoot(...).render(<Counter/>) · 全树离屏构建 · ≈3.0 ms 到 useEffect 跑完 render · 5 fibers + offscreen DOM commit · appendChild root ★ PAINT useEffect → document.title = "#0" 0 1.2 1.4 1.6 3.0 ms ACT II · UPDATE #1 click → setCount(1) · SyncLane · 复用 fiber · 1 次 DOM 写 · ≈1.2 ms 到 useEffect ▼click render reuse commit★ PAINT useEffect → document.title = "#1" 0 0.18 0.25 0.3 1.2 ms ACT III · UPDATE #2 (rapid · ≈3 ms after II) 第二次 click · 同 SyncLane · queue 上一条 update 已经 flush · 流程更快 ▼click² render (lighter) commit★ PAINT cleanup + effect → document.title = "#2" 如果两次 click 在同一 handler ↗ 两条 update 合并 · 一次 render 0 0.15 0.20 0.9 ms ACT IV · UNMOUNT 用户切到别的路由 · ChildDeletion · 没有 render phase · 直接 commit + cleanup · ≈0.4 ms (no render — being deleted) commit · ChildDeletion cleanup · ref detach removeChild Counter 子树从内存释放 10 个 fiber (current + alt) 进入 GC 队列 0 0.15 0.30 0.4 ms TOTAL · 一个 Counter 的一生 ≈ 5.5 ms · 但分布在用户感知的 N 秒里 真正 "看得到的 JS 工作" 仅占用户停留时长的 0.001%——其他 99.999% 都在等 click / scroll / network
FIG 19·1 Counter 一生的全栈四幕时间线。Mount 最慢(1.5 ms),后续每次 click 几乎 6 倍快(0.3 ms),Unmount 最快(0.4 ms 且无 render)。注意:四个 act 在用户感知上跨数秒,但每幕内部都是亚毫秒级。 Fig 19·1 · Counter's full lifecycle in four acts. Mount is slowest (~1.5 ms), each subsequent click is ~6× faster (~0.3 ms), Unmount fastest (~0.4 ms with no render). Note: the four acts span seconds in user-perceived time, but each act internally is sub-millisecond.
CHAPTER 20

Server Components — 把 Fiber 序列化送过来

Server Components — streaming a serialized Fiber

在服务器跑 render · 客户端只收"成品"

render on server · client receives the finished tree

RSC 不是 SSR——是把组件这个抽象本身切成两半。Server Component 跑在服务器 / build 时,永远不会出现在客户端 bundle 里。它的输出是一段"序列化的 React Element 流"——不是 HTML 字符串。客户端 React 解析这段流,把它和本地的 Client Component 拼起来,渲染出最终 UI。

RSC isn't SSR — it splits the component abstraction itself in two. A Server Component runs on the server or at build, never ships to the client bundle. Its output is a "serialized React-Element stream" — not HTML. The client React parses the stream, stitches it with local Client Components, and renders the final UI.

CLIENT COMPONENT
'use client' 标记
marked 'use client'

下到客户端跑。能用 useState / useEffect / 事件 / 浏览器 API。代价:占 JS bundle。

Ships to client. Can use useState / useEffect / event handlers / browser APIs. Costs JS bundle bytes.

SERVER COMPONENT
默认 · 不下到客户端
default · no client ship

服务器跑。能用 async/await、读数据库、读文件系统、用 server-only 库。代价:不能有交互。

Server-only. Can await fetch, hit DB, read FS, use server-only libs. Costs: no interactivity.

// 一个 Server Component (Next.js App Router)
async function UserPage({ id }) {
  const user = await db.users.findById(id);  // ← 在服务器直接 await
  return <section>
    <h1>{user.name}</h1>
    <EditButton userId={id} />  // ← 这是 Client Component, ships
  </section>;
}

// EditButton.jsx
'use client';
export function EditButton({ userId }) {
  const [editing, setEditing] = useState(false);  // ✅ ok, client
  return <button onClick={() => setEditing(true)}>Edit</button>;
}

RSC payload 长什么样

What the RSC payload looks like

服务器序列化输出的是一段React 内部 wire format——介于 JSON 和流式格式之间。每一行是一段独立的 "row",浏览器收到第一行就可以开始渲染。这就是 RSC 自带流式 SSR能力的根因。

Server-side serialization produces React's own wire format — between JSON and a streaming format. Each line is one "row"; the browser starts rendering as soon as the first row arrives. RSC's streaming SSR comes for free from this.

// what the browser actually receives (simplified RSC payload)
0:[["$","section",null,{"children":[
  ["$","h1",null,{"children":"$1"}],         // $1 — pending data
  ["$","$L2",null,{"userId":42}]              // $L2 — client comp ref
]}]]
1:"Alice"                                     // data row resolves $1
2:I["./EditButton.js",["chunks/edit-x.js"],"EditButton"]  // client manifest
RSC 是不是 SSR Is RSC just SSR? 不是。SSR 输出 HTML 字符串,客户端 hydration 时要把整棵组件树再 render 一遍。RSC 输出序列化 React Element,客户端不需要重 render Server Component——它们从来没在客户端跑过、也没在客户端 bundle 里。这是质的差别。可以把 RSC 看作"把组件的输出当数据"——React 第一次让"组件本身"成了一种网络协议。 No. SSR ships HTML; the client re-renders the whole component tree during hydration. RSC ships serialized React Elements; the client never re-renders Server Components — they never ran client-side, aren't in the bundle. A different category of thing entirely. The shortest way to put it: RSC turns a component's output into a wire format — the component itself becomes a network protocol.

Wire format 全解剖 · 6 种 row 类型

Wire format · the 6 row types

RSC payload 是一个面向行的流——每一行是一个独立单元,客户端解析器(react-server-dom-webpack/client)拿到任意完整行就能消化掉。每行格式:ID:TYPE PAYLOAD\n。第一个字符决定 row 的类型——React 19 定义了 6 种。

RSC payload is a line-oriented stream — each line is a self-contained unit; the client parser (react-server-dom-webpack/client) consumes whole lines as they arrive. Format: ID:TYPE PAYLOAD\n. The first character of TYPE picks the row kind — React 19 defines 6.

RSC PAYLOAD · 真实样本 (Next.js 14 dump · 简化) Content-Type: text/x-component · 16 lines · 2.1 KB · streaming // hint rows — 让浏览器预热 H :["preconnect",["https://cdn.app"]] // module manifest — Client Component 的位置 1 : I ["./EditButton.js", ["chunks/edit-x.js"],"EditButton"] // model row — 树形结构 0 :[["$","section",null,{ "children":[ ["$","h1",null, {"children": "$2" }], ["$"," $L1 ",null,{"userId":42}] ]}]] // value row — 数据库查询结果 2 : "Alice" // error row — 如果某分支抛了 3 : E {"digest":"abc123", "message":"User not found"} // 后续行可能继续流 ... H · Hint preconnect / preload / prefetch I · Module reference 客户端组件的 bundler ID 0 · Model row (★ 主体) React Element 树的序列化 ["$","tag",key,props] 嵌套 N · Value row 引用 (eg "$2") 在 model 里出现 这一行 resolve 该引用 E · Error row 某子树抛错时插入 digest 给生产, message 给 dev T · Text chunk · S · Symbol 大字符串切片 / Symbol() 引用 较少见 引用机制 $1 → 行 ID 1 的 value $L1 → 行 ID 1 的 module (Lazy) $@1 → 行 ID 1 的 promise $F1 → 行 ID 1 的 server action $E → 任意 Error 类型 $ 表示 React Element 标记
FIG 20·1 RSC wire format 一次完整 dump。行是独立单元——客户端拿到任意完整行立即消化,未到的引用先挂 placeholder。这就是 RSC 自带流式能力的根:协议级的可分块。 Fig 20·1 · A full RSC wire-format dump. Rows are self-contained units — the client consumes any complete row instantly, placeholders fill in for refs not yet arrived. This is the protocol-level chunkability that gives RSC its streaming.

Server Actions · 把"函数"发到客户端

Server Actions · shipping a "function" to the client

React 19 最大胆的设计是 Server Action——你在 Server Component 里定义一个 async 函数,把它绑给 <form action><button formAction>。客户端拿到的不是源码,而是一个引用。点按钮时,客户端 POST 一个请求到服务器,服务器查表跑这个函数。整套 RPC 机制对开发者完全透明

React 19's boldest design is Server Actions: you define an async function inside a Server Component and bind it to <form action> or <button formAction>. The client doesn't receive the source — it receives a reference. On click, the client POSTs to the server; the server looks up and runs the function. The whole RPC machinery is invisible to the developer.

// app/page.jsx · Server Component
export default async function Page() {
  async function addTodo(formData) {
    'use server';                       // ← 标记 server action
    const text = formData.get('text');
    await db.todos.insert({ text });   // 直接读写数据库
    revalidatePath('/');                  // 自动重 render Server Component
  }

  return <form action={addTodo}>
    <input name="text" />
    <button>add</button>
  </form>;
}

// 客户端拿到的 RSC payload 里 addTodo 是这样:
//   "$F5"  ← Server Action 引用, ID = 5
// bundler 会为每个 'use server' 函数生成一个 ID + 注册到 manifest
SERVER ACTION · 一次提交的完整路径 BUILD TIME · BUNDLER 扫描所有 'use server' 标记 → 为每个函数分配稳定 ID 生成 action manifest: { "$F5": "app/page.js#addTodo", "$F6": "app/admin.js#deleteUser" } manifest 同时驻留 server + client SERVER · RUNTIME render 时把 action 序列化为 $F5 送进 RSC payload 收到 POST /_action?id=$F5 从 manifest 查表 → 真函数 运行 → 返回新 RSC payload CLIENT · RUNTIME 解析 RSC: form.action = $F5 → 实际拿到一个 wrapper: async function (formData) { return fetch('/_action?id=$F5', { method:'POST', body: serialize(formData) }); } 用户点 submit → wrapper 被调用 → POST 出去 收到新 RSC payload → React reconcile 新树 → 局部更新(不刷页) ★ 整个过程对开发者透明 没写 fetch · 没写 endpoint 没写 controller · 没写 router ① RSC payload ② POST /_action ③ 新 RSC payload
FIG 20·2 Server Action 的三段路径:① bundler 给每个 'use server' 函数分配稳定 ID($F5),② 客户端收到的是个 fetch 包装器,③ 用户提交 → POST → 服务器从 manifest 查表跑真函数 → 返回新 RSC payload → 客户端 reconcile。 Fig 20·2 · Server Action's three legs: ① the bundler assigns each 'use server' function a stable ID ($F5); ② the client receives a fetch wrapper; ③ user submits → POST → server resolves the ID via manifest → runs the real function → returns new RSC payload → client reconciles.
闭包能跨网络吗 Can closures cross the network? 能。 当 server action 是嵌套在 Server Component 里的,它闭包了外层作用域的变量。React 19 把这些绑定参数序列化进客户端 wrapper,调用时一并发回。addTodo.bind(null, userId) 形式的预绑参数也同样支持。代价:被闭包的值必须是可序列化的(字符串、数字、纯对象、Date、Map、Set)——闭包了一个函数?编译期报错。这是 React 团队对"函数引用"和"函数值"边界的清晰划分。 Yes. When a server action is nested inside a Server Component, it closes over the outer scope. React 19 serializes those bound arguments into the client wrapper, sending them along on invocation. addTodo.bind(null, userId)-style preset args work the same way. Cost: closed-over values must be serializable (string, number, plain objects, Date, Map, Set) — closed over a function? Compile error. A clear React-team boundary between "function reference" and "function value".

Streaming SSR + Selective Hydration · 让 Suspense 真的能流式

Streaming SSR + Selective Hydration · making Suspense actually stream

React 18 给 SSR 注入了两个改变行业的能力:StreamingSelective Hydration。两者协作让旧的"render-to-string-then-blast-everything"模式彻底失效。

React 18 added two industry-shifting capabilities to SSR: Streaming and Selective Hydration. Together they killed the old "render-to-string-then-blast-everything" model.

OLD · React 17 SSR
同步阻塞 · 一次性
Sync blocking · monolithic

renderToString() 必须等全部组件 render 完才能输出。一个慢数据库查询 → 整个页面卡住。客户端收到 HTML 后又得从头 hydrate 整棵树才能开始交互。最慢的部分决定一切

renderToString() waits for every component to render before output. One slow DB query → whole page stalls. Client receives the HTML then must hydrate the entire tree before any interaction. The slowest part dominates everything.

NEW · React 18+ Streaming
流式 · 分块
Streamed · chunked

renderToPipeableStream() 按 Suspense 边界切——边界外的 HTML 立刻 flush,边界内的留个 <div hidden id="B:1"> 占位。数据准备好后服务器把真实 HTML 追加到流末尾,加上一段 inline script 把占位替换成内容。Hydration 也按 Suspense 边界独立进行——某段慢,不耽误其他段交互。

renderToPipeableStream() chunks at Suspense boundaries. Outside-the-boundary HTML flushes immediately; inside leaves a <div hidden id="B:1"> placeholder. When data resolves, the server appends real HTML to the stream tail plus an inline script that swaps the placeholder. Hydration also goes per-boundary — one slow chunk doesn't block others' interactivity.

STREAMING SSR · 一个真实页面的时间线 t=0 100 ms 400 ms (DB) 1200 ms (推荐 API) 2 s Server shell HTML placeholders UserCard HTML Recommendations HTML Browser DOM parse paint shell spinners UserCard paints Recs paint Hydrate shell ready UserCard ready Recs ready ★ 用户点击 UserCard 上的按钮 → 优先 hydrate UserCard, Recs 排后 三个关键时刻 @ 100ms · shell 就 paint 了——TTFB 不再被慢数据库拖累, FCP 大幅前移 @ 400ms · UserCard 一回来立刻就位——不等剩下的 API, 不阻塞 @ 用户点击 · 该区被优先 hydrate——交互不必等整页, INP 大幅改善
FIG 20·3 Streaming SSR 的"瀑布":server 一段一段 flush,浏览器一段一段 paint,hydration 一段一段就位。用户点击哪儿,那一段先被 hydrate——这是 Selective Hydration 的核心。 Fig 20·3 · The Streaming SSR "waterfall": server flushes chunk by chunk, browser paints chunk by chunk, hydration completes chunk by chunk. Wherever the user clicks, that chunk hydrates first — that's Selective Hydration.

RSC payload 到 Fiber 树 · 客户端拼接的精确过程

RSC payload → Fiber tree · the client's exact assembly

前面看了 server 怎么发出 RSC payload,但客户端怎么把流式 row 拼回 Fiber 树这块还薄。这是 RSC 最不显但最关键的一段——它让"边收边渲染" 这件事真的发生。

We saw how the server emits RSC payload, but the harder question is: how does the client assemble streamed rows back into a Fiber tree? The least visible yet most critical part of RSC — it's what makes "render as you receive" actually happen.

RSC HYDRATION ASSEMBLY · 客户端的 3 个状态机 ① 网络 · 流到达 ② 解析器 · 内存表 ③ 协调器 · Fiber 树 t=0 0:[["$","main",null,{ "children":["$","h1",null, {"children":"$1"}]}]] model[0] = <main>...</main> refs.pending = {$1: [placeholder]} create <main> fiber create <h1> fiber create Suspense placeholder fiber t=100ms 1:"Hello Airing" // resolves $1 refs[1] = "Hello Airing" → notify subscribers of $1 <h1> child placeholder → 替换为 TextNode "Hello Airing" t=180ms 2:I["./Like.js", ["chunks/btn.js"], "LikeButton"] moduleManifest[2] = { url:"chunks/btn.js", export:"LikeButton" } fetch chunks/btn.js create LazyComponent fiber tag=16 占位 t=220ms 3:[["$","$L2",null, {"postId":42, "initialCount":17}]] model[3] = <$L2 postId=42/> $L2 待 chunks/btn.js 到达 refs.pending[L2] = [...] LazyComponent fiber 等 module 加载 → 设 type=LikeButton props={postId:42, initialCount:17} t=350ms chunks/btn.js loaded // browser 完成 fetch LikeButton fn 可用 moduleCache[L2] = LikeButton → resolve all pending refs to L2 → notify LazyComponent fiber LazyComponent → SimpleMemoComp render LikeButton({postId,initialCount}) attach handlers · hydrate done ✓ 三个状态机协作的关键点 解析器维护两张表: model[id] (已 resolve 的值) + refs.pending (等待中的引用 → 订阅者列表) ▸ 同一个 fiber 可以多次状态变更: 先是 placeholder, 然后等到 ref 后原地变成真实节点 (不重建 fiber) ▸ Client Component ($L2) 需要三段 ready: module manifest 到 + chunk JS 加载完 + props 到——三者全到才 hydrate ▸ 整个过程是 push-based: 任何 row 到达都立刻触发该 row 的订阅者 (其他 fiber/refs), 不轮询 ▸ 这就是 RSC 自带"边收边渲"——协议+解析器+协调器三层是 streaming-friendly 的
FIG 20·4 客户端把 RSC payload 拼回 Fiber 树的精确过程。三个状态机并行: 网络流接收 row、解析器维护 model/refs 表、协调器构建 Fiber 树。同一个 Fiber 可以多次状态变更(placeholder → 真实节点), 不重建——这是 RSC 流式渲染的核心机制。FIG 18·1 同源: 都是"等 promise 然后 fill" 模式——Suspense 是组件粒度的等, RSC 是row 粒度的等。 Fig 20·4 · How the client assembles RSC payload into a Fiber tree. Three state machines in parallel: network stream receiving rows, parser maintaining model/refs tables, reconciler building the Fiber tree. The same Fiber can change state multiple times (placeholder → real node), not rebuilt — the core mechanism enabling RSC's streaming render. Same family as FIG 18·1: both are "wait-then-fill" — Suspense waits at component granularity, RSC waits at row granularity.
为什么 RSC 不重建 Fiber Why RSC doesn't rebuild Fibers 关键洞察: client 一收到第一个 row 就开始 构 Fiber 树, 后续 row 是填空而非重建。如果走"等所有 row 到齐再构" 的设计, RSC 就退化成"JSON 而非流"——失去了所有 streaming 价值。React 选择在 Fiber 节点上预留 placeholder 状态, 让同一节点的身份保持稳定, 只是内部 type/props 随 row 到达就地更新。这是 RSC 协议设计能 work 的根基。 Critical insight: the client starts building the Fiber tree on the first row, subsequent rows fill blanks, not rebuild. A "wait for all rows then build" design would degrade RSC to "JSON, not stream" — losing all streaming value. React preserves placeholder state on Fiber nodes, keeping node identity stable while internally updating type/props as rows arrive. The foundation that makes RSC's protocol design work.

三层(RSC / SSR / Streaming)的关系

RSC / SSR / Streaming · how they layer

三者最容易被搞混。一句话:SSR 解决"首屏 HTML 早出",Streaming 解决"慢部分不拖累快部分",RSC 解决"组件可以不送 JS"。它们可以独立使用、也可以组合——React 19 + Next.js 14 的标准栈是三者全开。

All three get confused. In one line: SSR makes the first HTML arrive sooner; Streaming keeps slow parts from blocking fast parts; RSC means components don't need to ship JS. They can stack — React 19 + Next.js 14's default stack runs all three.

解决什么需要什么2026 普及度
SSR (基础)SSR (basic)首屏 HTML · SEO · 慢网络可用first paint · SEO · slow networksrenderToString() · Node 服务器~85%
+ Streaming+ Streaming局部慢不拖累全局slow parts don't block fast partsrenderToPipeableStream + Suspense~60%
+ Selective Hydration+ Selective Hydration交互优先 · INP 优化interaction-first · INP同 streaming · 自动comes with streaming~60%
+ RSC+ RSC组件不进客户端 bundlecomponents stay off the client bundle编译期分类 + RSC wire formatbuild-time partition + RSC wire~30%
+ Server Actions+ Server Actions写 fetch / API route 的尽头end of writing fetch / API routesRSC + bundler action manifest~25%
为什么 RSC 不强求 Streaming SSR Why RSC doesn't require Streaming SSR RSC 是组件 → wire format,wire format 既可以走流式 HTML(配合 SSR)也可以走 fetch(纯客户端导航)。Next.js App Router 里你导航到新页面时,浏览器 fetch 一份 RSC payload,走 HTML SSR——客户端 React 直接消化 payload 渲染新页。所以 RSC 是底层协议,Streaming SSR 是它的一种使用方式。这两者解耦才让 RSC 能跨 framework 复用——Remix、Astro、Waku 都各自基于同一个 wire format 实现。 RSC is component → wire format; that wire format can be served as streaming HTML (with SSR) or over fetch (pure client nav). In Next.js App Router, when you navigate, the browser fetches an RSC payload without HTML SSR — client React consumes it directly. So RSC is the underlying protocol; Streaming SSR is one way to use it. The decoupling is why RSC is cross-framework — Remix, Astro, Waku each implement against the same wire format.
横向对比 · Astro Islands 走得更远 SIDE-BY-SIDE · Astro Islands goes further Astro 的"Islands 架构"和 RSC 解决相似问题——但更激进。Astro 默认把整个页面 render 成静态 HTML, 不下发任何 JS; 只有你用 client:load / client:idle / client:visible 标记的""才会下发对应组件的 hydration 代码——而且每个岛独立 hydrate, 不形成一棵整体 React 树。代价:不能在岛之间共享 React state(每个岛是独立 root)。Astro 把这当 feature: 大多数页面 80% 是静态。RSC 处理同样问题用的是"整页是一棵 React 树, 但其中部分组件不发 JS"方法——心智更统一但下发字节略多。两种思路, 各有市场。 Astro's "Islands architecture" attacks the same problem as RSC, but harder. Astro renders whole pages as static HTML by default and ships no JS; only "islands" tagged client:load / client:idle / client:visible get hydration code, and each island hydrates independently — never one React tree. The cost: islands can't share React state (each is its own root). Astro calls that a feature: most pages are 80% static anyway. RSC takes a different route — "one React tree per page, just don't ship JS for parts of it" — a more unified mental model that costs a few extra bytes. Two approaches; both have a market.

'use client' 是个边界标记 — bundle graph 长这样

'use client' is a boundary marker — the bundle graph

很多人以为 'use client' 是"把这个文件下发到客户端"——其实它是"把这个文件标成客户端入口"。bundler (Webpack / Turbopack / Vite) 看到这个指令, 会把这个文件 + 它所有静态依赖切到 client bundle, 同时在父侧 (server) 留一个占位 reference。RSC 序列化时, 这个 reference 变成 "$Lid" 行——告诉客户端"这里放一个 EditButton, manifest 第 id 项"。

It's tempting to think 'use client' means "send this file to the client". It actually means "this file is a client entry". The bundler (Webpack / Turbopack / Vite) treats the marked file plus all its static imports as the client bundle, and leaves a placeholder reference on the server side. When RSC serializes, that reference becomes a "$Lid" row — telling the client "put an EditButton here, manifest entry id".

MODULE GRAPH — what bundler does on seeing 'use client' SOURCE GRAPH (your code) page.tsx [Server] UserView.tsx [Server] EditButton.tsx 'use client' ★ heavy-mod.tsx imported by EditButton db-client.ts [Server-only] utils.ts isomorphic imports: page → UserView → EditButton → heavy-mod page → db-client · UserView → utils BUNDLER CUTS HERE BUNDLER OUTPUT (two bundles) SERVER BUNDLE runs on Node / Edge page.tsx UserView.tsx db-client.ts utils.ts + ref: $L42 → EditButton CLIENT BUNDLE ships to browser EditButton.tsx ★ heavy-mod.tsx utils.ts (duped) react-dom-client shipped JS bytes CLIENT MANIFEST { "$L42": { id: "./EditButton", chunks: ["edit-x.js"], name: "EditButton" } } server reads at render time → emits "$L42" row in RSC stream ↓ severed 'use client' 在源码图上画一条虚拟切线 · bundler 沿这条线切两个包 · server 包从不引用客户端文件 'use client' draws a virtual cut through the module graph · bundler splits along it · the server bundle never imports the client side 服务器需要"引用" EditButton 时, 用 manifest 里的 ID $L42 替代 · 客户端 RSC parser 在 manifest 中查表后加载真实 chunk When the server "references" EditButton, it emits manifest ID $L42 · client's RSC parser looks it up and loads the real chunk
FIG 20·5 'use client' 在 bundler 眼里是一条切线。它把模块图切成两个包: server 包(含 db / utils / page 等)和 client 包(从 'use client' 文件起跟到所有静态依赖)。同一个 utils.ts 可能两边都有——只要它"isomorphic"(无 server/client-only API)。server 包里对客户端组件的引用全部替换成 $Lid 占位符, 通过 manifest 在 wire format 里桥接。 Fig 20·5 · 'use client' is a cut line to the bundler. It splits the module graph into two bundles: server (db / utils / page) and client (everything from 'use client' down). Some files like utils.ts may live on both sides — provided they're isomorphic (no server/client-only APIs). All references to client components from the server side are replaced with $Lid placeholders; the manifest bridges them in the wire format.

客户端解码 — 一行 wire format 怎么变成一棵 fiber

Client decode — how one wire row turns into a fiber

客户端 React (react-server-dom-webpack/client) 拿到 RSC payload 的每一行, 跑一遍解码 pipeline——这条 pipeline 是 RSC 性能模型的核心。下面这张图把单行 row 从字节流到 fiber 节点的全过程拉直了画:

Client-side React (react-server-dom-webpack/client) runs each row of the RSC payload through a decode pipeline — the heart of RSC's performance model. This figure unrolls a single row from byte stream to fiber node:

DECODE PIPELINE · a single row from byte stream to live fiber ① BYTES ReadableStream 3a:["$","h1",null, {"children":"Alice"}] 62 bytes · UTF-8 scan :: ② ROW SPLIT find \n boundary id = "3a" payload = "[..." resolveModel(id) ③ JSON.parse with reviver [ "$", "h1", null, {children: "Alice"} ] "$" sentinel = Element ④ DEREFERENCE $L → load chunk if "$L42": manifest[42].load() → Promise<Module> ⑤ ELEMENT { $$typeof, type: h1, props: ... } JSX-equivalent reconciler ⑥ FIBER NODE becomes Ch07 territory tag = 5 (HostComponent) · type = "h1" stateNode = <HTMLHeadingElement> RSC row → React Element is essentially a "JSON.parse with a reviver" — no JSX compile, no transform RSC's only client-side transform is JSON.parse with a custom reviver — vastly cheaper than running a Server Component's body
FIG 20·6 单行 RSC 的解码 pipeline。客户端不 Server Component 的函数体——它只解码那个函数已经在服务器上跑过的输出。所以即使 Server Component 写了 for (i=0; i<1e6; i++), 客户端 CPU 也毫无额外负担。这是 RSC 性能优势的根本来源。 Fig 20·6 · Decode pipeline for a single RSC row. The client does not run Server Component bodies — it only decodes the output those bodies already produced on the server. Even if a Server Component contains for (i=0; i<1e6; i++), the client CPU sees zero extra cost. That's the foundation of RSC's perf advantage.

Server Action — 一次往返的真实时间线

Server Action — the round-trip in time

Server Action 写法看着像本地函数, 实际上是声明式 RPC: 你写 <form action={addTodo}>, bundler 把 addTodo 替换成"POST 到某个 endpoint"的薄包装。下面这张图把一次 Server Action 提交的 6 个阶段拉到一条时间线上, 每段标注谁(浏览器/网络/服务器)花了多少 ms。

Server Actions look like local functions; they're declarative RPCs. You write <form action={addTodo}>; the bundler replaces addTodo with a thin wrapper that POSTs to an endpoint. This figure lays the 6 phases on one timeline, each labelled by who's working (browser / network / server) and for how many ms:

SERVER ACTION TIMELINE · 1 form submit · M2 + 50 ms RTT + 80 ms DB t=0 10 ms 100 ms 200 ms 230 ms BROWSER NETWORK SERVER ① submit + useOptimistic 2 ms ② upload POST + body 25 ms (RTT/2) ③ action dispatch + db.insert manifest lookup + 80 ms DB write 85 ms ④ re-render + RSC encode revalidatePath + serialize 15 ms ⑤ download RSC payload 25 ms (RTT/2) ⑥ decode + reconcile + commit 8 ms 用户感知延迟: ~2 ms(optimistic 立刻显示) · 真实数据落地: 230 ms 后 Perceived latency: ~2 ms (optimistic UI lights up instantly) · actual data settled: 230 ms later
FIG 20·7 Server Action 一次提交的完整时间线。用户感知 2 ms(useOptimistic 把假数据塞进 UI), 但真实的数据库写 + RSC 重渲 + 网络传输总共花 230 ms。这就是 React 19 Form Actions 的核心承诺: 把 RPC 的用户感知延迟从 RTT 量级压到本地状态切换量级。 Fig 20·7 · The full timeline of one Server Action submit. Perceived latency: 2 ms (useOptimistic stuffs fake data into the UI), but the actual DB write + RSC re-render + network = 230 ms total. This is React 19 Form Actions' core promise: compress an RPC's perceived latency from RTT-scale down to local-state-flip scale.

Streaming chunk 注入 — Suspense 怎么和 RSC 协作

Streaming chunk injection — how Suspense and RSC cooperate

RSC 真正的杀手锏不只是"组件不发 JS",而是渐进式注入——服务器一边算一边发,Suspense 边界遇到 pending Promise 时先发个 placeholder, 数据 ready 后追加一行把 placeholder 替换掉。客户端无需"等整页 ready", 也无需"SSR 完一次 HTML, hydrate 再 fetch 一次"——所有进展都来自同一条 HTTP 流

RSC's real superpower isn't just "some components ship no JS" — it's progressive injection. The server streams as it computes; at every Suspense boundary it emits a placeholder first, then tacks on another row later that replaces the placeholder when data is ready. The client never has to "wait for the whole page" or "SSR HTML once, hydrate, then fetch again" — all progress comes from one HTTP stream.

STREAMING INJECTION · 1 stream · 4 chunks · 3 Suspense boundaries SERVER chunk #1: shell + placeholders chunk #2: header data (fast db) chunk #3: user details (DB) chunk #4: comments (slowest) ↓ ONE HTTP RESPONSE · Transfer-Encoding: chunked · Content-Type: text/x-component never closes until all Suspense boundaries resolve CLIENT paint 1 shell + spinners visible <100 ms FCP ✓ paint 2 header filled in ~150 ms paint 3 user details in ~280 ms paint 4 comments in ~750 ms TTI ✓ WHAT ACTUALLY FLOWS ON THE WIRE (the same response, growing) t=0 → 0:{"shell":[...,"$L1","$L2","$L3"]} 1:"$P" 2:"$P" 3:"$P" ← chunk #1 ends t=120 → 1:"Alice — fast DB" ← chunk #2 ends t=250 → 2:{"role":"admin",...} ← chunk #3 ends t=720 → 3:[5 comments] ← chunk #4 ends, stream closes
FIG 20·8 流式 RSC + Suspense 协同。一次 HTTP 响应里持续注入 chunk, 每个 chunk 把上一个 chunk 留下的 "$P" placeholder 替换为真值。客户端的同一条 ReadableStream 上, React 边读边 reconcile, 每次完成一个 Suspense 边界就 commit 一次——所以你看到的不是"白屏 → 整页"而是"骨架 → 头像 → 详情 → 评论"的渐进效果。SSR 在此之上只多加了一步: 把首个 chunk 同时编码成 HTML, 让没装 JS 的客户端(或慢 hydration 的设备)也能看见首屏。 Fig 20·8 · Streaming RSC + Suspense cooperate. One HTTP response keeps injecting chunks; each chunk replaces the "$P" placeholders left by the previous one. On the client's same ReadableStream, React reads and reconciles as it goes — committing every time a Suspense boundary resolves. The user sees not "blank screen → full page" but "skeleton → header → details → comments". SSR adds one more layer: the first chunk is also encoded as HTML so JS-less clients (or slow hydration) still see a first paint.
CHAPTER 21

React 19 — use · Actions · Compiler · Owner Stacks

React 19 — use · Actions · Compiler · Owner Stacks

2024-26 的四件大事

the four big shipments of 2024-26

① CONDITIONAL READ
use()
第一个允许条件调用的"hook"——其实它不是 hook,是个普通函数。可以读 Promise / Context。配合 Suspense 用。
First "hook" that can be called conditionally. It's not really a hook — just a function. Reads Promise / Context. Pairs with Suspense.
② FORMS
Actions · useFormStatus · useActionState
<form action={fn}> 自动管理 pending / error / 乐观更新。砍掉大半 redux 样板。
<form action={fn}> auto-manages pending / error / optimistic state. Half the redux boilerplate gone.
③ AUTO MEMO
React Compiler
官方 babel 插件,自动判断哪些组件 / 闭包要 memo。手写 useMemo / useCallback 大部分可以丢了——但它仍是 opt-in,不强制。
Official babel plugin auto-decides which components/closures need memoization. You can drop most hand-written useMemo / useCallback — still opt-in.
④ DEBUG
Owner Stacks
DevTools 报错时显示真实组件嵌套链,而非 JS 调用栈。React 16 之后第一次让"谁渲染了谁"在生产可见。
DevTools error reports show the real component-owner chain rather than JS call stack. First time since React 16 that "who rendered whom" is production-visible.

React Compiler 做了什么

What React Compiler does

看下面这段 Counter 子组件:

Take this Counter helper:

// before — 手写优化
function Display({ count }) {
  const doubled = useMemo(() => count * 2, [count]);
  const onClick = useCallback(() => log(count), [count]);
  return <Big value={doubled} onClick={onClick} />;
}

// after — compiler 接管
function Display({ count }) {
  const doubled = count * 2;
  const onClick = () => log(count);
  return <Big value={doubled} onClick={onClick} />;
}
// ← compiler 在编译期自动加上 cache 槽,等价于上面那段,
//   但你不需要写 useMemo / useCallback。

Compiler 用静态分析识别"同输入同输出"的纯片段,自动给它们生成等价的 useMemo 代码。在 Meta 内部的 200+ 个 React 应用上,平均能消除 35-45% 的不必要 render。它不是 React 的运行时——产物是普通 JS。

The Compiler uses static analysis to detect "same input → same output" pure regions and emits equivalent useMemo code. Across Meta's 200+ React apps, it eliminates 35-45% of unnecessary renders. It is not a React runtime — output is plain JS.

FIELD NOTE · 你还要不要写 memo FIELD NOTE · should you still write memo 2026 年开 Compiler 的话:可以不写。不开 Compiler 的项目(一些尾期的 React 18 项目、Astro 中的 React island、嵌入式场景):继续写。React 团队的官方建议是"等 Compiler 进了你的工具链,再把 useMemo 删干净"——不要预先删,否则没开 Compiler 的同事跑会变慢。 In 2026, with Compiler enabled: skip them. Without Compiler (some lingering React 18 projects, React islands in Astro, embedded cases): keep them. Official advice: "wait until Compiler lands in your toolchain, then strip useMemo". Don't strip preemptively — colleagues without Compiler will slow down.

Compiler 的 IR · Memo Map

Compiler's IR · the Memo Map

"编译器" 听起来神秘——其实 React Compiler 就是个 babel plugin。它分三步走:① 解析 AST 找出""函数体(无外部 mutation, 无 ref read);② 建立依赖图,把每个表达式标注它依赖哪些 props/state/hook;③ 生成代码,在函数顶部插入一个 cache 数组, 把每个表达式按依赖签名挂上 cache slot。

"Compiler" sounds mystical — but React Compiler is just a babel plugin. Three steps: ① parse AST to find "pure" function bodies (no external mutation, no ref read); ② build dependency graph annotating each expression with its props/state/hook deps; ③ emit code with a cache array at the top of the function and each expression hung on a slot keyed by its deps signature.

// ① 编译输入
function Display({ count }) {
  const doubled = count * 2;
  const onClick = () => log(count);
  return <Big value={doubled} onClick={onClick} />;
}

// ② Compiler 内部 IR (Memo Map · 简化展示)
{
  cache_slots: 3,
  bindings: {
    "doubled": { expr: "count * 2", deps: ["count"], slot: 0 },
    "onClick": { expr: "() => log(count)", deps: ["count"], slot: 1 },
    "$result": {
      expr: "<Big value={doubled} onClick={onClick} />",
      deps: ["doubled", "onClick"],
      slot: 2
    }
  }
}

// ③ 实际输出 (运行时代码)
function Display({ count }) {
  const $ = useMemoCache(3);                // 3 个 cache slot
  let doubled;
  if ($[0] !== count) {                  // deps 比对
    doubled = count * 2;
    $[0] = count; $[1] = doubled;
  } else {
    doubled = $[1];
  }
  let onClick;
  if ($[2] !== count) {
    onClick = () => log(count);
    $[2] = count; $[3] = onClick;
  } else {
    onClick = $[3];
  }
  // ... result 同样 cache
}

注意 useMemoCache(3)——这是 React 19 给 Compiler 留的新原语。它返回一个挂在 fiber 上的数组,跨 render 稳定。比起一堆 useMemo 节省了大量 hook 节点(一个数组替代 N 个 hook)。

Note useMemoCache(3) — a new primitive React 19 reserved for the Compiler. Returns an array hanging on the fiber, stable across renders. Saves many hook nodes (one array vs N hook entries) compared to spamming useMemo.

Compiler 不会做什么 What Compiler will NOT do 不会跨函数优化——只在 function component 内部分析, 不展开调用图; ② 不会推断 ref——任何带 ref.current 读的代码段被视为不纯, 不 cache; ③ 不会重排——不调整代码执行顺序; ④ 不会移除 hook——你写的 useState 还在那, Compiler 只它而不它。这些边界是 Compiler 1.0 安全发布的前提——它宁可少优化, 不能改坏语义 No cross-function optimization — analyzes only inside one function component; doesn't expand the call graph. ② No ref inference — any code touching ref.current is treated as impure; never cached. ③ No reordering — preserves execution order. ④ No hook removal — your useState stays put; Compiler wraps, never replaces. These guardrails are why Compiler 1.0 ships safely — it prefers under-optimization to broken semantics.

Memo Map 的失效传播 · 一个细胞坏了整个表怎么办

Memo Map invalidation · what happens when one cell goes stale

Memo Map 不是简单的"一个 dep 变就重算这个 expr"——它建立了依赖图。如果 doubled 的依赖 count 变了, 那么引用了 doubled的所有下游表达式 (比如 $result 用了 doubled) 也要重算。Compiler 在编译期就把这个传播图算清楚——运行时只是按图查表。

Memo Map isn't a simple "dep changed → recompute this expr" — it's a dependency graph. If doubled's dep count changed, every downstream expression referencing doubled (e.g. $result using doubled) must also recompute. The Compiler resolves this propagation at compile time — runtime is just a lookup.

// Compiler 编译期建立的传播图 (内部 IR · 简化展示)
{
  count: { upstream: ["props"], downstream: ["doubled", "onClick"] },
  doubled: { upstream: ["count"], downstream: ["$result"] },
  onClick: { upstream: ["count"], downstream: ["$result"] },
  $result: { upstream: ["doubled", "onClick"], downstream: [] }
}

// 运行时: count 变 → 自动失效 doubled + onClick → 级联失效 $result
// 注意: Compiler 跑这个图, 它把每个表达式的 dep 比对编译进代码
// 输出的运行时代码是展开后的 if-else 链, 不是动态图遍历

function Display({ count }) {
  const $ = useMemoCache(6);   // 3 个 expr × 2 槽 (dep + value)
  let doubled, onClick, result;

  if ($[0] !== count) {        // 失效检查 1
    doubled = count * 2;
    onClick = () => log(count);
    $[0] = count;
    $[1] = doubled;
    $[2] = onClick;            // ★ doubled 和 onClick 是同时失效的 (因为同一个 dep)
  } else {
    doubled = $[1];
    onClick = $[2];
  }

  if ($[3] !== doubled || $[4] !== onClick) {   // 失效检查 2 (依赖上层结果)
    result = <Big value={doubled} onClick={onClick} />;
    $[3] = doubled; $[4] = onClick; $[5] = result;
  } else {
    result = $[5];
  }
  return result;
}

Compiler escape hatch · 三种"别管这段" 指令

Compiler escape hatches · three "hands off" directives

100% 自动优化的编译器不存在——总会有 Compiler 推不出来或推错的边角。React Compiler 提供三个 escape hatch:

A 100%-automatic optimizing compiler doesn't exist — corner cases will always slip through or get misanalyzed. React Compiler ships three escape hatches:

// ① "use no memo" 指令字符串 · 函数级关闭
function Display({ count }) {
  "use no memo";        // ← 整个 Display 不被 Compiler 处理
  ...
}

// ② // @no-memo 注释 · 行级跳过
function Display({ count }) {
  // @no-memo
  const manualThing = expensive(count);  // 这一行不 cache
  ...
}

// ③ Compiler 自动放弃 · "bail out"
function Display({ count }) {
  globalCounter++;            // 全局 mutation → Compiler 检测到 → 整函数 bail out
  ref.current = true;        // ref read/write → 同样 bail out
  try { } catch {}            // 复杂控制流 → bail out
  ...
}
// dev 模式下 console 会告诉你哪些组件被 bail out 了

实测性能 · Meta 200 应用的真实数字

Real perf · numbers from Meta's 200+ apps

指标开 Compiler 前开 Compiler 后变化
render 次数(中位数)renders (median)baseline-35% ~ -45%不必要 render 消除unnecessary renders gone
INP P95INP P95baseline-12%render 减少 → 输入响应更快fewer renders → faster input
JS bundle 大小JS bundle sizebaseline+3% ~ +8%每个 component 加 useMemoCache 调用 + dep 比对代码each component adds useMemoCache + dep-compare code
每次 render 自身耗时per-render timebaseline+5% ~ +10%dep 比对开销 (vs 不 memo 直接计算)dep-compare overhead vs raw recompute
总体 CPU 时间total CPU timebaseline-25% ~ -35%少 render 收益远大于 dep 比对成本fewer-renders gain >> dep-compare cost
FIELD NOTE · Compiler 不是免费午餐 FIELD NOTE · Compiler isn't a free lunch 表格里的 bundle +3-8% 是 React Compiler 最常被忽视的真相: 它把每个用 prop / state / hook 的表达式都加上 dep 比对 + cache slot 代码——所以"组件越多, bundle 越大"。中小型应用 (50 组件以下) 可能得不偿失; 中大型 (200+ 组件) 才能让"少 render 节省"覆盖 bundle 成本。这就是为什么 React 团队不强制默认开启——Compiler 1.0 仍是 opt-in。 The +3-8% bundle in the table is React Compiler's most-overlooked truth: it adds dep-compare + cache-slot code around every expression using prop / state / hook — so "more components → bigger bundle". Small-to-mid apps (< 50 components) might lose net; mid-to-large (200+ components) are where "fewer renders" saves enough to dominate bundle cost. That's why the React team doesn't force-default it — Compiler 1.0 remains opt-in.

Owner Stacks · 实现机制

Owner Stacks · how it's implemented

React 19 的 Owner Stacks 看起来魔法——错误信息里能显示"Avatar 被 UserCard 渲染、UserCard 被 DashboardPage 渲染"。秘密在 Fiber 的两个字段:_debugOwner_debugSource(dev 才存在)。

React 19's Owner Stacks look magical — error messages show "Avatar was rendered by UserCard, UserCard by DashboardPage". The secret is two Fiber fields: _debugOwner and _debugSource (dev-only).

// dev 模式下, 每个 createElement 调用都被埋入 owner
function createElement(type, config, ...children) {
  ...
  if (__DEV__) {
    element._owner = ReactCurrentOwner.current;
    // ★ ReactCurrentOwner 是个全局指针, 当前 render 函数对应的 Fiber
  }
  return element;
}

// renderWithHooks 在调用 Component() 前后维护这个指针:
function renderWithHooks(current, wip, Component, props) {
  ...
  ReactCurrentOwner.current = wip;       // ← 设置 owner
  const children = Component(props);     // ← createElement 调用此期间发生
  ReactCurrentOwner.current = null;     // ← 复位
}

// 当错误抛出时, React 沿 fiber._debugOwner 链生成 stack
function getOwnerStackByFiberInDev(fiber) {
  let stack = '';
  let current = fiber;
  while (current) {
    stack += `\n    at ${getComponentName(current)} (${current._debugSource})`;
    current = current._debugOwner;       // ← 不是 .return! 是 owner
  }
  return stack;
}

注意一个微妙之处:_debugOwner 不等于 returnreturn结构父——谁包含这个节点; _debugOwner渲染父——谁的 render 函数创建了这个节点。同一个组件可能被 N 个不同的 owner 渲染(比如 forwardRef 转发)。Owner stack 跟踪"创建之路"而非"位置之路",所以才有调试价值——它告诉你"这个 Bug 在我的代码里哪"。

Subtle point: _debugOwner is not the same as return. return is the structural parent — what contains the node; _debugOwner is the rendering parent — whose render created the node. A single component can be rendered by N different owners (forwardRef forwarding, for instance). Owner stack traces the "creation path", not the "position path" — which is what makes it debuggable: it answers "where in MY code is this bug".

fiber.return
结构父
Structural parent

谁包含我? JSX 上看 我的外层组件。生产环境永远存在, 是 reconcile 的核心字段。

Who contains me? My JSX-visible outer component. Exists in production, central to reconciliation.

fiber._debugOwner
渲染父 (owner)
Rendering parent (owner)

谁的代码 创造了我? 通过 createElement(MyComp, ...) 直接调用我的那个组件。仅 dev 存在, DevTools / error stack 用。

Whose code created me? The component that called createElement(MyComp, ...). Dev-only; used by DevTools / error stacks.

// 一个例子 · return ≠ owner
function Wrapper({ children }) {
  return <div className="wrap">{children}</div>;
}

function Page() {
  return <Wrapper><Avatar /></Wrapper>;
}

// 对 Avatar fiber:
//   fiber.return → div (它的结构父是 Wrapper 里的 div)
//   fiber._debugOwner → Page (它的渲染父是 Page, 不是 Wrapper)

// React 18: error stack 显示 Wrapper → 让你以为是 Wrapper 写错了
// React 19: error stack 显示 Page → 准确指出是 Page 调用错
CHAPTER 22

症状反查 — 现场问题对应到哪一章

Symptom lookup — pin a real bug to a chapter

debug 手册 · 把症状映射到流水线节点

a debug manual that maps symptoms to pipeline nodes

症状Symptom最可能原因Likely cause回看See
点击按钮 setState,输出却是旧值setState then read it back — got old value闭包陷阱:setState 是异步入队,当前作用域的 count 永远是旧值Closure trap: setState enqueues; the current scope's count is always staleCh12
列表换 key 后 input 失去焦点 / state 全清空List re-key → inputs lose focus / state resetskey 不稳定(用了 index 或 random)Unstable keys (index / random)Ch10
useEffect 跑两次(dev 模式)useEffect runs twice in devStrictMode 故意 mount→cleanup→mountStrictMode does mount→cleanup→mount on purposeCh15
DOM 已经改了但 document.title 没变DOM updated but document.title didn'tuseEffect 在 paint 后才跑——下一帧才会看到useEffect runs after paint — visible next frameCh15
输入卡顿 / drop frameInput lag / dropped frames同步 render 太重,把非紧急 setState 挪到 startTransitionHeavy sync render; wrap non-urgent setState in startTransitionCh16-18
同样 props 子组件仍 renderSame props, child still re-renders父传了新对象/函数引用,bail-out 失败Parent passed a new object/fn reference, bail-out missedCh09
巨大 list mount 卡 100ms+Big-list mount stalls 100ms+react-window / Suspense 边界 + Streaming + Concurrentreact-window / Suspense boundaries + streaming + concurrentCh17
Hydration mismatch 警告Hydration mismatch warningSSR 的 HTML 与客户端首次 render 不一致:日期/随机数/媒体查询SSR HTML diverges from client first render: dates / random / media queriesCh20
"Rendered fewer hooks than expected""Rendered fewer hooks than expected"在条件分支里调了 hook → 链表对不上Hook called inside conditional → list misalignsCh12
memo 包了仍重 rendermemo wrapped but still re-renderscontext 变了 → memo 兜不住 context 变化A context changed → memo doesn't shield from contextCh09
DEBUG WORKFLOW · 三步定位
怀疑 React 慢时这样查
If you suspect React is slow

① 开 React DevTools Profiler 录一段交互。看每个组件的 "Why did this render?"——找无意义的 render。② Chrome Performance 录同一段。看 commit 微任务长度——超过 5 ms 就是 commit 太重。③ 如果是 render 慢,跳到 Ch10 看是不是 diff/key 问题;如果是 commit 慢,看 Ch14/15。如果都不是 React 问题,那就是浏览器自己的 layout/paint——回去看 Chromium 渲染流水线

React DevTools Profiler — record an interaction. "Why did this render?" surfaces the useless renders. ② Chrome Performance — same interaction. Look at commit microtask length; >5 ms = commit is heavy. ③ If render's slow → Ch10 (diff/key). If commit's slow → Ch14/15. If neither, it's the browser's own layout/paint — see Chromium pipeline.

CHAPTER 22·B

Hydration Mismatch — 一张专门的诊断图

Hydration mismatch — a dedicated debug map

SSR / RSC 时代最常见的错误 #418, #419, #423

errors #418, #419, #423 — the most common bugs of the SSR / RSC era

"Hydration mismatch"是 SSR/RSC 项目里出现频率最高的 React 警告——但它的诊断路径散落在文档、博客、Discord 答疑中, 从来没有一张完整的"看到这条错就走这条流程"图。这一章补上。

"Hydration mismatch" is the most common React warning in SSR/RSC projects — yet its diagnostic path is scattered across docs, blog posts, and Discord threads. There's never been one complete "see this error → walk this flowchart" map. Here it is.

HYDRATION DEBUG FLOWCHART — see the error → follow the arrow SEE A WARNING? read the message · what # is it? #418 · text content "Hi Alice" ≠ "Hi Bob" most common · easy fix #419 · server suspended Suspense never resolved server hit a throw + no boundary #423 · text inside whole subtree mismatched often parent did Date.now() other · attribute / extra child e.g. data-* attribute differs 3rd-party script writing to DOM ASK YOURSELF — IN PRIORITY ORDER Q1 · render used Date.now() / Math.random() / Intl.DateTimeFormat() / navigator.lang? → FIX: move to useEffect + state, or use suppressHydrationWarning Q2 · render touched window / document / localStorage / matchMedia()? → FIX: gate with typeof window !== 'undefined' + useEffect Q3 · browser extension / 3rd-party script (Grammarly, dark-reader) writes to DOM? → FIX: put suppressHydrationWarning on body / extension target Q4 · number/currency/date formatted on both sides with different locale defaults? → FIX: lock locale, format once in a Server Component, pass as string Q5 · none of the above? open DevTools → React tab → "Components"; the warning lists the first divergent fiber. Walk up the tree from there — the actual cause is usually a parent that branches on server-only state. PERMANENT FIXES (preferred over suppressHydrationWarning) 把"server 算不出来"的值挪到 useEffect 里, 配 useState 把它写回; 用 Server Component 在服务器算一次,传 字符串 给客户端; 二阶段 render (mounted ? clientView : serverView), mounteduseEffect(() => setMounted(true), []) 翻起; 最后才考虑 suppressHydrationWarning ——它是"承认 mismatch, 跳过 warning", 不解决根因, 该用于明知差异的 timestamp / extension DOM 等。 Move server-uncomputable values into useEffect + state. Compute on server (Server Component), pass as string. Two-pass render gated by a mounted flag. Last resort: suppressHydrationWarning — admits the mismatch but doesn't fix it.
FIG 22·B·1 Hydration mismatch 的专用诊断流程: 先看 error 编号 (418 / 419 / 423 / 其他) → 再按优先级走 Q1-Q5 → 修复方案①-④ 按优先级。日常 80% 案例落在 Q1 (Date/Math.random) 和 Q2 (window/localStorage)。剩下 15% 是 Q4 (locale divergence) 和 Q3 (extension)。Q5 是其余 5% 的兜底。 Fig 22·B·1 · A dedicated hydration-mismatch diagnostic flow. Triage on error number (418 / 419 / 423 / other) → walk Q1-Q5 in priority order → pick fix ①-④ by preference. 80% of real cases land at Q1 (Date / Math.random) and Q2 (window / localStorage). Another 15% are Q4 (locale divergence) and Q3 (extension). Q5 is the catch-all for the remaining 5%.
React 19 的选择性 hydration 怎么和这个流程兼容 How React 19 selective hydration changes this React 19 把 hydration 做成边界级别——一个 Suspense 边界内的 mismatch 让那个边界回到 client-render, 其它边界不受影响。这意味着上面的修复优先级没变, 但影响范围变小了——你写 suppressHydrationWarning 时只放弃那一段的 SSR 收益, 不会拖累整页。React 18 之前这个错会让整页降级到 client-render。所以"修不动就 suppress"在 19 里成本明显降低了。 React 19 makes hydration boundary-scoped — a mismatch inside one Suspense boundary only kicks that boundary back to client render, leaving others unaffected. This doesn't change the fix priorities above, but it shrinks the blast radius: when you reach for suppressHydrationWarning, you give up SSR benefit only for that subtree, not the whole page. Pre-18, the same error degraded the entire page to client render. So "can't fix, just suppress" has gotten cheaper in 19.
CHAPTER 23

实战 · React DevTools Profiler

Field practice · React DevTools Profiler

flamegraph · ranked · why did this render · owner stacks

flamegraph · ranked · why did this render · owner stacks

工具
Tool
React DevTools
来源
Source
官方浏览器扩展
采样
Captures
每个 commit
React 19
React 19
Owner Stacks
I · Mount II · Click III · Click² IV · Unmount

从这一章起进入"field 章节"——不讲原理,讲怎么抓现行。React DevTools Profiler 是第一线索:它告诉你React 自己觉得哪里慢、为什么 render。它不告诉你浏览器有没有问题(那是下一章 Chrome Performance 的活)。两个工具配合,覆盖 95% 的现场。

From here we shift to "field chapters" — not theory, but how to catch problems in the wild. React DevTools Profiler is the first lead: it tells you what React itself thinks is slow and why a render happened. It does not tell you about the browser's behavior (that's next chapter). Together they cover 95% of cases.

Profiler 在抓什么

What Profiler actually captures

点 record → 操作页面 → 停止。这段时间里 React 把每一次 commit 都记下来:哪些 fiber 被 render 了、各自耗时、render 原因、是否 bail-out。Profiler 不抓 commit 之间的"空闲"——只抓 React 真的在干活的瞬间。

Press record → interact → stop. React records every commit in that window: which fibers rendered, how long each took, the render reason, whether bail-out fired. Profiler doesn't capture the "idle" gaps between commits — only the moments React is actually working.

REACT DEVTOOLS · Flamegraph 视图 (示意) Profiler Flamegraph · Ranked · Interactions · Components commit 3 / 8 COMMITS (横轴是时间,纵高代表 commit 总耗时) 3 ms 1.2 ★ 12 ms ← 慢 commit,点这块进 flamegraph FLAMEGRAPH (此 commit · 横宽=耗时 · 纵高=fiber 深度 · 灰色=未 render) App (memoized, did not render · 0 ms) Layout (memoized, did not render · 0 ms) TodoList (rendered · 8.4 ms) ★ 罪魁 ... <TodoItem> × 50, each ~0.15 ms ← 全部不必要 <Checkbox> · <Label> ... ε WHY DID THIS RENDER? TodoItem #14 ▸ props changed: onToggle (new function ref) ▸ Hooks did NOT change ▸ Context did NOT change 提示:在 flamegraph 任意 fiber 上悬停即可看到这个面板
FIG 23·1 React DevTools Profiler 的两个核心视图:上方 commits 条选择哪次 commit(黄框是最慢的),下方 flamegraph 展示这次 commit 里谁 render、各自耗时。点任一 fiber 出 "Why did this render?" 面板——这是 90% 不必要 render 的入口。 Fig 23·1 · React DevTools Profiler's two core views: top strip selects which commit (yellow border = slowest); bottom flamegraph shows what rendered and how long. Hover any fiber to get the "Why did this render?" panel — the entry point for 90% of unnecessary-render bugs.

"Why did this render?" 的四种答案

The four answers to "why did this render?"

React 给你的原因列表只有四种可能。背熟它,看 Profiler 就如读字典:

React's reason list has exactly four entries. Memorize them; Profiler becomes a dictionary lookup:

原因含义修法
Props changed: foo父传的 prop 引用变了(按 Object.is 比对)Parent passed a new prop ref (Object.is mismatch)React.memo + 稳定父引用(useMemo / useCallback)React.memo + stable parent refs (useMemo / useCallback)
Hooks changed某个 hook(useState/useReducer)的 state 变了Some hook (useState/useReducer) state changed这是真的需要 render——只能优化 render 本身This is a real render — only render itself can be optimized
Context changed: X该 fiber 订阅的 context value 变了A context this fiber subscribes to changed拆 context(按变化频率分组),或用 selector 库(zustand)Split contexts by update frequency, or use a selector lib (zustand)
Parent rendered仅仅因为父 render 了,没有别的原因(没 memo)Parent rendered, no other reason (not memoized)React.memo——这是 80% 的优化机会Wrap in React.memo — 80% of optimization wins are here

Owner Stacks · React 19 引入的"谁渲染了我"

Owner Stacks · React 19's "who rendered me"

2026 年的 React DevTools 一打开就显示组件嵌套链而不是 JS 调用栈。错误信息里也是:从此你能看到"App → DashboardPage → UserCard → Avatar",而不是无穷的 renderWithHooks。这是 React 16 以来调试体验最大的一次提升。

React DevTools in 2026 shows the component nesting chain instead of the JS call stack. Same in error messages. You see "App → DashboardPage → UserCard → Avatar" instead of an endless ladder of renderWithHooks frames. The biggest debug-experience win since React 16.

// React 18 error:
TypeError: Cannot read property 'name' of undefined
  at renderWithHooks (react-dom.js:14782:18)
  at mountIndeterminateComponent (react-dom.js:17811:13)
  at beginWork (react-dom.js:19049:16)
  ... 50 more identical frames ...

// React 19 error (with Owner Stacks):
TypeError: Cannot read property 'name' of undefined
  at Avatar (Avatar.tsx:7)
  at UserCard (UserCard.tsx:14)        // ← rendered me
  at DashboardPage (DashboardPage.tsx:23) // ← rendered them
  at App (App.tsx:4)
实战调试 · 三步定位"慢 commit" FIELD WORKFLOW · 3 steps to locate a slow commit ① 录交互 → 在 commits 条找最高的(最长那一条)。② 切到 Flamegraph,找最宽的非灰色块——它就是这次 commit 里耗时最多的 fiber。③ 悬停它读 "Why did this render?"。看到 "Parent rendered" → 加 React.memo。看到 "Props changed: onClick" → 上面那个父用 useCallback 锁住函数引用。看到 "Hooks changed" → 拍 setState 流,看是不是冗余更新。 ① Record an interaction → find the tallest bar in the commits strip. ② Switch to Flamegraph, find the widest non-gray bar — that's the slowest fiber in this commit. ③ Hover it for "Why did this render?". "Parent rendered" → add React.memo. "Props changed: onClick" → the parent should useCallback the function. "Hooks changed" → trace the setState chain for redundant updates.
CHAPTER 24

实战 · Chrome Performance & 火焰图

Field practice · Chrome Performance & flame charts

user timings · long tasks · layout thrashing · 浏览器视角

user timings · long tasks · layout thrashing · browser view

工具
Tool
Chrome DevTools · Performance
视角
Lens
浏览器主线程
React 18+ 注入
React 18+ injects
User Timings
关键警告
Key warning
Long Task ≥ 50 ms
I · Mount II · Click III · Click² IV · Unmount

React DevTools 告诉你 React 自己慢不慢,但它的视角止于 commit——browser 内部的 layout、style 重算、paint、GC、image decode 它都看不见。Chrome Performance 是另一只眼睛:它看主线程一切,包括 React 跑、浏览器跑、第三方脚本跑。两个工具像 X 光和 MRI——拍同一个病人但成像原理不同。

React DevTools tells you whether React itself is slow, but its lens stops at commit — browser internals (layout, style recalc, paint, GC, image decode) are invisible. Chrome Performance is the other eye: it sees everything on the main thread — React, browser, third-party scripts. The two tools are like X-ray and MRI — same patient, different imaging.

React 18+ 自动注入的 User Timings

User Timings React 18+ auto-injects

React 18 之后不再需要装"Why Did You Render"或 React Profiler 数据导出。React 自己在每次 render 和 commit 时调 performance.measure(...)——这些 mark 在 Chrome Performance 的 Timings 轨道里直接可见。在 Components track 里还能看到每个组件的 render 区段。这两个 track 是 React 19 + Chrome 130 之后不需要任何额外配置就能用的现场工具。

From React 18 you don't need "Why Did You Render" or exported Profiler data. React itself calls performance.measure(...) around every render and commit — the marks appear in Chrome Performance's Timings track. The Components track shows per-component render spans. As of React 19 + Chrome 130, zero config required.

CHROME PERFORMANCE · 主线程火焰图 (示意) Performance 2.4 s recorded 0 200ms 400ms 600ms 800ms Timings render commit render (BIG) commit Components <Counter/> <TodoList/> TodoItem × 50 Main Task · click handler Task ⚠ Long Task · 172 ms ★ performSyncWorkOnRoot renderRootSync (workLoop) commitRoot Layout Paint 怎么读这张图 Timings 轨道看 React 自己花了多久 render / commit(React 18+ 自动注入) Components 轨道看具体哪个组件慢(React 19 / Chrome 130+ 增强) Main 轨道看整个主线程,包括 React + 浏览器 layout/paint ④ Long Task (≥50ms) 用 红色 ⚠ 标——这就是用户感觉""的瞬间 ⑤ commit 后紧跟紫色 Layout/Paint——如果它比 React 还长,问题在 CSS,不在 React 三种致病模式 render 长 + Layout 短:React 自己慢 → 回去 Profiler 找原因 render 短 + Layout 长:CSS / DOM 数量问题 → 浏览器算几何慢
FIG 24·1 Chrome Performance 的三条轨道:Timings(React 自己注入的标尺)、Components(React 19+ 组件粒度)、Main(整个主线程火焰图)。红色 ⚠ 是 Long Task 警告——只要看到它,用户就已经感觉到卡顿了 Fig 24·1 · Three tracks in Chrome Performance: Timings (React's auto-injected markers), Components (React 19+ per-component spans), Main (full main-thread flame chart). The red ⚠ flags Long Tasks — once you see one, the user has already felt the jank.

区分"React 慢"vs"浏览器慢"

Telling "React slow" from "browser slow"

症状诊断修哪里
Timings 里 render 段宽 + Main 里同段窄Wide render in Timings, narrow Main spanReact 算太久React computing too long回 Profiler 找不必要 renderProfiler hunt for unnecessary renders
Main 里 commit 后跟一大段 Layout(紫)Big Layout block (purple) right after commit in Main浏览器算几何太久Browser layout slow减少 DOM 总数 · 用 transform 替代 top/leftTrim DOM count · use transform instead of top/left
Main 里出现 Recalculate Style 多次Multiple Recalculate Style in Mainlayout thrashing(写读循环)layout thrashing (write-read cycles)批量读 DOM 几何 · 一次性写Batch DOM reads · then write once
commit 后 Paint 很大 + 频繁Large + frequent Paint after commitoverdraw / 大图重绘overdraw / large image repaintwill-change 提升合成层Use will-change to promote to compositor layer
Long Task ≥ 50ms 且 Timings 内 React 占比 < 30%Long Task ≥ 50ms but React shows < 30% of it第三方脚本 / 大 JSON parse3rd-party script / big JSON parse用 Coverage 找占用 · 移到 workerUse Coverage to find it · move to worker
CASE · 真实生产案例
"列表点击卡 300 ms" 的两小时
Two hours debugging "list click stalls 300 ms"

一个 200 项的 TodoList,点 todo 时整页卡 300 ms。① React DevTools Profiler:看到 commit 8 ms——React 自己很快。② Chrome Performance:commit 后 Layout 占 220 ms(紫色巨条)。诊断:CSS 用了 position: sticky + height: auto,触发整个父容器的 layout 重算。修法:固定 row 高度 → Layout 降到 12 ms,总时长 25 ms。"React 慢"是错觉,慢的是 CSS"——只有 Chrome Performance 能告诉你这件事。

200-item TodoList, clicking a todo stalls 300 ms. ① React DevTools Profiler: commit is 8 ms — React's fine. ② Chrome Performance: a 220 ms Layout block (huge purple bar) right after commit. Diagnosis: CSS uses position: sticky + height: auto, triggering full-container layout recalc. Fix: fixed row height → Layout drops to 12 ms, total 25 ms. "React is slow" was an illusion — CSS was slow. Only Chrome Performance can tell you this.

三大隐藏指标

Three hidden metrics

INP (Interaction to Next Paint)
INP (Interaction to Next Paint)
2024 年取代 FID 的 Core Web Vital——衡量从交互到下一次 paint 的延迟。200 ms 以上 = 体验差。Chrome Performance 录一段交互后在右上角 INP overlay 直接显示。这是用户视角的 React 性能终极指标。
The Core Web Vital that replaced FID in 2024 — measures interaction-to-next-paint latency. >200 ms = poor. Chrome Performance overlays INP after recording. This is the user-facing ultimate React perf metric.
Forced reflow 警告
Forced reflow warning
Main 轨道上某个 task 旁出现 ⚠ Forced reflow——你在 JS 里写了 DOM 之后又立刻读 DOM 几何(offsetWidth / getBoundingClientRect)。浏览器被迫同步算 layout。React 里这经常是 useLayoutEffect 写完 DOM 又读它——本就是合法用法,但太频繁就会显形。
A ⚠ Forced reflow badge next to a task in Main — your JS wrote to the DOM and then immediately read DOM geometry (offsetWidth / getBoundingClientRect). The browser was forced to layout synchronously. In React this usually comes from useLayoutEffect writing to and then reading from the DOM — legal but visible at scale.
commit 微任务长度 > 5 ms
commit microtask > 5 ms
Concurrent 模式下,render 可以切片,但 commit 不行——它是连续微任务。如果 commit 超过 5 ms,就阻塞了一个 paint 周期。Chrome Performance 看 commit 这段紫色块的宽度,对应 ms 数 = 你欠用户的那个帧。
In concurrent mode, render can slice, but commit can't — it's a continuous microtask. A commit over 5 ms costs you a paint cycle. Read the commit's purple block width in Chrome Performance — that ms = the frame you owe the user.
CHAPTER 25

13 年源码考古 — 关键 commit / RFC / 人物

13 years of source archaeology — key commits, RFCs, people

从 Jordan Walke 一个人开始到 RSC 标准化

from Jordan Walke alone to the RSC standard

前面 24 章拆的都是结构——React 现在长什么样。这一章拆演化——React 怎么走到这一步。每一个看起来"理所当然"的设计(Fiber 链表、Hooks 链表、Lanes 位掩码、Server Components 流式协议)背后都是一个具体的人、一个具体的 commit、一个具体的 RFC。把这条线串起来,你会发现 React 的内核演化有三条规律:

The previous 24 chapters dissect structure — what React looks like today. This chapter dissects evolution — how it got here. Every "obvious" design (the Fiber linked list, the Hooks list, Lanes bitmask, RSC wire format) has a specific person, commit, and RFC behind it. Trace the line and three patterns emerge:

"先实验, 后 RFC"
"Experiment first, RFC second"
几乎每个核心特性都先有一个 unstable_ 实验分支跑半年到一年, 然后才进 RFC 流程。Fiber 实验 2 年才并入 16.0; Hooks 公开演示后又过了 4 个月才发 16.8; Concurrent Mode 从 2018 unstable 到 2022 稳定走了 4 年。
Almost every core feature began as an unstable_ branch running 6–12 months before entering RFC. Fiber: 2 years before 16.0 merge. Hooks: 4 months between public demo and 16.8. Concurrent Mode: 4 years from 2018 unstable to 2022 stable.
"引擎换三次, 方向盘不动"
"Engine swapped 3×, steering wheel still"
React 0.3 (Stack) → 16 (Fiber) → 18 (Concurrent) → 19 (Server Components). 这四代引擎本质上是完全不同的架构, 但 useState / setState / <Component /> 这些公开 API 从 2013 到 2026 几乎没变。
React 0.3 (Stack) → 16 (Fiber) → 18 (Concurrent) → 19 (Server Components). Four architecturally distinct engines — yet useState / setState / <Component /> have hardly changed from 2013 to 2026.
"错的方向也要试过"
"Even wrong directions get tried"
unstable_AsyncMode (后改名 Concurrent)、Suspense for data fetching 原版提案 (被 use() 取代)、原生 React Forget / React Compiler 4 次重启、Server Components 的 .server.js / .client.js 扩展名 (后改成 'use server' / 'use client')——React 公开走过的死胡同比成功路径多得多。
unstable_AsyncMode (renamed Concurrent), original Suspense-for-data RFC (replaced by use()), React Forget / Compiler had four false starts, Server Components' .server.js / .client.js extensions (replaced by 'use server' / 'use client') — React's public dead-ends outnumber its successes.

完整时间线 · 关键节点

Full timeline · key milestones

REACT TIMELINE · 2011–2026 · 13 个关键节点 2011 2013 2014 2015 2017 2018 2019 2020 2022 2023 2024 2025 2026 FaxJS · Jordan Walke 在 FB 内部 hack 出 FaxJS React 0.3.0 开源 · 2013-05-29 · JSConf US 演示, 一半人在摇头 React Native 发布 · 2015-03 · "Learn once, write anywhere" ★ Fiber 实验分支 · 2015-09 · Andrew Clark 独立开发, commit acdlite/react-fiber-architecture ★ React 16.0 (Fiber) · 2017-09-26 · 2 年重写, public API 几乎不变 Hooks RFC #68 · 2018-10-26 · Sebastian Markbåge 起草 · ReactConf 演示后病毒式传播 ★ React 16.8 Hooks 稳定 · 2019-02-06 · RFC 4 个月后发, 创下最快稳定记录 Concurrent Mode RFC #150 · 2019-12-12 · 第一次提出 lane 模型 Server Components RFC #188 · 2020-12-21 · Dan Abramov + Lauren Tan 提出 ★ React 18.0 Concurrent · 2022-03-29 · createRoot · Suspense · transitions Next.js 13 ship app/ (RSC alpha) · 2022-10-25 · Vercel 押注 Server Actions alpha · 2023-05 · 'use server' 字符串指令 React Compiler announce · 2024-02-15 · "Forget" 第 4 次重启, 这次落地 React 19 beta · 2024-04-25 · use / Actions / 表单原生支持 ★ React 19 稳定 · 2024-12-05 · Owner Stacks / Compiler / Activity React Compiler 1.0 · 2025-Q3 · 默认开启候选 Stack 时代 Fiber 时代 Concurrent 时代 RSC 时代
FIG 25·1 React 13 年关键节点。颜色分四个时代:蓝色 Stack 时代 (2011-2017) · 铜色 Fiber 时代 (2017-2019) · 紫色 Concurrent 时代 (2019-2022) · 绿色 RSC 时代 (2022-)。每个 ★ 是重写级 release, 圆点是 RFC / unstable 起点。 Fig 25·1 · React's 13-year key nodes. Colors are four eras: blue Stack (2011-2017), copper Fiber (2017-2019), violet Concurrent (2019-2022), green RSC (2022-). Each ★ is a rewrite-level release; dots are RFCs / unstable starts.

3 个被丢弃的方向 · 失败也是历史

Three abandoned directions · failures are history too

Suspense for data fetching 原版 (2018) · 被 use() 取代
Original Suspense for data fetching (2018) · superseded by use()
原版需要数据请求库实现一个特殊的 read() 接口, 跑在 cache layer 之上。社区适配缓慢——大部分库不愿改 API。React 18 提出 use() hook 后, 任何普通 Promise 都能挂到 Suspense, 旧 RFC 静默废弃。教训:协议越简单, 采用率越高。
Original required data libraries to implement a special read() on top of a cache layer. Community adoption stalled — most libs didn't want to change APIs. React 18's use() hook accepts any Promise, silently retiring the old RFC. Lesson: simpler protocols win adoption.
.server.js / .client.js 扩展名 (2020) · 被指令字符串取代
.server.js / .client.js extensions (2020) · replaced by directive strings
RSC 原始提案让你用文件名区分组件类型。社区强烈反对——一个文件可能既包含 server function 又包含 client component, 用扩展名一刀切太粗暴。换成 'use server' / 'use client' 指令字符串后,细粒度到函数级。
RSC's original proposal distinguished by filename. The community pushed back — a single file could contain both server functions and client components, file extension was too coarse. Replaced by 'use server' / 'use client' string directives, giving function-level granularity.
React Forget · 4 次重启 → React Compiler (2024)
React Forget · 4 false starts → React Compiler (2024)
"编译期自动 memo" 这个想法 2020 年首次提出, 团队叫它 "React Forget"。但前三版要么太激进 (改坏正确语义) 要么太保守 (优化收益 ≤ 5%)。第四版(2024)改名 React Compiler, 用更严格的纯度推断+ 显式 dirty 标记, 终于在 Meta 内部 200+ 应用上拿到 35-45% render 节省。
"Auto-memo at compile time" was floated in 2020 as "React Forget". The first three iterations were either too aggressive (broke correctness) or too conservative (≤5% wins). v4 (2024) was renamed React Compiler, with stricter purity inference + explicit dirty markers, finally hitting 35-45% render savings across Meta's 200+ apps.

关键人物

Key people

人物关键贡献2026 在哪
Jordan WalkeReact 创始人 · FaxJS · 2013 演示React creator · FaxJS · 2013 demoMeta
Sebastian MarkbågeHooks RFC #68 主作者 · Suspense / Server Components 总设计师Hooks RFC #68 author · Suspense / Server Components chief architectVercel (2022 起)
Andrew ClarkFiber 独立开发 · Lanes 模型 · workLoop 重写Fiber solo dev · Lanes model · workLoop rewriteVercel
Dan AbramovRedux 创始人 · React 团队 (2015-2024) · Server Components 推手Redux creator · React team (2015-2024) · Server Components championBluesky (2024 起)
Lauren TanRSC 共同作者 · React Native 团队RSC co-author · React Native teamMeta
Joe SavonaReact Compiler 主导 · Relay 创始人之一React Compiler lead · Relay co-founderMeta
Sophie AlpertReact engineering manager (2017-2018) · Pyret 语言设计React engineering manager (2017-2018) · Pyret languageNotion
Brian VaughnReact DevTools 主作者 · Profiler / Owner StacksReact DevTools lead · Profiler / Owner Stacks独立顾问independent
Rick HanlonReact 19 release manager · CRA 终结者React 19 release manager · the one who killed CRAMeta
FIELD NOTE · 一个有趣的人事变迁 FIELD NOTE · A telling personnel shift 2022 之后, React 核心团队的重心从 Meta 偏向了 Vercel。Sebastian Markbåge 离开 Meta 加入 Vercel; Andrew Clark 紧随其后; Dan Abramov 虽然 2024 才离开但更早就深度合作 Next.js。这导致 React 19 的特性(Server Components / Actions / 表单内置)大多围绕 Next.js App Router 的需求。这不是阴谋——只是核心贡献者去哪, 设计就去哪。但这也是为什么 Remix / Astro / TanStack Start 在 React 生态里仍能并立——它们用同一套底层协议但走自己的 framework 路径。 After 2022, React core's center of gravity shifted from Meta toward Vercel. Sebastian Markbåge left Meta for Vercel; Andrew Clark followed; Dan Abramov, while not leaving until 2024, deep-collaborated with Next.js much earlier. As a result, React 19 features (Server Components / Actions / built-in form) mostly orbit Next.js App Router's needs. Not a conspiracy — just where the core contributors go, the design goes. It's also why Remix / Astro / TanStack Start can coexist in the React ecosystem — same protocol, different framework paths.
CHAPTER 26

多端 · HostConfig 各家实现

Multi-renderer · HostConfig in the wild

DOM · Native · Three.js · CLI · PDF · Skia

DOM · Native · Three.js · CLI · PDF · Skia

Ch04 三层架构里我们说过:同一个 react-reconciler 算法可以驱动任何东西,只要你实现 HostConfig 这张表。本章把这个论断展开——看 8 个真实 renderer 怎么实现 HostConfig 的差异,并理解每种平台对应到 React 概念时的折扣或加成。

Ch04 claimed: the same react-reconciler algorithm can drive anything, as long as you implement the HostConfig table. This chapter unfolds that claim — showing how 8 real renderers fill in HostConfig and what discounts or bonuses each platform brings to React's concepts.

八家 renderer 对照

Eight renderers, side by side

renderer"DOM" 是什么commit 模式状态
react-dom浏览器 HTMLElement 树browser HTMLElement treesupportsMutation官方
react-native (Fabric)原生 iOS/Android view tree (C++ ShadowTree)native iOS/Android view tree (C++ ShadowTree)supportsPersistence官方
react-dom/server字符串 / 流string / stream官方
react-three-fiberThree.js Scene graph (WebGL)Three.js Scene graph (WebGL)supportsMutation社区 · 主流community · widely adopted
ink终端 ANSI 字符缓冲terminal ANSI buffersupportsMutationvercel/ink · GitHub CLI 在用vercel/ink · powers GitHub CLI
react-pdfPDF 操作符流PDF operator streamsupportsPersistence独立项目standalone project
react-native-skiaSkia canvassupportsMutationShopify 主推
react-blessed基于 blessed 的 TUIblessed-based TUIsupportsMutation较小生态smaller ecosystem

同一棵 React 树, 三种落地

Same React tree, three landings

SAME REACT TREE → DIFFERENT HOSTS · 一次写, 三处跑 react-reconciler Fiber 算法 · Lanes · Scheduler 不变 · 共用 ↑↓ HostConfig 接口边界 react-dom createElement → appendChild setAttribute · textContent Browser HTML DOM visible in DevTools react-native (Fabric) createNode → cloneNode C++ JSI · ShadowTree iOS UIView / Android View native pixel buffer react-three-fiber new THREE.Mesh() scene.add(obj) WebGL Scene Graph render every frame Hooks / Suspense / Context / Memo · 三处完全相同 差异只在最底层 30 个 HostConfig 方法 · 一次写, 三处跑
FIG 26·1 同一棵 react-reconciler 树通过不同 HostConfig 落到三个平台。Hooks / Suspense / Context / Memo 在三处语义完全一致——差异只在最底层 30 个 host 方法里。这就是 React 的"learn once, write anywhere"承诺的物理实现。 Fig 26·1 · One react-reconciler tree lands on three platforms via different HostConfig. Hooks / Suspense / Context / Memo are semantically identical across all three — the difference is in 30 host methods at the bottom. That's React's "learn once, write anywhere" promise made physical.

Fabric · React Native 的"新桥"

Fabric · React Native's "new bridge"

React Native 2022 完成了从老 Bridge 到 Fabric 架构的迁移。这是 React 多端生态里最大的一次重构。核心改变:

React Native completed the migration from the old Bridge to Fabric in 2022 — the biggest restructure in React's multi-platform ecosystem. Core changes:

OLD Bridge (2014–2022)
异步 · supportsMutation
Async · supportsMutation

JS 线程通过序列化 JSON 把指令发到原生线程, 异步执行。代价:① 跨线程数据 copy 开销 ② JS 和原生不同步—— UI 永远比 React 状态晚一帧。

JS thread sends instructions to native thread via serialized JSON, async execution. Cost: ① cross-thread data copy overhead ② JS and native are out of sync — UI is always one frame behind React state.

NEW Fabric (2022+)
同步 · supportsPersistence (C++ JSI)
Sync · supportsPersistence (C++ JSI)

JS 通过 JSI (JavaScript Interface) 直接调 C++, 不再序列化。每次 commit React 产出一棵不可变 ShadowTree, C++ 层 diff 后同步应用到 native。代价:复杂度极高, 调试器适配难——但延迟降到几乎为 0

JS calls C++ directly via JSI (JavaScript Interface), no serialization. Each commit React produces an immutable ShadowTree; the C++ layer diffs and applies sync to native. Cost: high complexity, hard debugger support — but latency drops to essentially zero.

// react-native/Libraries/Renderer/HostConfig.js · 简化
const FabricHostConfig = {
  supportsMutation: false,
  supportsPersistence: true,   // ← 不可变 ShadowTree

  createInstance(type, props, root, ctx) {
    return FabricUIManager.createNode(
      allocateTag(), type, root.surfaceId, diffInProps(props), eventEmitter
    );
  },

  // Persistent: 任何更新都clone 而不是 mutate
  cloneInstance(instance, payload, type, oldProps, newProps, keepChildren) {
    return FabricUIManager.cloneNode(instance, payload);
  },
  cloneInstanceForRecyclableInstance: ...,
};

react-three-fiber · WebGL 当成 DOM 用

react-three-fiber · WebGL treated as DOM

r3f 把 Three.js 的对象(Mesh / Geometry / Material)当成 host element。<mesh> JSX 创建 new THREE.Mesh()<boxGeometry args={[1,1,1]} /> 创建几何体, 等等。React 的 props diff 直接映射到 Three 对象的属性 update:

r3f treats Three.js objects (Mesh / Geometry / Material) as host elements. <mesh> JSX creates new THREE.Mesh(); <boxGeometry args={[1,1,1]} /> creates the geometry. React's props diff maps directly to Three's property updates:

// react-three-fiber 的 HostConfig 关键部分
createInstance(type, props) {
  const ThreeClass = THREE[capitalize(type)];  // "mesh" → THREE.Mesh
  const instance = new ThreeClass(...(props.args || []));
  applyProps(instance, props);
  return instance;
},

appendChild(parent, child) {
  if (child.isObject3D) parent.add(child);          // 加入场景图
  else if (child.attach) parent[child.attach] = child; // 比如 geometry
},

// 用法:
<Canvas>
  <mesh position={[0, 0, 0]}>
    <boxGeometry args={[1, 1, 1]} />
    <meshStandardMaterial color="hotpink" />
  </mesh>
</Canvas>

ink · 用 React 写 CLI

ink · writing CLIs with React

ink 把终端字符缓冲当成 DOM。<Text> / <Box> / <Spacer> 这些原生元素被 ink 翻译为终端 ANSI 字符。GitHub CLI、Gatsby CLI、Prisma 都用 ink 做交互式界面。本文的 Counter 在 ink 里长这样:

ink treats the terminal character buffer as DOM. <Text> / <Box> / <Spacer> are translated to ANSI sequences. GitHub CLI, Gatsby CLI, Prisma all use ink for interactive UIs. The Counter from this article looks like this in ink:

import { render, Text, Box, useInput } from 'ink';

function Counter() {
  const [count, setCount] = useState(0);
  useInput((input) => {
    if (input === '+') setCount(c => c + 1);
  });

  return <Box borderStyle="single" padding={1}>
    <Text>Count: <Text bold color="green">{count}</Text> (press +)</Text>
  </Box>;
}

render(<Counter />);
// 输出到终端:
// ┌────────────────────────────────┐
// │ Count: 0 (press +)             │
// └────────────────────────────────┘
写一个自己的 renderer 难吗 How hard is writing your own renderer? 不难。react-reconciler 是公开 npm 包。一个 minimal HostConfig 大约 ~30 个方法, 几百行代码——其中只有 createInstance / appendChild / commitUpdate / removeChild 这四个必须真正实现, 其余可以留空或返回 noop。react-renderer-tutorial 用 ~500 行实现了一个把 React 渲染到 PDF 的小 renderer。如果你想体验 React 内部, 写一个 renderer 比读源码学得快。 Not hard. react-reconciler is a public npm package. A minimal HostConfig is ~30 methods, a few hundred lines — only createInstance / appendChild / commitUpdate / removeChild really need real implementations; the rest can be noop. react-renderer-tutorial implements a PDF renderer in ~500 lines. To grok React's internals, writing a renderer teaches faster than reading source.
CHAPTER 26·B

React vs 其他框架 · 设计权衡的横切面

React vs the field · a cross-section of trade-offs

Preact · Vue 3 · Solid · Svelte · Qwik · Astro · React 选了什么, 放弃了什么

Preact · Vue 3 · Solid · Svelte · Qwik · Astro · what React chose, what it gave up

前面 26 章都在拆 React 的"怎么做"。这一章把镜头拉远——同样的问题, 其他框架是怎么不同地回答的? 把 React 放回它所在的设计空间里, 你才能看清它主动选择了什么、放弃了什么。这不是给 React 做广告——它的每个选择都有可被合理质疑的反方向, 这章把那些反方向列出来

26 chapters cracked React's "how". This chapter zooms out — how do other frameworks answer the same questions, differently? Place React back in its design space and you see what it actively chose and actively gave up. Not a React commercial — every choice has a reasonably-challengeable counterpoint; this chapter lists those counterpoints.

6 个框架的设计取舍对照

6 frameworks · design trade-off matrix

frameworkbundle协调响应性SSR心智
React 19~80 KBFiber · lastPlacedIndex显式 setState · 整组件重跑Streaming + Selective Hydration + RSC组件 = 纯函数 (但会跑多次)
Preact 10~30 KBStack · 同 React 15同 React 但无 concurrent同 SSR · 无 Streaming同 React
Vue 3~55 KBvDOM diff · LIS 最优Proxy-based · 自动追踪独立 SSR · 含 Streaming组件 = setup() 跑一次 + reactive 表达式自动重跑
Solid 1.x~7 KB无 vDOMfine-grained signals · 自动追踪独立 SSR · 含 Streaming · 含 Islands组件 = 一次性创建反应订阅
Svelte 5~5 KB/component (compile)编译时 · 无运行 diff$state rune · 编译期追踪SvelteKit · 含 Streaming组件 = 编译为命令式 DOM 操作
Qwik 1.x~1 KB initial · resume on demand类 React vDOM · 但0 hydrationsignals 风格Resumability · 整页 0 JS, 用 click 才 fetch handler组件 = 序列化到 HTML, 再"恢复"
Astro 4默认 0 KB / island 按需Islands · 整页静态 + 局部 React/Vue/Solid页面 = HTML + 多个独立小岛

两个轴上看清楚

Seeing it on two axes

FRAMEWORK DESIGN SPACE · 两个轴 + 6 个点 RUNTIME COST → 0 KB 小 (~10 KB) 中 (~50 KB) 大 (~80+ KB) ← REACTIVITY GRANULARITY (FINE) · (COARSE) → fine coarse "编译时" 象限 · 小 + 细 "细粒度运行时" 象限 · 大 + 细 "轻量级粗粒度" 象限 · 小 + 粗 "虚拟 DOM" 象限 · 大 + 粗 R React 19 P Preact V Vue 3 S Solid Sv Svelte 5 Q Qwik Astro 每个岛各自落点 观察 React 是唯一明确选了"大 + 粗"角的主流框架 代价: bundle 大; 收益: 心智单一, 生态最大
FIG 26B·1 前端框架的设计空间。X 轴是运行时成本 (bundle 大小近似), Y 轴是响应性粒度 (细=每个表达式精确更新, 粗=整组件重跑)。React 19 落在大+粗 那一角——是 6 个框架里唯一这种位置。这是明确的设计选择: 用运行时复杂度换"组件是纯函数" 这种最简心智。 Fig 26B·1 · The framework design space. X axis: runtime cost (bundle size proxy); Y axis: reactivity granularity (fine = each expression updates precisely, coarse = whole component reruns). React 19 sits in the big + coarse corner — the only one of the 6 to make this choice. A deliberate trade: pay runtime complexity to keep "component is a pure function" mental model.

React 主动选了什么、放弃了什么

What React actively chose, what it gave up

选: 函数体每次重跑 · 放弃: 细粒度反应
Chose: rerun whole function body · Gave up: fine-grained reactivity
React 的核心心智是"组件是 props/state 的纯函数"——一旦 state 变, 整个函数体重跑。Solid / Vue / Svelte 都是只跑"变了的表达式"——更高效但要建追踪图。React 的取舍: 用笨办法 + Fiber + 双缓冲 + Compiler 优化, 换最简心智。代价: bundle 大, render 多。
React's core mental model: "a component is a pure function of props/state" — state change reruns the whole function. Solid / Vue / Svelte only rerun "changed expressions" — more efficient but requires tracking graphs. React's trade: a brute force approach + Fiber + double-buffer + Compiler optimization to keep simplest mental model. Cost: bigger bundle, more renders.
选: 虚拟 DOM · 放弃: 编译时优化空间
Chose: virtual DOM · Gave up: compile-time optimization
Svelte 把 {count} 直接编译成 text.data = count——0 运行时差异。React 要在运行时跑 diff 才能知道"这个 textNode 的值是 count"。Svelte 的代价: 每个组件都是独立的编译产物, 体积按组件数累加; React 的代价: 运行时永远有 ~80 KB 基础开销, 但更多组件几乎不增加 bundle。大型应用是 React 占优。
Svelte compiles {count} directly to text.data = count — zero runtime overhead. React must run a diff at runtime to know "this textNode's value is count". Svelte's cost: each component is an independent compile output, size scales with component count; React's cost: ~80 KB baseline runtime forever, but more components barely grow the bundle. React wins on large apps.
选: 单一可中断 render · 放弃: 直接 DOM 更新
Chose: single interruptible render · Gave up: direct DOM updates
Vue 3 / Solid 的 reactive 表达式可以直接 mutate DOM, 不需要走 reconcile。但这意味着 reactive 更新不可中断——大表单一旦响应慢就整个卡。React 的 Fiber + Concurrent 让 render 可切片可丢弃, 在 1000+ 组件的大场景下用户输入永远响应。代价: scheduler 加复杂度, 引入 tearing 风险 (Ch17 详)。
Vue 3 / Solid's reactive expressions directly mutate DOM without reconcile. But reactive updates are uninterruptible — a slow form blocks everything. React's Fiber + Concurrent makes render sliceable and discardable, keeping user input responsive at 1000+ components. Cost: scheduler complexity, tearing risk (see Ch17).
选: hydration 模型 · 放弃: resumability
Chose: hydration · Gave up: resumability
React 18+ 的 Selective Hydration 已经很接近"按需 hydrate"——但仍需要客户端重新执行组件函数才能拿到事件 handler。Qwik 走得更极端: 把整个组件 state 序列化到 HTML 里, handler 也是按 click 才下载。代价: Qwik 心智复杂, 你必须知道哪些值是可序列化的, 哪些不行。React 的取舍: 接受 hydration 成本, 换更简单的开发模型。
React 18+'s Selective Hydration is close to "hydrate on demand" — but still needs the client to re-execute component functions to get event handlers. Qwik goes further: serializes whole component state into HTML; handlers are downloaded on click. Cost: Qwik's mental model is complex, you must know what's serializable and what isn't. React's trade: accept hydration cost, keep a simpler dev model.
选: JSX (编译期透明) · 放弃: 模板 DSL
Chose: JSX (transparent at compile time) · Gave up: template DSL
Vue / Svelte 用模板 DSL (单文件 .vue / .svelte), 可以做更激进的编译期优化——静态的 vDOM 子树自动 hoist, 动态的部分编译为 patch flag。React 的 JSX 仅是函数调用糖, 编译时拿不到"这段是不变的" 这种信息——只能靠 React Compiler 用更聪明的静态分析弥补。代价: JSX 自由度高, 但优化空间窄。
Vue / Svelte use template DSLs (single-file .vue / .svelte), enabling more aggressive compile-time optimization — static vDOM subtrees auto-hoist, dynamic parts compile into patch flags. React's JSX is just function-call sugar, the compiler can't know "this is constant" — it has to fight back with React Compiler's smarter static analysis. Cost: JSX is more flexible, but the optimization window is narrower.

React 在 2026 的市场位置

React's market position in 2026

2026 年的 npm 周下载量: React ~30M / Vue ~6M / Svelte ~2M / Solid ~500K / Qwik ~200K / Preact ~3M / Astro ~1.5M。React 仍然单独占据下载量的 70%+, 但增长率被 Solid / Svelte / Qwik 这些细粒度反应派分摊。这不是 React 出问题, 是设计空间扩大了——前端能做的事越来越多, 一种框架满足所有需求的时代过去了。

2026 npm weekly downloads: React ~30M / Vue ~6M / Svelte ~2M / Solid ~500K / Qwik ~200K / Preact ~3M / Astro ~1.5M. React still alone holds 70%+ of downloads, but growth is being shared with Solid / Svelte / Qwik — the fine-grained reactivity camp. Not React losing — the design space expanded. Frontend's job is more diverse than ever; the era of "one framework solves all" is over.

什么时候选 React When not to pick React 三个明确的反向场景: ① 嵌入式 widget / banner (要求 30KB 以下) → Preact 或 Svelte。② 静态内容为主的网站 (blog / docs) → Astro Islands。③ 极致 INP 要求 (大型电商 / 营销页面) → Qwik 的 resumability。这三个场景里 React 的 ~80KB 运行时是纯负担——选别的框架不是反 React, 是用对工具。剩下大多数场景 (admin / dashboard / SaaS app / 中后台 / 应用类 SPA) 仍然是 React 占优。 Three clear counter-scenarios: ① embedded widgets / banners (need ≤ 30KB) → Preact or Svelte. ② static-content-heavy sites (blog / docs) → Astro Islands. ③ extreme INP demands (large e-commerce / marketing pages) → Qwik's resumability. In these three, React's ~80KB runtime is pure overhead — picking another framework isn't anti-React, it's right tool. Most other scenarios (admin / dashboard / SaaS / mid-back-office / app-like SPA) still favor React.

看完 React 之后再去看 Solid · 5 分钟入门

After React, look at Solid · 5-minute primer

看完本文你已经懂了 React 一切。最值得对比看的是 Solid——它跟 React 表面 99% 像但内核 100% 不同, 是检验你对 React 理解深度的最好镜子:

Having finished this article you understand React fully. The most enlightening comparison is Solid — 99% similar on the surface, 100% different in the kernel. The best mirror for testing your React understanding:

// React 19
function Counter() {
  const [count, setCount] = useState(0);
  console.log('render');   // ← 每次 setCount 都打印 ★
  return <button onClick={() => setCount(c+1)}>{count}</button>;
}

// Solid 1.x · 看上去一模一样的 JSX
function Counter() {
  const [count, setCount] = createSignal(0);
  console.log('render');   // ← 打印一次, 即使点 100 下 ★
  return <button onClick={() => setCount(c => c+1)}>{count()}</button>;
  // 注意 {count()} —— 它是一个 reactive primitive 的"读取"
  // Solid 编译期看到 {count()} 就建立一条反应订阅:
  // "setCount 跑了 → 这个 textNode 重新 read count() → 改 .data"
  // 函数体只跑一次, 仅有那条订阅被反复触发
}

这个对比比读 100 篇 React 文章都管用——它逼你回答: React 为什么必须 重跑函数体? 答案: 因为 hooks 调用顺序是 fiber 链表的物理约束 (Ch12), React 没法分辨"这个表达式依赖 count, 那个不依赖"——所以只能全跑。Solid 用编译期 + signal API 解决了这个问题, 代价: 你必须用 count() 而不是 count (signal 是 getter)。

This comparison teaches more than 100 React articles — it forces you to answer: why must React rerun the function body? Answer: because hook call order is a physical constraint of the fiber list (Ch12), React can't tell "this expression depends on count, that one doesn't" — so it reruns everything. Solid solves this via compile-time + signal API; cost: you must write count() not count (signals are getters).

Vue 3 · 第三种答案 (Proxy + 同 vDOM)

Vue 3 · the third answer (Proxy + still vDOM)

Solid 砍掉了 vDOM。但 Vue 3 选了另一条路: 保留 vDOM, 用 Proxy 给数据加追踪——只在 setup() 跑一次, 之后由响应系统精确决定哪些 vDOM 节点要重新生成。同一个 Counter:

Solid axed the vDOM. Vue 3 took a different route: keep the vDOM, but wrap data in Proxy for tracking — setup() runs only once, and the reactive system decides precisely which vDOM nodes to regenerate. Same Counter:

// Vue 3 · Composition API
<script setup>
import { ref } from 'vue';
const count = ref(0);                // ref returns a reactive Proxy wrapper
console.log('setup');                // ← only fires once, even after 100 clicks
</script>

<template>
  <button @click="count++">{{ count }}</button>
</template>
// Vue's compiler turns this template into a render function with patch flags;
// at runtime the Proxy on count tracks: "the textNode inside <button> depends on me"
// → count++ surgically marks only that vDOM node dirty → diff/patch only the dirty path

关键差别: Vue 仍走 vDOM diff (像 React), 但 diff 范围从"整棵子树" 缩到"已知 dirty 节点"。三种答案的本质对比:

Key difference: Vue still uses vDOM diff (like React), but the diff scope shrinks from "whole subtree" to "known-dirty nodes". The essence of all three answers:

questionReact 19Vue 3Solid 1.x
组件函数跑几次?how often does the component fn run?每次 state 变都跑every state change只跑 1 次 (setup)once (setup)只跑 1 次once
依赖追踪dep tracking无 · 全量 reconcilenone · full reconcile运行时 Proxy + 编译期 patch flagruntime Proxy + compile-time patch flag编译期分析 + signal gettercompile-time + signal getter
vDOM diffvDOM diff有 (Fiber)yes (Fiber)有 (但范围窄)yes (but scoped)none
用户语法负担user-syntax burden最低 (普通 JS)lowest (plain JS)中 (需 .value / ref / reactive)medium (.value / ref / reactive)中 (需 () 调用 getter)medium (call getter)
心智负担mental load高 (要懂 render 何时跑)high (when does render run?)mediumlow
中断 / Concurrentinterruptibleyes2024 起实验性experimental since 2024未实现not implemented

三种取舍各自合理。看清这三种之后, 再回头看 React 的"函数重跑 + Fiber 切片"——你会发现它不是没想到 reactive 这条路, 而是为了保住"纯函数"心智, 主动选了带运行时复杂度的"笨办法"。

All three trade-offs are sensible. After seeing all three, look back at React's "function reruns + Fiber slicing" — you'll see it isn't blind to the reactive path. It chose the runtime-heavy "brute force" approach precisely to keep the "pure function" mental model intact.

CHAPTER 27

附录 · React 源码导航地图

Appendix · React source navigation map

facebook/react monorepo 各 package 干什么 · 关键文件 · 阅读顺序

what each package does · key files · reading order

facebook/react 仓库 2026 年有 32 个 package。第一次进去会迷路——多得让人不知道从哪读起。这份地图按"从外到内"和"由抽象到具体"两个维度给你阅读顺序。配合本文 25 章读源码, 大约 4 周可读到一个能改 React 的水平。

The facebook/react repo has 32 packages in 2026. First-timers get lost — too many entries, no clear starting point. This map gives reading order along two axes: "outside-in" and "abstract-to-concrete". Pair with the 25 chapters of this article; ~4 weeks to a level where you can actually modify React.

7 个核心 package · 按阅读顺序排

7 core packages · in reading order

#package大致 LoC入口文件读多久
1react/~2Ksrc/ReactElement.js1 天
2react-reconciler/~30K ★src/ReactFiberReconciler.js → ReactFiberWorkLoop.js2 周
3scheduler/~1.5Ksrc/forks/Scheduler.js2 天
4react-dom/~15Ksrc/client/ReactDOMRoot.js3 天
5react-dom/server~8Ksrc/server/ReactDOMFizzServer.js2 天
6react-server/~6Ksrc/ReactFlightServer.js3 天
7react-server-dom-webpack/~4Ksrc/server/ReactFlightDOMServerNode.js2 天

react-reconciler 内部 · 13 个最重要文件

Inside react-reconciler · 13 most important files

file负责本文哪章
ReactFiber.jsFiber 节点定义 + createFiber()Ch07
ReactFiberWorkLoop.jsworkLoop · performUnitOfWork · 主循环Ch09
ReactFiberBeginWork.jsbeginWork · 30+ switch caseCh09
ReactFiberCompleteWork.jscompleteWork · subtreeFlags bubbleCh11
ReactChildFiber.jsreconcileChildren · keyed diff · lastPlacedIndexCh10
ReactFiberHooks.js所有 hooks 的实现Ch12 / Ch12B
ReactFiberLane.jsLanes 位掩码 · entanglementCh16 / Ch17
ReactFiberFlags.js32 位 flag 定义Ch11
ReactFiberCommitWork.jscommit 三阶段总入口Ch13–Ch15
ReactFiberThrow.jsSuspense + Error Boundary 派发Ch18 / Ch15B
ReactFiberConcurrentUpdates.jssetState 入队 · 优先级提升Ch12 / Ch17
ReactFiberClassComponent.jsclass 组件全套生命周期Ch13 / Ch15B
ReactFiberHostConfig.jsHostConfig 接口契约 (注释为主)Ch04 / Ch26

阅读建议 · 一个 4 周的计划

Recommended path · a 4-week plan

W1
第 1 周 · 骨架
Week 1 · skeleton
react/src/ReactElement.js(200 行)+ react-reconciler/src/ReactFiber.js 字段定义(300 行)+ ReactFiberWorkLoop.js 主循环骨架(仅看 workLoopSyncperformUnitOfWork,不展开 beginWork)。目标:能画出一棵 Fiber 树并解释字段。
Read react/src/ReactElement.js (200 lines), react-reconciler/src/ReactFiber.js field definitions (300 lines), and ReactFiberWorkLoop.js's main loop skeleton (only workLoopSync and performUnitOfWork, don't expand beginWork yet). Goal: be able to draw a Fiber tree and explain each field.
W2
第 2 周 · 协调
Week 2 · reconciliation
深入 ReactFiberBeginWork.jsupdateFunctionComponent + updateHostComponent 分支。然后读 ReactChildFiber.js(diff 算法)。最后看 ReactFiberHooks.js 里的 useState / useEffect目标:能跑通"一次 setState → 一次 render"的整条调用链。
Dig into ReactFiberBeginWork.js's updateFunctionComponent + updateHostComponent branches. Then read ReactChildFiber.js (diff). Finally ReactFiberHooks.js's useState / useEffect. Goal: trace the full chain from a setState call to a render.
W3
第 3 周 · 提交
Week 3 · commit
ReactFiberCompleteWork.js(创建 DOM 离屏)+ ReactFiberCommitWork.js(三子阶段)+ react-dom 那一侧的 DOMSetTextContent / setValueForAttributes目标:能解释 root.current swap 的精确时序。
Read ReactFiberCompleteWork.js (offline DOM creation), ReactFiberCommitWork.js (3 sub-phases), and the react-dom side: DOMSetTextContent / setValueForAttributes. Goal: explain the exact timing of the root.current swap.
W4
第 4 周 · 调度
Week 4 · scheduling
ReactFiberLane.js 完整 Lane 模型 + scheduler/src/Scheduler.js 的 MessageChannel + 小顶堆。最后挑战:读 ReactFiberThrow.js 的 Suspense + ErrorBoundary 分发。目标:能修一个 scheduler 相关的 React bug。
Read all of ReactFiberLane.js + scheduler/src/Scheduler.js (MessageChannel + min-heap). Final challenge: ReactFiberThrow.js — Suspense + ErrorBoundary dispatch. Goal: be able to fix a scheduler-related React bug.

实用工具

Useful tools

tool用途
yarn build react/index,react-dom/index --type=NODE本地构建 React, 改完代码立刻能测build React locally so your tweaks are testable instantly
yarn test --watch ReactFiber运行 fiber 相关单元测试run fiber-related unit tests
yarn flow domReact 用 Flow 而非 TypeScript · 检查类型React uses Flow not TypeScript · type check
react-illustration-series第三方源码逐行注解(中文)third-party line-by-line annotations (Chinese)
overreacted.ioDan Abramov 博客 · 文章里讲 React 内部最权威Dan Abramov's blog · the most authoritative writing on React internals
jser.dev/react/JSer 系列视频 · 一行一行讲 React 源码JSer video series · React source line-by-line
reactjs/rfcs所有 React RFC · 设计决策的"第一手"all React RFCs · the "first-hand" record of design decisions
FIELD NOTE · React 用 Flow 不是 TypeScript FIELD NOTE · React uses Flow, not TypeScript 第一次进 React 源码会发现类型语法是 Flow——type Foo = ...$ReadOnly<X> 这些跟 TypeScript 很像但细节不同。这是历史原因——Flow 2014 由 Facebook 推出, React 用得早, 迁 TypeScript 不值得(公开 API 早就出了官方 .d.ts 类型)。但 Flow 在外部生态几乎死了——只有 React、Jest、Yarn 等 Meta 系仓库还在用。读 React 源码时把 Flow 当 "奇怪的 TypeScript" 看就行, 大多数语义能猜出来。 First time inside React source, you'll notice the types are Flow — type Foo = ... and $ReadOnly<X> resemble TypeScript with subtle differences. Historical: Flow shipped from Facebook in 2014, React adopted early; migrating to TS isn't worth it (public API already ships official .d.ts). Flow is effectively dead in the wider ecosystem — only Meta-orbit repos (React, Jest, Yarn) still use it. Treat Flow as "weird TypeScript" while reading; most semantics are guessable.

核心文件 · 演化简史

Core files · evolution history

读 React 源码要小心一件事: 同一个文件在不同版本里干的事可能完全不同。下面 8 个最核心文件的演化简史, 是读老博客 / 老 commit 时的避坑指南:

When reading React source, beware one thing: the same file may do entirely different jobs across versions. Below are 8 core files' evolution histories — a survival guide when reading old blog posts or commits:

file诞生 (作者)关键演化点2026 现状
ReactFiber.js 2015-09 · acdlite 16.0 (2017): 引入完整 Fiber 节点。16.8 (2019): 加 hooks 字段。18.0 (2022): 加 lane 字段, 弃 expirationTime。19.0 (2024): 加 refCleanup / _debugStack16.0 (2017): full Fiber node. 16.8 (2019): hooks fields. 18.0 (2022): lane fields, expirationTime gone. 19.0 (2024): refCleanup / _debugStack ~700 行 · 60 字段~700 lines · 60 fields
ReactFiberWorkLoop.js 2016 · acdlite (实验) → 2017 主仓 16.0: workLoopSync. 17.0: 加 workLoopConcurrent 雏形 (unstable). 18.0: concurrent 转 stable, 加 Selective Hydration 路径. 19.0: 加 Activity / Transition 改进16.0: workLoopSync. 17.0: workLoopConcurrent (unstable). 18.0: concurrent goes stable + Selective Hydration. 19.0: Activity / Transition refinements ~3500 行 · 入口最复杂~3500 lines · most complex entry
ReactFiberBeginWork.js 2016 · acdlite 16.0: 5 个 case (Function/Class/Host/...) . 17.0: ~25 case. 18.0: 加 Suspense / Offscreen. 19.0: 加 HostHoistable / HostSingleton (tag 25, 26)16.0: 5 cases. 17.0: ~25 cases. 18.0: + Suspense / Offscreen. 19.0: + HostHoistable / HostSingleton (tags 25, 26) ~2500 行 · 30+ case~2500 lines · 30+ cases
ReactChildFiber.js 2016 · acdlite 16.0: 引入 lastPlacedIndex 贪心算法. 后续 9 年几乎没动 (算法稳定)16.0: lastPlacedIndex greedy algorithm introduced. Barely touched in 9 years (algorithm stable) ~1500 行 · React 最稳定的核心文件~1500 lines · React's most stable core file
ReactFiberHooks.js 2018-10 · sebmarkbage 16.8 (2019): 9 个 hook. 17.0: 加 mountId, useMutableSource. 18.0: 加 useSyncExternalStore / useTransition / useDeferredValue / useId / useInsertionEffect. 19.0: 加 use() / useFormStatus / useActionState / useOptimistic / useMemoCache16.8 (2019): 9 hooks. 17.0: + mountId, useMutableSource. 18.0: + useSyncExternalStore / useTransition / useDeferredValue / useId / useInsertionEffect. 19.0: + use() / useFormStatus / useActionState / useOptimistic / useMemoCache ~3800 行 · 增长最快~3800 lines · fastest-growing
ReactFiberLane.js 2020 · acdlite 替代 18.0 之前的 expirationTime.js. 引入 31 位 bitmap. 之后加 Entangled / Retry / Offscreen lanesReplaced expirationTime.js pre-18. Introduced 31-bit bitmap. Later added Entangled / Retry / Offscreen lanes ~1500 行 · 调度核心~1500 lines · scheduling core
ReactFiberCommitWork.js 2017 · acdlite 16.0: effect list 链表遍历. 18.0: 改为 subtreeFlags 树剪枝. 19.0: 加 ErrorBoundary 重构 + ref cleanup callback16.0: effect list traversal. 18.0: switched to subtreeFlags tree pruning. 19.0: + ErrorBoundary rework + ref cleanup callback ~3000 行 · commit 总入口~3000 lines · commit entry
scheduler/Scheduler.js 2018 · acdlite + rickhanlonii 独立包. 16.x 内部用. 18.0 加 MessageChannel postTask. 19.0 加 PostTask Scheduler API (实验)Standalone package. Internal use in 16.x. 18.0: + MessageChannel postTask. 19.0: + PostTask Scheduler API (experimental) ~1500 行 · 完全独立可单独 npm~1500 lines · fully standalone, npm-able

读 React 源码的 5 个常见陷阱

5 common traps when reading React source

__DEV__ 包裹的代码不是注释——是 dev-only 真实逻辑
__DEV__ blocks aren't comments — they're real dev-only logic
React 用 if (__DEV__) {...} 包大量调试代码 (warn / freeze / double-invoke / owner tracking). 这些块生产构建被 dead-code 消掉, 但读源码时必须读——很多 dev/prod 不一致 bug 的解释都在这里。常见误读: "这段不影响生产, 跳过" → 错过 StrictMode 双 render 的来源。
React wraps lots of debug code in if (__DEV__) {...} — warnings, freezes, double-invokes, owner tracking. These blocks vanish in production build via dead-code elimination, but you must read them. Many dev/prod inconsistency bugs are explained here. Common mistake: "doesn't affect prod, skip" → miss StrictMode double-render's origin.
"old" / "new" 后缀文件是 fork——别看错版本
"old" / "new" suffixed files are forks — don't read the wrong version
React 17/18 重写期间, ReactFiberBeginWork.old.js + ReactFiberBeginWork.new.js 共存——old 是即将废弃的版本, new 是当前默认。读 .old 等于读历史; 读 .new 才是读现状。有 .new 就别读 .old。19.0 之后大部分 fork 已合并, 但 RSC 相关文件仍有 .old/.new 分支。
During React 17/18's rewrite, ReactFiberBeginWork.old.js + ReactFiberBeginWork.new.js coexisted. .old is deprecated, .new is current. Reading .old = reading history; reading .new = reading reality. If .new exists, ignore .old. Most forks merged after 19.0, but RSC-related files still split.
function 名以 __ 开头的是内部 — 不是公开 API
Names starting with __ are internal — not public API
看到 __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED 这种命名别笑——React 团队真的就用这个命名风格表达 "用了别哭"。这些是不保证向前兼容的内部接口, 三方库 (react-redux / react-router 等) 偶尔用, 但你自己代码里出现就是 reading-traps。
Don't laugh at __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED — the React team literally uses this naming to say "use at your own risk". These are not forward-compatible. Third-party libs (react-redux / react-router) touch them sometimes; if you see them in your own code, that's a code smell.
React 不用 TypeScript · 用 Flow · 类型有下划线变体
React uses Flow, not TypeScript · with underscored variants
type Fiber = {...}type Fiber_ = {...} 区别不在拼写——后者是 Flow 的opaque type, 跨 module 边界不可解构。读到带下划线类型时无法直接展开看字段, 必须找到它同 module内的定义点。
type Fiber = {...} vs type Fiber_ = {...} aren't typos — the latter is Flow's opaque type, not destructurable across module boundaries. When you see underscored types, you can't expand the fields directly; you must find the same-module definition.
大量 // $FlowFixMe 注释是临时绕过, 不是设计
Many // $FlowFixMe comments are temporary workarounds, not design
看到一段代码挂满 $FlowFixMe 别以为是有意为之——大概率是某次重构遗留, 后人不敢删。这些位置往往真有 type bug 但目前 hack 工作。读到时心理建立"这里有未完成工作"的预期, 别当成"这就是设计"。
A function plastered with $FlowFixMe isn't intentional — usually a refactor leftover no one dares delete. These spots do have type bugs but the hack works. Read with "unfinished work here" mindset, not "this is the design".

7 个核心 package 的依赖图

Dependency map of the 7 core packages

FACEBOOK/REACT MONOREPO · 7 PACKAGE DEPENDENCIES (READING ORDER) CORE LAYER react ~2K LoC · ① 1 day Element + Hooks API surface react-reconciler ★ ~30K LoC · ② 2 weeks · the largest Fiber · workLoop · reconcile · commit Hooks · Lanes · Throw · ContextStack scheduler ~1.5K LoC · ③ 2 days priority queue + MessageChannel imports HostConfig boundary RENDERER LAYER · concrete hosts react-dom ~15K LoC · ④ 3 days HostConfig.dom + hydration react-dom/server ~8K LoC · ⑤ 2 days Fizz · streaming SSR react-server ~6K LoC · ⑥ 3 days Flight encoder (the RSC core) react-server-dom-webpack ~4K LoC · ⑦ 2 days webpack/Turbopack glue COMMUNITY RENDERERS · plug their HostConfig in react-native (Fabric) r3f (three.js) ink (terminal) react-pdf (PDF ops) these depend on react-reconciler (cross-line via the HostConfig contract) — same algorithm, different concrete renderer 阅读顺序: react → reconciler → scheduler → dom → dom/server → react-server → react-server-dom-webpack
FIG 27·1 7 个核心 package 的依赖关系。react-reconciler 是中央(30K 行, 算法本体), 所有 renderer 都通过 HostConfig 边界挂上去。读源码的顺序是箭头反方向: 从 react Element 开始,进 reconciler 后向 scheduler 扩, 再去看 renderer 怎么消费 reconciler。社区 renderer (RN / r3f / ink) 都接在同一个 reconciler 上——这是 React 跨平台的核心。 Fig 27·1 · The 7 core packages and their deps. react-reconciler is the hub (30K LoC, the algorithm proper); every renderer attaches via the HostConfig boundary. Reading order is the reverse of the arrows: start at react Element, descend into the reconciler, branch off to scheduler, then study how each renderer consumes the reconciler. Community renderers (RN / r3f / ink) all plug into the same reconciler — this is what makes React cross-platform.
CHAPTER 28

术语表 — 30 个关键名词

Glossary — 30 key terms

这条目录是写给 6 个月之后忘了一半的你

written for the you that's forgotten half of this in six months

term含义meaning
ElementJSX 编译后的普通 JS 对象 · 不可变 · 每次 render 重建plain JS object compiled from JSX · immutable · recreated each render
Fiber内存里的组件实例 · 持有 state / hooks / dom ref / flags · 60 字段 · 600 字节in-memory component instance · holds state / hooks / DOM ref / flags · ~60 fields
current当前已经提交的 Fiber 树(屏幕上看到的那棵)the currently committed Fiber tree (what's on screen)
workInProgress正在计算的 Fiber 树 · 通过 alternate 指针指向 currenttree being computed · linked to current via alternate
alternatefiber 字段 · 指向双缓冲里的另一棵fiber field · points to the other tree in the double buffer
returnfiber 字段 · 完成后回到哪里(结构上的父)fiber field · where to return when complete (structural parent)
beginWork递阶段 · 把 element 转 fiber · 调用组件函数descent · element→fiber · calls component function
completeWork归阶段 · 创建 DOM(离屏)· 上卷 subtreeFlagsascent · creates DOM offline · bubbles subtreeFlags
Reconciliationchild element 和 child fiber 配对 · O(n) 三规则 diffmatch child elements with child fibers · O(n) 3-rule diff
flagsfiber 上的 32-bit 位掩码 · 标记 commit 要做的事32-bit bitmask on fiber · marks what commit must do
subtreeFlagsflags 上卷的版本 · commit 阶段剪枝用bubbled-up version of flags · used for commit pruning
Lanes31 位优先级模型 · 位运算合并/查询/移除31-bit priority model · bitwise merge/query/strip
SyncLane最高优先级 · 用户输入 / discrete eventhighest priority · user input / discrete events
TransitionLanestartTransition 用 · 16 条 · 可中断可丢弃used by startTransition · 16 lanes · interruptible
Scheduler独立包 · MessageChannel + 小顶堆 · 时间切片器standalone package · MessageChannel + min-heap time-slicer
shouldYieldworkLoop 每个 fiber 后调一次 · 检查 5ms 预算called after every fiber · checks the 5ms budget
batching同一 task 内多次 setState 合并为一次 rendermultiple setStates in one task coalesced into one render
commit三个子阶段:BeforeMutation / Mutation / Layoutthree sub-phases: BeforeMutation / Mutation / Layout
passive effectuseEffect · paint 后异步跑useEffect · async, after paint
HostConfigreconciler 与 renderer 之间的接口约定contract between reconciler and renderer
Suspenserender 中断的边界 · 接住 throw 的 Promiserender interrupt boundary · catches thrown Promises
use()React 19 · 可条件调用 · 读 Promise/ContextReact 19 · conditionally callable · reads Promise/Context
RSC payload服务器组件序列化输出的流式 React Elementstreamed serialized React Elements from server components
ActionsReact 19 · <form action={fn}> · pending / optimistic 内置React 19 · <form action={fn}> · pending / optimistic built in
React Compilerbabel 插件 · 自动生成 memo 代码 · opt-inbabel plugin · auto-generates memo code · opt-in
StrictModedev 模式 · 故意 double-invoke 来曝光 bugdev-only · double-invokes to surface bugs
Hydration把 SSR HTML 跟客户端 Fiber 树绑起来binding SSR HTML to a client Fiber tree
$$typeofElement 上的 Symbol · 防 XSS 伪造Symbol on Element · prevents XSS forgery
dispatcher挂在 ReactCurrentDispatcher.current · hook 实现的指针on ReactCurrentDispatcher.current · pointer to hook impls
ActivityReact 19 新元素 · 可隐藏但保留 state 的子树React 19 element · subtree that's hidden but state-preserved
Error Boundaryclass 组件 · 实现 gDSFE/cDC · 接住子树 render 抛错class component · implements gDSFE/cDC · catches descendant render throws
Portalfiber.tag=4 · 跨 container commit · React tree ≠ DOM treefiber.tag=4 · cross-container commit · React tree ≠ DOM tree
useReduceruseState 的"大哥" · 接显式 reducer · useState 是它的特化"elder sibling" of useState · takes explicit reducer · useState is its specialization
useImperativeHandle配合 forwardRef · 过滤 ref 暴露 · commit layout 阶段跑paired with forwardRef · filters ref exposure · runs in commit layout phase
useInsertionEffectmutation 之前跑 · 给 CSS-in-JS 库专用runs before mutation · CSS-in-JS exclusive
useFormStatus读最近父 <form> 的 pending · 隐式 contextreads nearest parent <form>'s pending · implicit context
useActionStateServer Action 的 [state, dispatch, isPending] 三元组Server Action's [state, dispatch, isPending] triple
useOptimistic乐观更新 · 异步完成前显示""结果optimistic update · show "fake" result pre-async
Entangled Lanes两条 lane 临时合并为一条 · 防 store tearingtwo lanes temporarily fused · prevents store tearing
Starvation Protection低优先级等 5 秒后被强制按 Sync 跑完low-priority forced to run as Sync after 5 s wait
lastPlacedIndexkeyed diff 的贪心游标 · 决定哪个 fiber 真需要 Placementgreedy cursor in keyed diff · decides which fibers truly need Placement
RSC payload面向行流 · 6 种 row · $1/$L1/$F1/$E 引用机制line-oriented stream · 6 row types · $1/$L1/$F1/$E reference scheme
Server Action'use server' 函数 · bundler 分配稳定 ID · 闭包跨网络'use server' function · bundler assigns stable ID · closures cross network
Streaming SSR按 Suspense 边界切 HTML · 慢部分不拖累快部分chunks HTML at Suspense boundaries · slow doesn't block fast
Selective Hydration用户点击优先 hydrate 该区 · 不必等整页click-prioritized hydration · no whole-page wait
HostConfigreconciler ↔ renderer 的合同 · ~30 个方法contract between reconciler and renderer · ~30 methods
FabricReact Native 新架构 · C++ JSI · supportsPersistenceReact Native's new architecture · C++ JSI · supportsPersistence
Owner Stackfiber._debugOwner 链 · 渲染父 ≠ 结构父 · React 19 error 中显示fiber._debugOwner chain · render parent ≠ structural parent · shown in R19 errors
React Compilerbabel plugin · 静态分析 + Memo Map · 自动注入 useMemoCachebabel plugin · static analysis + Memo Map · auto-injects useMemoCache
useMemoCacheCompiler 专用原语 · 挂 fiber 的稳定数组 · 替代多个 useMemoCompiler-only primitive · stable array on fiber · replaces many useMemos
JSIJavaScript Interface · Fabric 用 · 替代旧 BridgeJavaScript Interface · used by Fabric · replaces old Bridge
FaxJS2011 · React 的前身 · Jordan Walke 内部 hack2011 · React's predecessor · Jordan Walke's internal hack

12 组易混名词 · 别再用错了

12 frequently-confused pairs · stop mixing them up

AB差别
ElementFiberElement 是 props 描述 (一次性, 不变); Fiber 是组件实例 (持久, 含 state) — 见 Ch06/07Element = props description (throwaway, immutable); Fiber = component instance (persistent, holds state) — Ch06/07
vDOMFiber treevDOM 是表示 (用 Element 对象表达 UI); Fiber tree 是实体(在内存里的持久结构) — Ch07vDOM is representation (Elements describing UI); Fiber tree is the entity (persistent memory structure) — Ch07
render phaserender functionrender phase 是 React 内部的协调阶段; render function 是你写的组件函数 — 一个 phase 调用多次 functions — Ch05render phase = React's internal reconciliation stage; render function = your component fn — one phase calls many functions — Ch05
fiber.returnfiber._debugOwnerreturn 是结构父(被谁包含); _debugOwner 是渲染父(谁创建的) — 见 Ch21return = structural parent (who contains it); _debugOwner = render parent (who created it) — Ch21
SuspenseErrorBoundary同一套 throw 机制——前者接住 Promise, 后者接住 Error — Ch15B/Ch18Same throw mechanism — Suspense catches Promises, ErrorBoundary catches Errors — Ch15B/Ch18
useEffectuseLayoutEffect前者 paint 后异步跑, 后者 mutation 后同步跑 (阻塞 paint) — Ch15useEffect: async, after paint. useLayoutEffect: sync, after mutation, blocks paint — Ch15
useLayoutEffectuseInsertionEffect两者都同步, 但 useInsertionEffect 更早——在 mutation 之前跑, 给 CSS-in-JS 用 — Ch12BBoth sync, but useInsertionEffect runs earlierbefore mutation, for CSS-in-JS — Ch12B
batchingschedulingbatching: 多次 setState 合一次 render. scheduling: 决定 render 何时跑 — 前者属于 commit 边界, 后者属于 scheduler — Ch15C/Ch16batching: coalesce setStates into one render. scheduling: decide when render runs — former is commit boundary, latter is scheduler — Ch15C/Ch16
SyncLaneSyncBatchedSyncLane 是 lane 类型 (优先级位); SyncBatched 是历史用语 (React 17 之前的同步批量边界) — Ch16/Ch15CSyncLane = lane type (priority bit); SyncBatched = legacy term (pre-React-17 sync batch boundary) — Ch16/Ch15C
hydrationresumabilityhydration: 客户端重跑组件函数挂上 handler (React/Vue/Svelte); resumability: handler 在 click 时才下载(Qwik) — Ch20/Ch26Bhydration: client re-runs components to attach handlers (React/Vue/Svelte); resumability: handlers downloaded on click (Qwik) — Ch20/Ch26B
RSCSSRRSC 输出 wire format 序列化 Element; SSR 输出 HTML 字符串 — 两者可叠加 — Ch20RSC outputs wire-format serialized Elements; SSR outputs HTML string — they stack — Ch20
React.memouseMemo前者包组件: 父 render 时跳过子; 后者包表达式: 同函数内的值缓存 — Ch09/Ch12BReact.memo wraps a component: skips child render when parent renders; useMemo wraps an expression: caches a value within the same fn — Ch09/Ch12B

关键 RFC / Commit 索引

Key RFC / Commit index

读完文章想直接跳到源头? 这是设计决策的第一手记录:

Want to go to the source after this article? Here's the first-hand record of design decisions:

主题RFC / 链接对应章节
Hooks RFCHooks RFCreactjs/rfcs#68 (2018-10-26 · sebmarkbage)Ch12 / Ch12B
Concurrent Mode RFCConcurrent Mode RFCreactjs/rfcs#150 (2019-12-12)Ch16 / Ch17
Server Components RFCServer Components RFCreactjs/rfcs#188 (2020-12-21 · gaearon + sebmarkbage + laurentan)Ch20
Automatic BatchingAutomatic Batchingreactwg/react-18#21 (2021)Ch15C
Suspense for Data FetchingSuspense for Data Fetchingreactjs/rfcs#213 (2022)Ch18
Server Functions / ActionsServer Functions / Actionsreactjs/rfcs#229 (2023)Ch20 / Ch12B
React Compiler · 公开博客React Compiler · public blogreact.dev 2024-02Ch21
Fiber 原始设计文档Fiber design docacdlite/react-fiber-architecture (2016)Ch03 / Ch25
React 18 working groupReact 18 working groupreactwg/react-18 (2021-2022 全程公开讨论)Ch16-Ch18
React 19 working groupReact 19 working groupreactwg/react-19Ch12B / Ch21

三个看完本文应该会做的事

Three things you should do after this article

写一个最小 renderer (200 行 HostConfig)
Write a minimal renderer (200-line HostConfig)
不是为了用——是为了感受 reconciler 跟 renderer 的边界。300 行内你能让 React 渲染到任何东西 (console / JSON / SVG / midi)。这一行写下来 React 的"不是 web 框架" 这事会变成肌肉记忆
Not for use — to feel the reconciler/renderer boundary. In 300 lines you can render React to anything (console / JSON / SVG / midi). Writing this turns "React isn't a web framework" into muscle memory.
读 Andrew Clark 2016 那篇 Fiber Architecture
Read Andrew Clark's 2016 Fiber Architecture doc
那是 React 现代设计的开篇。文章里没出现 fiber 这两个字之前的 React 是一个完全不同的库——读它能看见设计是怎么发生的。地址: github.com/acdlite/react-fiber-architecture
It's the opening chapter of modern React's design. Before this document, React was a different library — reading it shows you how design happens. URL: github.com/acdlite/react-fiber-architecture
用 Solid 或 Vue 写同一个组件
Write the same component in Solid or Vue
前面 26 章你只看了 React 怎么解决问题。试着用 Solid 或 Vue 3 实现同一个 Counter v2——你会突然懂"React 为什么这么做"——通过看它没那么做。这是最快的 React 心智回路检验。
26 chapters showed you how React solves problems. Try implementing the same Counter v2 in Solid or Vue 3 — you'll suddenly grok "why React does it this way" by seeing what it didn't. The fastest React mental-model self-test.

30 个术语 · 按概念维度聚合

30 terms grouped by conceptual axis

GLOSSARY MAP · 30 terms · 5 conceptual axes ① DATA STRUCTURES 物件 — what React actually allocates Element · Fiber · WorkInProgress · alternate flags · subtreeFlags · effect list · Hook list UpdateQueue · stateNode · memoizedState 10 terms ~Ch07-Ch12 ② PHASES 流程节点 — what runs when render · commit · before-mutation · mutation layout · passive · beginWork · completeWork reconciliation · bail-out 10 terms ~Ch05/Ch09-Ch15 ③ SCHEDULING 优先级 — when & how interrupted Lane · SyncLane · TransitionLane time slicing · yield · workLoop batching · automatic batching 8 terms ~Ch16-Ch17 (+ Ch17·B) ④ ASYNC & SUSPENSE 数据未就绪 — pause & fallback Suspense · throw-promise · boundary use(promise) · transition · optimistic fallback · cache 7 terms ~Ch18 / Ch21 ⑤ SERVER / RSC 服务器侧概念 RSC · wire format · 'use client' manifest · hydration · Fizz · Flight Server Action · revalidate 9 terms ~Ch20 / Ch22·B
FIG 28·1 术语表的概念维度聚合: 数据结构 / 流程节点 / 调度 / 异步 / 服务器侧。同一个术语可能跨多个维度(比如 useTransition 横跨 ③ 和 ④), 这张图给的是首要归属。6 个月后回来查时, 先问"这是个什么类型的概念"——按颜色找盒子, 比按字母表更快。 Fig 28·1 · The glossary organised by conceptual axis: data structures / phases / scheduling / async / server-side. A term may span axes (e.g. useTransition lives in ③ and ④); this figure shows the primary home. Coming back in six months, ask "what kind of concept is this?" first — finding by colour is faster than alphabetical lookup.
"我们换了引擎,但你不需要换驾照。" "We changed the engine. You don't need a new driver's license." — Andrew Clark, React Conf 2017
"React 不是关于 UI ——它是关于表达一种意图, 让机器去想怎么实现。" "React isn't about UI — it's about expressing intent, leaving the how to the machine." — Sebastian Markbåge, React Conf 2018 (Hooks 首发)
"读源码不是为了变成 React 的用户——是为了变成它的同事。" "Reading the source isn't to become a better React user — it's to become its colleague." — Airing, 2026 (本文末)
APPENDIX · REFERENCES

References — 每个论断的出处

References — sources for every claim

官方文档 · RFC · 论文 · 演讲 · 源码 · 博客

Official docs · RFCs · papers · talks · source · blogs

把全文用到的外部出处归档在一处。每条带状态: DOC 官方文档 · SRC 源码路径 · PAPER 学术论文 · TALK 大会演讲 · RFC 互联网标准 · BLOG 长文笔记。所有 URL 在 2026 年 5 月有效

Every external source the article touches, archived here. Tags: DOC official · SRC source path · PAPER academic · TALK conference · RFC internet · BLOG long-form notes. URLs valid as of May 2026.

A · React 官方文档 & RFC

A · React official docs & RFCs

官方文档族Official docs family
react.dev
DOC react.dev · React 19 的官方学习站。所有 hooks/API 参考。Official React 19 learning site; all hooks/API references.
RFCs repo
DOC github.com/reactjs/rfcs · 所有大特性设计文档(Hooks #68, Suspense #213, Server Components #188)。All major feature design docs (Hooks #68, Suspense #213, Server Components #188).
Hooks RFC
DOC RFC #68 · Hooks · Sebastian Markbåge, 2018-10。Ch12 的 dispatcher 设计来源。Sebastian Markbåge, 2018-10. Dispatcher design used in Ch12.
RSC RFC
DOC RFC #188 · React Server Components · Dan Abramov / Lauren Tan / Andrew Clark / Joe Savona, 2020-12。Ch20 wire format / 'use client' 边界。Dan Abramov / Lauren Tan / Andrew Clark / Joe Savona, 2020-12. Source for Ch20 wire format / 'use client' boundary.
Suspense RFC
DOC RFC #213 · Suspense · Ch18 throw-promise + Boundary 协议。Ch18 throw-promise + Boundary protocol.
use RFC
DOC RFC · use · React 19 的 use(promise) 设计文档,Ch18/21。React 19 use(promise) design doc, Ch18/21.

B · 经典设计文档 & 源码导览

B · Landmark design docs & source-code maps

现代 React 设计的"开篇"The "founding documents" of modern React
Fiber arch
BLOG acdlite/react-fiber-architecture · Andrew Clark, 2016。定义了 Fiber 的核心数据结构(type/return/child/sibling)。Ch07/08 全部源于此。Andrew Clark, 2016. Defines Fiber's core data structure (type/return/child/sibling). Source for all of Ch07/08.
Inside Fiber
BLOG Inside Fiber · Andrew Lukashov · 基于 Lin Clark 演讲的深入图解,Ch08/09/11 的 double-buffer 心智模型。Image-rich deep dive based on Lin Clark's talk; the double-buffer mental model in Ch08/09/11.
Algebraic Effects
BLOG Algebraic Effects for the Rest of Us · Dan Abramov · Suspense 的理论基础。Ch18。The theoretical basis of Suspense. Ch18.
React Internals
BLOG How React Fiber works · JSer.dev · 逐行讲解 workLoopSync / performUnitOfWork。Ch09/11/16 引用。Line-by-line walk-through of workLoopSync / performUnitOfWork. Cited in Ch09/11/16.
Why did you render
BLOG Before You memo() · Dan Abramov · 解释 React.memo / useMemo 的真实代价。Ch10/21 footnote。The real cost of React.memo / useMemo. Footnote in Ch10/21.

C · 大会演讲 & 视频(Lin Clark 三连)

C · Conference talks & videos (the Lin Clark triple)

2017-2019 React 内核三大教学演讲The three canonical Fiber/Hooks teaching talks (2017-2019)
Lin Clark
TALK A Cartoon Intro to Fiber · ReactConf 2017 · Fiber 教学的祖文。Code Cartoon 风格画出"链表怎么 cooperative-schedule"。Ch07/08/09 的可视化语言全部源于此。The founding talk of Fiber teaching. Code-cartoon style showing how the linked list cooperative-schedules. All of Ch07/08/09's visual vocabulary descends from this.
Lin Clark
TALK code-cartoons.com · Lin Clark 的卡通技术解释 series。React、Flux、wasm、Servo 都画过。Lin Clark's cartoon-style technical explainer series — React, Flux, wasm, Servo.
Sophie Alpert
TALK Building React from Scratch · ReactConf 2018 · 现场用 200 行实现 React。Ch04 三层架构溯源。Live-coding React in 200 lines. Source for Ch04's three-layer model.
Sebastian
TALK React Today and Tomorrow · ReactConf 2018 · Hooks 首发演讲。Ch12 的 dispatcher 设计源。The Hooks unveiling. Dispatcher design source for Ch12.
Andrew Clark
TALK React 18 Keynote · ReactConf 2021 · Concurrent Rendering / Suspense for Data Fetching / Server Components 全景。Ch17/18/20。Concurrent Rendering / Suspense for Data / Server Components panorama. Ch17/18/20.
Joe Savona
TALK React without memo · React Conf 2021 · React Compiler 的设计预告。Ch21 引用。The design preview of React Compiler. Cited in Ch21.

D · React 19 / Compiler 数据出处

D · React 19 / Compiler primary sources

React 19
DOC React 19 Release · 2024-12-05 · 官方发布文。use、Actions、ref-as-prop 等。Ch21 全部基于此。Official release note. use, Actions, ref-as-prop. Foundation of Ch21.
Compiler
DOC React Compiler · react.dev · 官方介绍页(2024 beta)。讲编译器目标、产物形状、何时不要用。Official overview (2024 beta). Goals, output shape, when not to use it.
Compiler perf
BLOG Introducing React Compiler · Meta Engineering 2024-05 · Ch21 那条"35-45% 重渲减少"footnote 的原始数据来源。来自 Instagram.com / Quest Store / Meta 内部跑分。The primary source for Ch21's "35-45% re-render reduction" claim. Data from Instagram.com / Quest Store / internal Meta benchmarks.
Compiler RFC
DOC React Compiler discussions · 设计讨论与 known limitations。Design discussion thread and known limitations.
Babel plugin
SRC facebook/react · compiler/ · React Compiler 源码,babel-plugin-react-compiler。Ch21。React Compiler source, babel-plugin-react-compiler. Ch21.
Actions
DOC useActionState reference · React 19 Actions API。Ch21 表单 + Server Action。React 19 Actions API. Ch21 forms + Server Action.
Owner Stacks
DOC captureOwnerStack reference · React 19.1 调试 API。Ch21。React 19.1 debug API. Ch21.

E · 学术论文 / 形式化

E · Academic papers / formal models

Algeb. effects
PAPER An Introduction to Algebraic Effects and Handlers · Plotkin 2015 · Suspense / use 的理论基础。Ch18 footnote。Theoretical basis of Suspense / use. Ch18 footnote.
VDOM perf
PAPER Stop Pasting Reactivity Together · Solid talks · 对照 Solid 的signal-based 重渲。Ch26·B。Contrast against Solid's signal-based re-render. Ch26·B.
O(n) diff
PAPER String-to-String Edit Distance · Wagner 1974 · Ch10 O(n³) → O(n) 三启发的原始 O(n³) 来源。Source of the O(n³) baseline that Ch10's three heuristics escape.
Concurrent UI
PAPER Onward! 2022 · UI concurrency · Ch17 time-slicing 类比的形式化处理。Formal treatment of UI time-slicing analogs in Ch17.

F · 标准 / RFC / Web Platform

F · Standards / RFCs / Web Platform

JSX spec
DOC JSX Specification · Draft 6 · JSX 语法 (不是 React 独有)。Ch06。JSX syntax (not React-specific). Ch06.
JSX runtime
DOC RFC #123 · automatic JSX runtime · 为什么 React 17+ 不需要 import React。Ch06。Why React 17+ no longer needs import React. Ch06.
Scheduler
WD WICG · Scheduling APIs · Browser scheduler.postTask;React 18 之前的 polyfill 思路。Ch16。Browser scheduler.postTask; the model React 18 polyfills. Ch16.
requestIdleCallback
WD W3C · requestIdleCallback · React 16 Time Slicing 最初基于此 API,后改 MessageChannel。Ch16/17。React 16's initial time-slicing API; later replaced by MessageChannel. Ch16/17.
MessageChannel
DOC HTML Living Standard · Web Messaging · React 17+ Scheduler 用 port.postMessage 触发宏任务。Ch16。How React 17+ Scheduler triggers macrotasks via port.postMessage. Ch16.
Streams
DOC WHATWG · Streams Living Standard · RSC 用 ReadableStream 串行送 chunk。Ch20。RSC uses ReadableStream to send chunks serially. Ch20.
Trusted Types
WD W3C · Trusted Types · React 19 内置支持。dangerouslySetInnerHTML 边界的安全防线。Built-in support in React 19. Safety boundary at dangerouslySetInnerHTML.

G · React 源码路径 (commit-pinned, React 19.x)

G · React source paths (commit-pinned, React 19.x)

核心 Reconciler / Scheduler 文件 — 章节 cross-refCore Reconciler / Scheduler files — chapter cross-refs
Fiber
SRC react-reconciler/src/ReactFiber.js · createFiber / createWorkInProgress。Ch07/08。createFiber / createWorkInProgress. Ch07/08.
WorkLoop
SRC react-reconciler/src/ReactFiberWorkLoop.js · workLoopSync / workLoopConcurrent / performUnitOfWork。Ch09/11/16/17。workLoopSync / workLoopConcurrent / performUnitOfWork. Ch09/11/16/17.
beginWork
SRC ReactFiberBeginWork.js · Ch09 递的一半,bail-out 入口。Ch09 descent half, bail-out entry.
completeWork
SRC ReactFiberCompleteWork.js · Ch11 归的一半,Host 节点 prepareUpdate / appendAllChildren。Ch11 ascent half, Host node prepareUpdate / appendAllChildren.
Reconciler
SRC ReactChildFiber.js · Ch10 keyed list diff (reconcileChildrenArray, lastPlacedIndex)。Ch10 keyed-list diff (reconcileChildrenArray, lastPlacedIndex).
Hooks
SRC ReactFiberHooks.js · Ch12 三个 dispatcher 切换在HooksDispatcherOnMount / HooksDispatcherOnUpdate / ContextOnlyDispatcherCh12 dispatcher switch: HooksDispatcherOnMount / HooksDispatcherOnUpdate / ContextOnlyDispatcher.
Commit
SRC ReactFiberCommitWork.js · Ch13/14/15 commit 三阶段。commitBeforeMutationEffects / commitMutationEffects / commitLayoutEffectsCh13/14/15 three commit phases.
Lanes
SRC ReactFiberLane.js · Ch16 31 bit lane 定义、优先级、合并。Ch16 31-bit lane definitions, priorities, merge ops.
Scheduler
SRC packages/scheduler/src/forks/Scheduler.js · Ch16/17 独立包,优先队列 + 5 ms time slice。Ch16/17 stand-alone package, priority queue + 5 ms time slice.
Suspense
SRC ReactFiberThrow.js · Ch18 throw promise / Boundary 协议。Ch18 throw-promise / Boundary protocol.
RSC server
SRC packages/react-server · Ch20 RSC server-side wire format encoder。Ch20 RSC server-side wire format encoder.
RSC client
SRC packages/react-client · Ch20 客户端 chunk decoder。Ch20 client-side chunk decoder.
HostConfig
SRC ReactFiberConfig.dom.js · Ch04/Ch26 DOM 端的 HostConfig 实现。Ch04/Ch26 DOM-side HostConfig implementation.

H · 跨框架对照(Ch26·B)

H · Cross-framework comparison (Ch26·B)

Vue 3
DOC Vue · Reactivity in Depth · Proxy-based 细粒度依赖追踪。和 React VDOM 直接对照。Proxy-based fine-grained dependency tracking. Direct contrast with React's VDOM.
Solid
DOC Solid · Reactivity · Signal-based 无 VDOM。组件函数只跑一次,所以"为什么 React 跑多次"得到反衬。Signal-based, no VDOM. Component function runs once — by contrast, "why does React run repeatedly" becomes vivid.
Svelte 5
DOC Svelte 5 · Runes · 编译时分析 dirty tracking。和 React Compiler 直接对照。Compile-time dirty tracking. Direct contrast with React Compiler.
Preact
SRC preactjs/preact · Preact 10 ~3KB,同样 VDOM 不同 Reconciler。Ch26 多端对照参考。Preact 10 ~3 KB, same VDOM model but a different reconciler. Ch26 cross-renderer reference.

I · 调试 / DevTools

I · Debugging / DevTools

DevTools
DOC React DevTools 用户文档 · Ch23 Profiler / Components inspector。Ch23 Profiler / Components inspector.
DevTools src
SRC react-devtools-extensions · DevTools 扩展实现。Ch23。DevTools extension impl. Ch23.
Profiler API
DOC <Profiler> reference · 代码内程序化测量 commit 时间。Ch23。Programmatic in-code commit-time measurement. Ch23.
Chrome perf
DOC Chrome DevTools · Performance · Ch24 火焰图 + Tracks 解读。Ch24 flame chart + Tracks reading.
Hydration
DOC Error #418 · Hydration text mismatch · Hydration mismatch 最常见的报错。Ch22·B。The most common hydration mismatch error. Ch22·B.
StrictMode
DOC StrictMode reference · 为何 effect / render 跑两次的官方说明。Ch15/22。Why effects/renders run twice. Ch15/22.

J · 历史考古 / commit pointers

J · Historical archaeology / key commits

First commit
SRC facebook/react · 2013-05-29 first commit · Jordan Walke 第一个 React commit。Ch02/25。Jordan Walke's first React commit. Ch02/25.
Fiber merge
SRC PR #8784 · Initial Fiber implementation · Andrew Clark 把 Fiber 合入 main 的 PR (2017-02)。Ch03/25。Andrew Clark's PR merging Fiber to main (2017-02). Ch03/25.
Hooks merge
SRC PR #13968 · useState/useEffect/useContext · Hooks 首次实现 PR(2018-10)。Ch12/25。First Hooks implementation PR (2018-10). Ch12/25.
Concurrent
SRC reactwg/react-18 · working group · React 18 Concurrent Rendering 全公开设计讨论。Ch17/25。React 18 Concurrent Rendering public design discussion. Ch17/25.

本节共 72 条外部引用,覆盖每条非自明论断。如发现链接失效或事实漂移,issue 见 airingursb/airingursb.github.io

This section carries 72 external references, one for every non-self-evident claim. Broken link or factual drift → file an issue at airingursb/airingursb.github.io.