一次 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.
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.
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.
很多人把"虚拟 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.
公式①和②自 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.
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.
从 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:
beginWork + completeWork + workLoop。第一次允许 render 阶段被打断重启。两年时间,三万行代码。beginWork + completeWork + workLoop. For the first time, render could be paused and resumed. Two years, ~30,000 lines.useDeferredValue。新 root API(createRoot)开关全部并发能力。useDeferredValue. The new root API (createRoot) flipped concurrency on.use、Actions、useOptimistic、Compiler 同步落地。use, Actions, useOptimistic, the Compiler — all in.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.
递归不可中断 · 浏览器卡顿 · 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.
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 节点。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.
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."
为什么同一套 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.
createInstance、appendChild、commitUpdate……把 reconciler 算出的差异落到具体平台。
~15 KB gzip (DOM). Implements ~30 HostConfig methods: createInstance, appendChild, commitUpdate… Lands the diff onto the platform.
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 }
react-reconciler 是公开 npm 包,任何人都可以 npm i react-reconciler 写一个自己的 renderer。react-three-fiber、react-pdf、react-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".
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.
一张图把 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.
| phase | can interrupt? | side-effects? | runs in | pkg |
|---|---|---|---|---|
| Render | yes (concurrent) | NO — must be pure | JS task, sliced | react-reconciler |
| Commit · BeforeMutation | no | read DOM only | microtask | react-reconciler |
| Commit · Mutation | no | DOM writes here | microtask | react-dom |
| Commit · Layout | no | sync, blocks paint | microtask | react-reconciler |
| Commit · Passive | yes (re-scheduled) | async, after paint | scheduled task | scheduler |
| Scheduler | — | no | MessageChannel ports | scheduler |
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> ); }
为了让接下来 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.
createRoot(...).render(<Counter/>)。一棵全新的 Fiber 树从无到有,所有 DOM 节点离屏创建。createRoot(...).render(<Counter/>). A fresh Fiber tree from nothing; every DOM node created off-document.setCount(1) 入队,SyncLane 调度,复用 fiber,1 次 DOM 写。setCount(1) enqueues, SyncLane scheduled, fibers reused, 1 DOM write.ChildDeletion,递归卸载 ref、跑 effect cleanup,最后从 DOM 摘除。ChildDeletion; recursive ref-detach, effect cleanup, then DOM removal.用户点击按钮。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:
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.
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 是同一个标本的最简形态。但 React 里有些路径——keyed list diff、useContext 传播、useTransition 降优先级、Suspense throw promise、RSC + 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>; }
TodoList 子组件 + useTransition + <Suspense>。激活 keyed-list diff (Ch10) / context (Ch12B) / transition (Ch17) / Suspense (Ch18) / Action (Ch20-21) 路径。TodoList child, useTransition, and <Suspense>. Lights up keyed-list diff (Ch10), context (Ch12B), transition (Ch17), Suspense (Ch18), Action (Ch20-21) paths.jsx() 不是 createElement · 不是 vDOM 的等价物
jsx() is not createElement; it is not the virtual DOM
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" }) ] }); }
_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 }
$$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.
每次 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).
每次 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".
在内存里常驻。约 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".
child · sibling · return —— 三根指针撑起 React 半壁江山
child · sibling · return —— three pointers, half of React
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.
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 // 双缓冲的另一棵 (下一章) };
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 我们只举了 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.
| tag | name | 你写的形式What you write | commit DOM? |
|---|---|---|---|
| 0 | FunctionComponent | function Counter() {} | — |
| 1 | ClassComponent | class Counter extends Component | — |
| 11 | ForwardRef | forwardRef((p, ref) => ...) | — |
| 14 | MemoComponent | memo(Component) | — |
| 15 | SimpleMemoComponent | memo 包裹的函数组件(fast path)memo-wrapped FC (fast path) | — |
| tag | name | 你写的形式What you write | commit DOM? |
|---|---|---|---|
| 3 | HostRoot | createRoot(container) | yes (container) |
| 4 | HostPortal | createPortal(...) | yes (different parent) |
| 5 | HostComponent | <div> / <input> / ... | yes |
| 6 | HostText | JSX 里的纯文本plain text in JSX | yes (TextNode) |
| 25 | HostHoistable | <link> / <meta> / <title> (R19)<link> / <meta> / <title> (R19) | yes → <head> |
| 26 | HostSingleton | <html> / <body> / <head> (R19) | yes (复用现有) |
| tag | name | 作用Purpose | commit DOM? |
|---|---|---|---|
| 7 | Fragment | <>...</> / <Fragment> · 透明分组transparent group | no |
| 8 | Mode | <StrictMode> · 激活 dev 检查activates dev checks | no |
| 13 | SuspenseComponent | <Suspense fallback> · throw 边界throw boundary | no (only marker) |
| 18 | DehydratedFragment | SSR 流式占位SSR streaming placeholder | special |
| 19 | SuspenseListComponent | <SuspenseList> (R19 experimental) | no |
| 28 | Throw | R19 · 表示 render 时抛出的R19 · render-time throw marker | no |
| tag | name | 作用Purpose | 何时用When seen |
|---|---|---|---|
| 9 | ContextConsumer | <Context.Consumer> · render-prop API | 极少(多用 useContext)rare (useContext preferred) |
| 10 | ContextProvider | <Context.Provider value> | 几乎每个 appalmost every app |
| 12 | Profiler | <Profiler onRender> | DevTools 用used by DevTools |
| 16 | LazyComponent | lazy(() => import(...)) | 代码分割code splitting |
| 21 | OffscreenComponent | <Activity hidden> (R19 stable) · 隐藏但保留 statehidden, state preserved | 路由 / tab 切换routing · tabs |
| 23 | CacheComponent | RSC 用 · cache() 的 fiber 表示RSC · fiber rep of cache() | RSC only |
| 24 | TracingMarkerComponent | <unstable_TracingMarker> | 实验中experimental |
| tag | name | 用途Purpose |
|---|---|---|
| 2 | IndeterminateComponent | 挂载初期 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) |
| 17 | IncompleteClassComponent | class 组件 render 抛错时的恢复态recovery state when class component render throws |
| 22 | LegacyHiddenComponent | 即将废弃 · 被 OffscreenComponent 取代deprecated · replaced by OffscreenComponent |
| 27 | IncompleteFunctionComponent | 函数组件 render 抛错时的恢复态recovery state for FC throws |
<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.
显卡借来的把戏 · alternate 指针 · 失败回滚
borrowed from GPUs · the alternate pointer · safe rollback
游戏引擎渲染时永远维护两块帧缓冲——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.
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.
"双缓冲翻转"听起来很玄, 其实它只是一行赋值。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":
教学时常把双缓冲讲成"每次 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.
教学上,大家都会画"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).
workLoop · performUnitOfWork · 一个组件一次循环
workLoop · performUnitOfWork · one component, one iteration
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 进来第一件事就是看 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; }
注意 beginWork 第一行的提前返回:如果 props 没变、context 没变、这棵子树没有任何 update lane——React 整棵子树都跳过。这就是 React.memo、useMemo、useCallback 起作用的位置。它们不是"缓存",是把 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.
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.
同层比较 · type 标识 · key 标识
same-level · type identity · key identity
通用的两棵树之间最小编辑距离是 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:
<div> → <span>?整棵子树全部销毁,重建。即便子树长得一模一样。<div> → <span>? Whole subtree destroyed and rebuilt — even if children are identical.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.
前一节的算法告诉你哪些 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 推进 } }
在数组开头插一项 → 所有现有节点的 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.
用 todo.id、user.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.
<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.
effect flags · subtreeFlags · 离屏 DOM 创建
effect flags · subtreeFlags · off-DOM creation
当 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:
document.createElement(type)。注意:这时还没插入页面。completeWork 也调 appendInitialChild 把子 DOM 挂到自己上——但整棵子树仍然在文档树之外。这就是为什么大列表 mount 时浏览器 layout 只触发一次。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.prepareUpdate——React 不直接套用新 props,而是先算出一个"差异对象"挂在 fiber.updateQueue 上。Commit 阶段才真去改 DOM——这种预计算让 commit 跑得飞快。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.每个 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;
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.
当 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.
memoizedState · queue · 调用顺序的物理约束
memoizedState · queue · call-order as a physical constraint
函数组件没有 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.
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.
// 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); }
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 上轮转。render 之外调 hook 会撞到 ContextOnlyDispatcher 的 throwInvalidHook——这就是著名的 "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.
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.
useContext · useMemo · useCallback · useRef · useSyncExternalStore · useTransition · useId
useContext · useMemo · useCallback · useRef · useSyncExternalStore · useTransition · useId
Counter 只用了 useState 和 useEffect——前一章把这两个的内核拆透。但 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 | Hook.memoizedState 形态 | queue | 触发 re-render |
|---|---|---|---|
| useContext | null(不存值,只挂依赖) | — | 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 subscribe | store 通知 → forceUpdate |
| useTransition | [isPending, startTransition] | SyncLane 用于 isPendingSyncLane for isPending | TransitionLane |
| useId | ":r0:" | — | — |
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.
// 简化版 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 是同一段代码——后者只是前者的语法糖(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 —— 浅比较, 不递归
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.
// 全部实现就这两行 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).
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
两个 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:
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).
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.
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(推荐用法)
很多人不知道: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.
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 阶段的精确时机:useImperativeHandle 跟 useLayoutEffect 同步跑——在 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.
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 把"提交表单" 升级成了 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>; }
SubmitBtn——它不接任何参数也不接 ref,却能读到外层 <form> 的 pending。秘密:React 19 把 form action 的 pending 状态做成隐式 context——<form action> 自动建立一个 form-status context provider;useFormStatus 是 useContext 的特化。这就是为什么它只能用在 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.
最简单的 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; }
读 DOM · getSnapshotBeforeUpdate · 但还不能写
read DOM · getSnapshotBeforeUpdate · no writes yet
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:
"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; } } }
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".
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:
getSnapshotBeforeUpdate 就是干这事的——返回的 snapshot 自动传给 commit layout 阶段的 componentDidUpdate。getSnapshotBeforeUpdate exists for this — the returned snapshot is auto-passed to componentDidUpdate in the layout phase.new Image() 先去预加载 ; commit Mutation 把 src 设上去时浏览器立刻 paint (因为已 cache)。这个 trick 只在 BeforeMutation 能玩——commit Layout 太晚, render phase 又不能 side-effect。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.document.activeElement 的特征 (DOM 路径/data-test-id), Layout 阶段对照新 DOM 找到对应节点 .focus() 上去。React 内部对受控组件做了一部分这种保护, 但跨非 React 边界 (比如把 input 移进 portal) 仍需要手写。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.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>; } }
很多人问: 为什么 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.
commitPlacement · commitUpdate · commitDeletion
commitPlacement · commitUpdate · commitDeletion
这是 React 整个流水线里唯一真的改 DOM 的阶段。它按 fiber.flags 分发处理:
The only phase that touches the DOM. Dispatches on fiber.flags:
| flag | action | react-dom 调用 |
|---|---|---|
| Placement | 插入到父 DOMinsert into parent DOM | parent.appendChild(node) / parent.insertBefore(node, ref) |
| Update | 应用 updatePayloadapply updatePayload | setValueForProperty / setValueForStyles / setTextContent |
| ChildDeletion | 移除并回收 fiberremove + recycle fiber | parent.removeChild(node) + 递归卸载 ref/effect |
| Ref | 先解绑旧 refdetach old ref | oldRef(null) / oldRef.current = null |
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.
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); }
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.
createPortal(children, otherContainer) 让一棵子树渲染到另一个 DOM container——典型场景是 Modal / Tooltip / Dropdown,它们需要逃出父级的 overflow: hidden 或 z-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 下
getRootHostContainer(portalFiber) 返回 #modal-root, 不是 App 的容器 → appendChild 落到 portal 自带 container。getRootHostContainer(portalFiber) which returns #modal-root, not App's container → appendChild lands in the portal's own container.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 不是一个"原子操作"——它内部有严格的子步顺序。这个顺序决定了 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.flag | react-dom 调用 |
|---|---|---|---|
| 1 | 递归处理 ChildDeletion · 卸载子树Recursive ChildDeletion · unmount subtree | Deletion | 见下表展开expanded below |
| 2 | 解绑旧 refDetach old refs | Ref | oldRef(null) / oldRef.current = null |
| 3 | 运行所有 useInsertionEffect cleanupRun all useInsertionEffect cleanups | Insertion | CSS-in-JS 注入前清旧 styleclear old styles before injecting |
| 4 | 运行所有 useInsertionEffect createRun all useInsertionEffect creates | Insertion | 插入新 style tag (在 DOM 改前!)insert new style tag (before DOM mutate!) |
| 5 | 应用所有 Placement / Update / TextUpdateApply all Placement / Update / TextUpdate | Placement, Update | appendChild / 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 | |
删除一棵子树不是一个 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);
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 增加了一个反直觉点: 它的容器跟父 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 必须同步一气呵成" 这件事就显然了——任何中断都会让 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.
同步 vs 异步 · paint 前 vs paint 后
sync vs async · before paint vs after 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.
被 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).
每个 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.
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.
getDerivedStateFromError · componentDidCatch · React 19 三回调
getDerivedStateFromError · componentDidCatch · React 19's three callbacks
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.
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:
getDerivedStateFromError 是 静态方法——React 在需要时直接拿类引用调用,不依赖父组件的 render 周期。getDerivedStateFromError is a static method — React calls it directly off the class reference, independent of any render cycle.try { return <Child/> } catch (e) { ... }。但 JSX 不是函数调用——Child 真正 render 是后续 workLoop 的事,那时这层 try 早已退栈。所以接错的机制必须在更深的协调层——不是用户 JS。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.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; } }
React 19 把"错误监控"从分散在各个 ErrorBoundary 提到了整个 root。createRoot 现在接 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); }, });
没有任何 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.
某个 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.
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.
初学者常犯的错:把整个 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 挂掉, 其他依然正常
onClick 抛错)— 不在 render 流里,try/catch 自己处理。异步代码里的错(setTimeout、fetch 的 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.
从 event-boundary 到 lane-based · 跨 setTimeout/Promise 自动合并
from event-boundary to lane-based · cross-setTimeout/Promise auto-batching
"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 的 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 把"是不是在 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 ✓ }); };
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(); // 滚到刚显示的元素 }
queueMicrotask 或 requestAnimationFrame 而不是 flushSync——前者不破坏 batching, 后者破坏。queueMicrotask or requestAnimationFrame instead of flushSync — the former preserves batching, the latter breaks it.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 是"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.
用位掩码替代优先级队列
a bitmask instead of a priority queue
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.
// 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> 隐藏树
Lanes 设计的核心追求是合并和查询都要快。位运算让这两件事都是 1 个 CPU 周期:
Lanes target one thing: merge + query in one CPU cycle. Bitwise wins:
| operation | lane code | cost |
|---|---|---|
| 合并两组优先级Merge two priority sets | a | b | 1 cycle |
| 检查是否有某优先级Check if a priority is present | (lanes & lane) !== 0 | 1 cycle |
| 取最高优先级Pick highest priority | lanes & -lanes | 1 cycle (lowest set bit) |
| 移除已完成优先级Remove completed priority | lanes & ~done | 1 cycle |
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.
MessageChannel · shouldYield · 调度器内部
MessageChannel · shouldYield · inside the scheduler
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 等到浏览器真的空了才触发——可能 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.requestIdleCallback。MessageChannel 是 99%+ 兼容的标准 API。requestIdleCallback. MessageChannel has 99%+ support.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; }
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.
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:
console.log(...) 会打两次(吓到的人不计其数)、Date.now() 会跑两次、随机数生成两次值不同。这是 opt-in(但默认模板都开),关掉就丢了 Concurrent 的核心安全保证。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.useSyncExternalStore(见 Ch12B)就是为这个问题设计的——但必须外部 store 用它,混用旧 hook 仍可能撕裂。Redux v7、Mobx 旧版、自写订阅 hook 都中招过。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.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.
// 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; } }
优先级模型有个经典问题:低优先级永远等不到。想象一棵被 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.
workInProgressRoot · same-lane resume · higher-lane restart · prepareFreshStack
workInProgressRoot · same-lane resume · higher-lane restart · prepareFreshStack
前一章把"怎么让出"讲完了——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.
// 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:
// 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.
prepareFreshStack, performConcurrentWorkOnRoot ·
RWG #27 · concurrent rendering ·
Andrew Clark · React 18 keynote ·
StrictMode (the double-fire rationale)
prepareFreshStack, performConcurrentWorkOnRoot ·
RWG #27 · concurrent rendering ·
Andrew Clark · React 18 keynote ·
StrictMode (the double-fire rationale)
抛 Promise · render 中断 · fallback 兜底
throw a Promise · pause render · fallback shown
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); } } }
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.
① 用户点击导航 → 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.
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 渲染,新数据准备好后才换 </>; }
{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>.
从 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:
在服务器跑 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.
下到客户端跑。能用 useState / useEffect / 事件 / 浏览器 API。代价:占 JS bundle。
Ships to client. Can use useState / useEffect / event handlers / browser APIs. Costs JS bundle bytes.
服务器跑。能用 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>; }
服务器序列化输出的是一段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 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.
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
'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.
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".
React 18 给 SSR 注入了两个改变行业的能力:Streaming 和 Selective 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.
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.
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.
前面看了 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.
三者最容易被搞混。一句话: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 networks | renderToString() · Node 服务器 | ~85% |
| + Streaming+ Streaming | 局部慢不拖累全局slow parts don't block fast parts | renderToPipeableStream + 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 routes | RSC + bundler action manifest | ~25% |
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' 是"把这个文件下发到客户端"——其实它是"把这个文件标成客户端入口"。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".
$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.
客户端 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:
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 写法看着像本地函数, 实际上是声明式 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:
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.
"$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.
2024-26 的四件大事
the four big shipments of 2024-26
<form action={fn}> 自动管理 pending / error / 乐观更新。砍掉大半 redux 样板。<form action={fn}> auto-manages pending / error / optimistic state. Half the redux boilerplate gone.看下面这段 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.
"编译器" 听起来神秘——其实 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.
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 不是简单的"一个 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; }
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 了
| 指标 | 开 Compiler 前 | 开 Compiler 后 | 变化 |
|---|---|---|---|
| render 次数(中位数)renders (median) | baseline | -35% ~ -45% | 不必要 render 消除unnecessary renders gone |
| INP P95INP P95 | baseline | -12% | render 减少 → 输入响应更快fewer renders → faster input |
| JS bundle 大小JS bundle size | baseline | +3% ~ +8% | 每个 component 加 useMemoCache 调用 + dep 比对代码each component adds useMemoCache + dep-compare code |
| 每次 render 自身耗时per-render time | baseline | +5% ~ +10% | dep 比对开销 (vs 不 memo 直接计算)dep-compare overhead vs raw recompute |
| 总体 CPU 时间total CPU time | baseline | -25% ~ -35% | 少 render 收益远大于 dep 比对成本fewer-renders gain >> dep-compare cost |
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 不等于 return。return 是结构父——谁包含这个节点; _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".
谁包含我? JSX 上看 我的外层组件。生产环境永远存在, 是 reconcile 的核心字段。
Who contains me? My JSX-visible outer component. Exists in production, central to reconciliation.
谁的代码 创造了我? 通过 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 调用错
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 stale | Ch12 |
| 列表换 key 后 input 失去焦点 / state 全清空List re-key → inputs lose focus / state resets | key 不稳定(用了 index 或 random)Unstable keys (index / random) | Ch10 |
| useEffect 跑两次(dev 模式)useEffect runs twice in dev | StrictMode 故意 mount→cleanup→mountStrictMode does mount→cleanup→mount on purpose | Ch15 |
| DOM 已经改了但 document.title 没变DOM updated but document.title didn't | useEffect 在 paint 后才跑——下一帧才会看到useEffect runs after paint — visible next frame | Ch15 |
| 输入卡顿 / drop frameInput lag / dropped frames | 同步 render 太重,把非紧急 setState 挪到 startTransitionHeavy sync render; wrap non-urgent setState in startTransition | Ch16-18 |
| 同样 props 子组件仍 renderSame props, child still re-renders | 父传了新对象/函数引用,bail-out 失败Parent passed a new object/fn reference, bail-out missed | Ch09 |
| 巨大 list mount 卡 100ms+Big-list mount stalls 100ms+ | react-window / Suspense 边界 + Streaming + Concurrentreact-window / Suspense boundaries + streaming + concurrent | Ch17 |
| Hydration mismatch 警告Hydration mismatch warning | SSR 的 HTML 与客户端首次 render 不一致:日期/随机数/媒体查询SSR HTML diverges from client first render: dates / random / media queries | Ch20 |
| "Rendered fewer hooks than expected""Rendered fewer hooks than expected" | 在条件分支里调了 hook → 链表对不上Hook called inside conditional → list misaligns | Ch12 |
| memo 包了仍重 rendermemo wrapped but still re-renders | context 变了 → memo 兜不住 context 变化A context changed → memo doesn't shield from context | Ch09 |
① 开 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.
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.
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.
flamegraph · ranked · why did this render · owner stacks
flamegraph · ranked · why did this render · owner stacks
从这一章起进入"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.
点 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 给你的原因列表只有四种可能。背熟它,看 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 |
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)
user timings · long tasks · layout thrashing · 浏览器视角
user timings · long tasks · layout thrashing · browser view
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 之后不再需要装"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.
| 症状 | 诊断 | 修哪里 |
|---|---|---|
| Timings 里 render 段宽 + Main 里同段窄Wide render in Timings, narrow Main span | React 算太久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 Main | layout thrashing(写读循环)layout thrashing (write-read cycles) | 批量读 DOM 几何 · 一次性写Batch DOM reads · then write once |
| commit 后 Paint 很大 + 频繁Large + frequent Paint after commit | overdraw / 大图重绘overdraw / large image repaint | 用 will-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 |
一个 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.
offsetWidth / getBoundingClientRect)。浏览器被迫同步算 layout。React 里这经常是 useLayoutEffect 写完 DOM 又读它——本就是合法用法,但太频繁就会显形。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.从 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:
useState / setState / <Component /> 这些公开 API 从 2013 到 2026 几乎没变。useState / setState / <Component /> have hardly changed from 2013 to 2026.read() 接口, 跑在 cache layer 之上。社区适配缓慢——大部分库不愿改 API。React 18 提出 use() hook 后, 任何普通 Promise 都能挂到 Suspense, 旧 RFC 静默废弃。教训:协议越简单, 采用率越高。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.'use server' / 'use client' 指令字符串后,细粒度到函数级。'use server' / 'use client' string directives, giving function-level granularity.| 人物 | 关键贡献 | 2026 在哪 |
|---|---|---|
| Jordan Walke | React 创始人 · FaxJS · 2013 演示React creator · FaxJS · 2013 demo | Meta |
| Sebastian Markbåge | Hooks RFC #68 主作者 · Suspense / Server Components 总设计师Hooks RFC #68 author · Suspense / Server Components chief architect | Vercel (2022 起) |
| Andrew Clark | Fiber 独立开发 · Lanes 模型 · workLoop 重写Fiber solo dev · Lanes model · workLoop rewrite | Vercel |
| Dan Abramov | Redux 创始人 · React 团队 (2015-2024) · Server Components 推手Redux creator · React team (2015-2024) · Server Components champion | Bluesky (2024 起) |
| Lauren Tan | RSC 共同作者 · React Native 团队RSC co-author · React Native team | Meta |
| Joe Savona | React Compiler 主导 · Relay 创始人之一React Compiler lead · Relay co-founder | Meta |
| Sophie Alpert | React engineering manager (2017-2018) · Pyret 语言设计React engineering manager (2017-2018) · Pyret language | Notion |
| Brian Vaughn | React DevTools 主作者 · Profiler / Owner StacksReact DevTools lead · Profiler / Owner Stacks | 独立顾问independent |
| Rick Hanlon | React 19 release manager · CRA 终结者React 19 release manager · the one who killed CRA | Meta |
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 | "DOM" 是什么 | commit 模式 | 状态 |
|---|---|---|---|
| react-dom | 浏览器 HTMLElement 树browser HTMLElement tree | supportsMutation | 官方 |
| react-native (Fabric) | 原生 iOS/Android view tree (C++ ShadowTree)native iOS/Android view tree (C++ ShadowTree) | supportsPersistence | 官方 |
| react-dom/server | 字符串 / 流string / stream | — | 官方 |
| react-three-fiber | Three.js Scene graph (WebGL)Three.js Scene graph (WebGL) | supportsMutation | 社区 · 主流community · widely adopted |
| ink | 终端 ANSI 字符缓冲terminal ANSI buffer | supportsMutation | vercel/ink · GitHub CLI 在用vercel/ink · powers GitHub CLI |
| react-pdf | PDF 操作符流PDF operator stream | supportsPersistence | 独立项目standalone project |
| react-native-skia | Skia canvas | supportsMutation | Shopify 主推 |
| react-blessed | 基于 blessed 的 TUIblessed-based TUI | supportsMutation | 较小生态smaller ecosystem |
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:
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.
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: ..., };
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 把终端字符缓冲当成 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 +) │ // └────────────────────────────────┘
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.
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.
| framework | bundle | 协调 | 响应性 | SSR | 心智 |
|---|---|---|---|---|---|
| React 19 | ~80 KB | Fiber · lastPlacedIndex | 显式 setState · 整组件重跑 | Streaming + Selective Hydration + RSC | 组件 = 纯函数 (但会跑多次) |
| Preact 10 | ~30 KB | Stack · 同 React 15 | 同 React 但无 concurrent | 同 SSR · 无 Streaming | 同 React |
| Vue 3 | ~55 KB | vDOM diff · LIS 最优 | Proxy-based · 自动追踪 | 独立 SSR · 含 Streaming | 组件 = setup() 跑一次 + reactive 表达式自动重跑 |
| Solid 1.x | ~7 KB | 无 vDOM | fine-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 hydration | signals 风格 | Resumability · 整页 0 JS, 用 click 才 fetch handler | 组件 = 序列化到 HTML, 再"恢复" |
| Astro 4 | 默认 0 KB / island 按需 | — | — | Islands · 整页静态 + 局部 React/Vue/Solid | 页面 = HTML + 多个独立小岛 |
{count} 直接编译成 text.data = count——0 运行时差异。React 要在运行时跑 diff 才能知道"这个 textNode 的值是 count"。Svelte 的代价: 每个组件都是独立的编译产物, 体积按组件数累加; React 的代价: 运行时永远有 ~80 KB 基础开销, 但更多组件几乎不增加 bundle。大型应用是 React 占优。{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.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 一切。最值得对比看的是 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).
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:
| question | React 19 | Vue 3 | Solid 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?) | 中medium | 低low |
| 中断 / Concurrentinterruptible | 有yes | 2024 起实验性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.
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.
| # | package | 大致 LoC | 入口文件 | 读多久 |
|---|---|---|---|---|
| 1 | react/ | ~2K | src/ReactElement.js | 1 天 |
| 2 | react-reconciler/ | ~30K ★ | src/ReactFiberReconciler.js → ReactFiberWorkLoop.js | 2 周 |
| 3 | scheduler/ | ~1.5K | src/forks/Scheduler.js | 2 天 |
| 4 | react-dom/ | ~15K | src/client/ReactDOMRoot.js | 3 天 |
| 5 | react-dom/server | ~8K | src/server/ReactDOMFizzServer.js | 2 天 |
| 6 | react-server/ | ~6K | src/ReactFlightServer.js | 3 天 |
| 7 | react-server-dom-webpack/ | ~4K | src/server/ReactFlightDOMServerNode.js | 2 天 |
| file | 负责 | 本文哪章 |
|---|---|---|
| ReactFiber.js | Fiber 节点定义 + createFiber() | Ch07 |
| ReactFiberWorkLoop.js | workLoop · performUnitOfWork · 主循环 | Ch09 |
| ReactFiberBeginWork.js | beginWork · 30+ switch case | Ch09 |
| ReactFiberCompleteWork.js | completeWork · subtreeFlags bubble | Ch11 |
| ReactChildFiber.js | reconcileChildren · keyed diff · lastPlacedIndex | Ch10 |
| ReactFiberHooks.js | 所有 hooks 的实现 | Ch12 / Ch12B |
| ReactFiberLane.js | Lanes 位掩码 · entanglement | Ch16 / Ch17 |
| ReactFiberFlags.js | 32 位 flag 定义 | Ch11 |
| ReactFiberCommitWork.js | commit 三阶段总入口 | Ch13–Ch15 |
| ReactFiberThrow.js | Suspense + Error Boundary 派发 | Ch18 / Ch15B |
| ReactFiberConcurrentUpdates.js | setState 入队 · 优先级提升 | Ch12 / Ch17 |
| ReactFiberClassComponent.js | class 组件全套生命周期 | Ch13 / Ch15B |
| ReactFiberHostConfig.js | HostConfig 接口契约 (注释为主) | Ch04 / Ch26 |
react/src/ReactElement.js(200 行)+ react-reconciler/src/ReactFiber.js 字段定义(300 行)+ ReactFiberWorkLoop.js 主循环骨架(仅看 workLoopSync 和 performUnitOfWork,不展开 beginWork)。目标:能画出一棵 Fiber 树并解释字段。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.ReactFiberBeginWork.js 的 updateFunctionComponent + updateHostComponent 分支。然后读 ReactChildFiber.js(diff 算法)。最后看 ReactFiberHooks.js 里的 useState / useEffect。目标:能跑通"一次 setState → 一次 render"的整条调用链。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.ReactFiberCompleteWork.js(创建 DOM 离屏)+ ReactFiberCommitWork.js(三子阶段)+ react-dom 那一侧的 DOMSetTextContent / setValueForAttributes。目标:能解释 root.current swap 的精确时序。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.ReactFiberLane.js 完整 Lane 模型 + scheduler/src/Scheduler.js 的 MessageChannel + 小顶堆。最后挑战:读 ReactFiberThrow.js 的 Suspense + ErrorBoundary 分发。目标:能修一个 scheduler 相关的 React bug。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.| 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 dom | React 用 Flow 而非 TypeScript · 检查类型React uses Flow not TypeScript · type check |
| react-illustration-series | 第三方源码逐行注解(中文)third-party line-by-line annotations (Chinese) |
| overreacted.io | Dan 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 |
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.
读 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 |
__DEV__ 包裹的代码不是注释——是 dev-only 真实逻辑__DEV__ blocks aren't comments — they're real dev-only logicif (__DEV__) {...} 包大量调试代码 (warn / freeze / double-invoke / owner tracking). 这些块生产构建被 dead-code 消掉, 但读源码时必须读——很多 dev/prod 不一致 bug 的解释都在这里。常见误读: "这段不影响生产, 跳过" → 错过 StrictMode 双 render 的来源。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.ReactFiberBeginWork.old.js + ReactFiberBeginWork.new.js 共存——old 是即将废弃的版本, new 是当前默认。读 .old 等于读历史; 读 .new 才是读现状。有 .new 就别读 .old。19.0 之后大部分 fork 已合并, 但 RSC 相关文件仍有 .old/.new 分支。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.__ 开头的是内部 — 不是公开 API__ are internal — not public API__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED 这种命名别笑——React 团队真的就用这个命名风格表达 "用了别哭"。这些是不保证向前兼容的内部接口, 三方库 (react-redux / react-router 等) 偶尔用, 但你自己代码里出现就是 reading-traps。__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.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 注释是临时绕过, 不是设计// $FlowFixMe comments are temporary workarounds, not design$FlowFixMe 别以为是有意为之——大概率是某次重构遗留, 后人不敢删。这些位置往往真有 type bug 但目前 hack 工作。读到时心理建立"这里有未完成工作"的预期, 别当成"这就是设计"。$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".这条目录是写给 6 个月之后忘了一半的你
written for the you that's forgotten half of this in six months
| term | 含义meaning |
|---|---|
| Element | JSX 编译后的普通 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 |
| alternate | fiber 字段 · 指向双缓冲里的另一棵fiber field · points to the other tree in the double buffer |
| return | fiber 字段 · 完成后回到哪里(结构上的父)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 |
| Reconciliation | child element 和 child fiber 配对 · O(n) 三规则 diffmatch child elements with child fibers · O(n) 3-rule diff |
| flags | fiber 上的 32-bit 位掩码 · 标记 commit 要做的事32-bit bitmask on fiber · marks what commit must do |
| subtreeFlags | flags 上卷的版本 · commit 阶段剪枝用bubbled-up version of flags · used for commit pruning |
| Lanes | 31 位优先级模型 · 位运算合并/查询/移除31-bit priority model · bitwise merge/query/strip |
| SyncLane | 最高优先级 · 用户输入 / discrete eventhighest priority · user input / discrete events |
| TransitionLane | startTransition 用 · 16 条 · 可中断可丢弃used by startTransition · 16 lanes · interruptible |
| Scheduler | 独立包 · MessageChannel + 小顶堆 · 时间切片器standalone package · MessageChannel + min-heap time-slicer |
| shouldYield | workLoop 每个 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 effect | useEffect · paint 后异步跑useEffect · async, after paint |
| HostConfig | reconciler 与 renderer 之间的接口约定contract between reconciler and renderer |
| Suspense | render 中断的边界 · 接住 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 |
| Actions | React 19 · <form action={fn}> · pending / optimistic 内置React 19 · <form action={fn}> · pending / optimistic built in |
| React Compiler | babel 插件 · 自动生成 memo 代码 · opt-inbabel plugin · auto-generates memo code · opt-in |
| StrictMode | dev 模式 · 故意 double-invoke 来曝光 bugdev-only · double-invokes to surface bugs |
| Hydration | 把 SSR HTML 跟客户端 Fiber 树绑起来binding SSR HTML to a client Fiber tree |
| $$typeof | Element 上的 Symbol · 防 XSS 伪造Symbol on Element · prevents XSS forgery |
| dispatcher | 挂在 ReactCurrentDispatcher.current · hook 实现的指针on ReactCurrentDispatcher.current · pointer to hook impls |
| Activity | React 19 新元素 · 可隐藏但保留 state 的子树React 19 element · subtree that's hidden but state-preserved |
| Error Boundary | class 组件 · 实现 gDSFE/cDC · 接住子树 render 抛错class component · implements gDSFE/cDC · catches descendant render throws |
| Portal | fiber.tag=4 · 跨 container commit · React tree ≠ DOM treefiber.tag=4 · cross-container commit · React tree ≠ DOM tree |
| useReducer | useState 的"大哥" · 接显式 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 |
| useInsertionEffect | mutation 之前跑 · 给 CSS-in-JS 库专用runs before mutation · CSS-in-JS exclusive |
| useFormStatus | 读最近父 <form> 的 pending · 隐式 contextreads nearest parent <form>'s pending · implicit context |
| useActionState | Server 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 |
| lastPlacedIndex | keyed 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 |
| HostConfig | reconciler ↔ renderer 的合同 · ~30 个方法contract between reconciler and renderer · ~30 methods |
| Fabric | React Native 新架构 · C++ JSI · supportsPersistenceReact Native's new architecture · C++ JSI · supportsPersistence |
| Owner Stack | fiber._debugOwner 链 · 渲染父 ≠ 结构父 · React 19 error 中显示fiber._debugOwner chain · render parent ≠ structural parent · shown in R19 errors |
| React Compiler | babel plugin · 静态分析 + Memo Map · 自动注入 useMemoCachebabel plugin · static analysis + Memo Map · auto-injects useMemoCache |
| useMemoCache | Compiler 专用原语 · 挂 fiber 的稳定数组 · 替代多个 useMemoCompiler-only primitive · stable array on fiber · replaces many useMemos |
| JSI | JavaScript Interface · Fabric 用 · 替代旧 BridgeJavaScript Interface · used by Fabric · replaces old Bridge |
| FaxJS | 2011 · React 的前身 · Jordan Walke 内部 hack2011 · React's predecessor · Jordan Walke's internal hack |
| A | B | 差别 |
|---|---|---|
| Element | Fiber | Element 是 props 描述 (一次性, 不变); Fiber 是组件实例 (持久, 含 state) — 见 Ch06/07Element = props description (throwaway, immutable); Fiber = component instance (persistent, holds state) — Ch06/07 |
| vDOM | Fiber tree | vDOM 是表示 (用 Element 对象表达 UI); Fiber tree 是实体(在内存里的持久结构) — Ch07vDOM is representation (Elements describing UI); Fiber tree is the entity (persistent memory structure) — Ch07 |
| render phase | render function | render 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.return | fiber._debugOwner | return 是结构父(被谁包含); _debugOwner 是渲染父(谁创建的) — 见 Ch21return = structural parent (who contains it); _debugOwner = render parent (who created it) — Ch21 |
| Suspense | ErrorBoundary | 同一套 throw 机制——前者接住 Promise, 后者接住 Error — Ch15B/Ch18Same throw mechanism — Suspense catches Promises, ErrorBoundary catches Errors — Ch15B/Ch18 |
| useEffect | useLayoutEffect | 前者 paint 后异步跑, 后者 mutation 后同步跑 (阻塞 paint) — Ch15useEffect: async, after paint. useLayoutEffect: sync, after mutation, blocks paint — Ch15 |
| useLayoutEffect | useInsertionEffect | 两者都同步, 但 useInsertionEffect 更早——在 mutation 之前跑, 给 CSS-in-JS 用 — Ch12BBoth sync, but useInsertionEffect runs earlier — before mutation, for CSS-in-JS — Ch12B |
| batching | scheduling | batching: 多次 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 |
| SyncLane | SyncBatched | SyncLane 是 lane 类型 (优先级位); SyncBatched 是历史用语 (React 17 之前的同步批量边界) — Ch16/Ch15CSyncLane = lane type (priority bit); SyncBatched = legacy term (pre-React-17 sync batch boundary) — Ch16/Ch15C |
| hydration | resumability | hydration: 客户端重跑组件函数挂上 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 |
| RSC | SSR | RSC 输出 wire format 序列化 Element; SSR 输出 HTML 字符串 — 两者可叠加 — Ch20RSC outputs wire-format serialized Elements; SSR outputs HTML string — they stack — Ch20 |
| React.memo | useMemo | 前者包组件: 父 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 |
读完文章想直接跳到源头? 这是设计决策的第一手记录:
Want to go to the source after this article? Here's the first-hand record of design decisions:
| 主题 | RFC / 链接 | 对应章节 |
|---|---|---|
| Hooks RFCHooks RFC | reactjs/rfcs#68 (2018-10-26 · sebmarkbage) | Ch12 / Ch12B |
| Concurrent Mode RFCConcurrent Mode RFC | reactjs/rfcs#150 (2019-12-12) | Ch16 / Ch17 |
| Server Components RFCServer Components RFC | reactjs/rfcs#188 (2020-12-21 · gaearon + sebmarkbage + laurentan) | Ch20 |
| Automatic BatchingAutomatic Batching | reactwg/react-18#21 (2021) | Ch15C |
| Suspense for Data FetchingSuspense for Data Fetching | reactjs/rfcs#213 (2022) | Ch18 |
| Server Functions / ActionsServer Functions / Actions | reactjs/rfcs#229 (2023) | Ch20 / Ch12B |
| React Compiler · 公开博客React Compiler · public blog | react.dev 2024-02 | Ch21 |
| Fiber 原始设计文档Fiber design doc | acdlite/react-fiber-architecture (2016) | Ch03 / Ch25 |
| React 18 working groupReact 18 working group | reactwg/react-18 (2021-2022 全程公开讨论) | Ch16-Ch18 |
| React 19 working groupReact 19 working group | reactwg/react-19 | Ch12B / Ch21 |
"我们换了引擎,但你不需要换驾照。" "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 (本文末)
官方文档 · 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.
use(promise) 设计文档,Ch18/21。React 19 use(promise) design doc, Ch18/21.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.workLoopSync / performUnitOfWork。Ch09/11/16 引用。Line-by-line walk-through of workLoopSync / performUnitOfWork. Cited in Ch09/11/16.React.memo / useMemo 的真实代价。Ch10/21 footnote。The real cost of React.memo / useMemo. Footnote in Ch10/21.use、Actions、ref-as-prop 等。Ch21 全部基于此。Official release note. use, Actions, ref-as-prop. Foundation of Ch21.babel-plugin-react-compiler。Ch21。React Compiler source, babel-plugin-react-compiler. Ch21.import React。Ch06。Why React 17+ no longer needs import React. Ch06.scheduler.postTask;React 18 之前的 polyfill 思路。Ch16。Browser scheduler.postTask; the model React 18 polyfills. Ch16.MessageChannel。Ch16/17。React 16's initial time-slicing API; later replaced by MessageChannel. Ch16/17.port.postMessage 触发宏任务。Ch16。How React 17+ Scheduler triggers macrotasks via port.postMessage. Ch16.dangerouslySetInnerHTML 边界的安全防线。Built-in support in React 19. Safety boundary at dangerouslySetInnerHTML.createFiber / createWorkInProgress。Ch07/08。createFiber / createWorkInProgress. Ch07/08.workLoopSync / workLoopConcurrent / performUnitOfWork。Ch09/11/16/17。workLoopSync / workLoopConcurrent / performUnitOfWork. Ch09/11/16/17.reconcileChildrenArray, lastPlacedIndex)。Ch10 keyed-list diff (reconcileChildrenArray, lastPlacedIndex).HooksDispatcherOnMount / HooksDispatcherOnUpdate / ContextOnlyDispatcher。Ch12 dispatcher switch: HooksDispatcherOnMount / HooksDispatcherOnUpdate / ContextOnlyDispatcher.commitBeforeMutationEffects / commitMutationEffects / commitLayoutEffects。Ch13/14/15 three commit phases.本节共 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.