React 19.3.1 源码深度剖析-第 1 章 总览与架构哲学

React 19.3.1 源码深度剖析-第 1 章 总览与架构哲学

本章是整套系列的地基。它要回答的不是"React 怎么用",而是四个更底层的问题: React 的代码为什么被切成这个样子?一次点击之后,数据在源码里究竟走了哪些地方? 并发渲染到底在并发什么?以及 React 19 相比 18,架构层面真正变了什么?


1.1 这本系列要解决什么问题

大多数开发者对 React 的理解,停留在"写组件、调 Hook、传 props"这一层。这层知识足以应付日常业务,但只要遇到下面这几类问题,就会立刻不够用:

  1. 性能问题 :组件明明没变却疯狂重渲染;一次输入卡顿;列表一长就掉帧。要定位它,你必须知道 updateReducerImpl 是怎么按 Lane 过滤更新的、bubbleProperties 是怎么把 childLanes 往上冒泡的、useMemo 的依赖比较到底比较的是什么。
  2. 诡异 bug :useEffect 清理函数执行时机不对;ref 拿到旧值;key 用了数组下标导致输入框内容串味;严格模式下 effect 跑了两遍。这些现象的根因,全部埋在 reconciler 的链表与双缓冲实现里。
  3. 升级困惑 :React 19 把 forwardRef 变成可选项、把 defaultProps 从函数组件上移除、引入了 use 与 useActionState、把编译器作为正式能力推出。这些不是"新 API",而是架构层面的转向。
  4. 知其然而不知其所以然 :知道"用 key 能让列表更新更高效",但说不清 mapRemainingChildren 为什么用 key 建 Map;知道"并发模式能让输入不卡",但说不清 shouldYieldToHost 每 5ms 让出一次主线程到底发生在哪个函数里。

这套系列的定位就是填补这四类空白。它的方法论很直接:打开真实源码,一行一行读,把每个关键函数、每个字段、每条分支都讲清楚,然后指出它与你日常写代码时的因果关系。

1.1.1 本系列的一条主线

如果你只想从这套系列里带走一句话,请带走这句:

React 的本质,是"用纯函数描述 UI,再用一套可中断的 diff 算法,把描述变成对宿主环境的最小操作集合"。

这句话里每一个词,都对应源码里一片具体的代码:

这句话里的词 对应的源码位置 讲解章节
纯函数描述 UI packages/react/src/ReactElement.js 的 createElement / jsx 第 02 章
可中断 packages/scheduler/src/forks/Scheduler.js 的 shouldYieldToHost 第 05 章
diff 算法 packages/react-reconciler/src/ReactChildFiber.js 第 04 章
宿主环境的最小操作 packages/react-dom-bindings/src/client/ReactDOMComponent.js 的 setProp 第 06 章
把它们串起来的调度 packages/react-reconciler/src/ReactFiberWorkLoop.js 第 03、06 章

本系列所有章节,都是这句话的展开。


1.2 系列地图

全套系列共 15 章,分为五个层次。层次之间有严格的依赖关系------这符合源码本身的依赖关系,因为 ReactFiberWorkLoop.js 确实 import 了 ReactChildFiber.js 导出的东西。

flowchart TD subgraph L1["第一层:地基"] C1[第01章 总览与架构哲学] C2[第02章 React元素与JSX] end subgraph L2["第二层:核心引擎"] C3[第03章 Fiber架构与双缓冲] C4[第04章 Reconciliation协调算法] end subgraph L3["第三层:调度与落地"] C5[第05章 Scheduler调度器] C6[第06章 ReactDOM渲染器与Commit] end subgraph L4["第四层:编程模型"] C7[第07章 Hooks原理全解] C8[第08章 并发特性] C9[第09章 Suspense与懒加载] C10[第10章 事件系统] end subgraph L5["第五层:扩展与新能力"] C11[第11章 React Compiler] C12[第12章 SSR与RSC] C13[第13章 React19新特性] C14[第14章 性能优化与调试] C15[第15章 实战与反模式] end C1 --> C2 C2 --> C3 C3 --> C4 C3 --> C5 C4 --> C6 C3 --> C6 C5 --> C8 C6 --> C7 C7 --> C13 C7 --> C11 C8 --> C9 C9 --> C12 C6 --> C10 C11 --> C14 C7 --> C14 C14 --> C15

四条推荐阅读路径:

  • 只想彻底搞懂渲染流程(最推荐,覆盖面最广):第 1 → 2 → 3 → 4 → 6 章。
  • 只想搞懂并发与性能:第 3 → 5 → 8 → 9 → 14 章。
  • 只想搞懂 Hooks:第 3 → 7 → 13 章。
  • 面试/知识体系补全:按顺序读完 1--10 章,再按兴趣挑 11--15。

1.3 如何阅读这份源码

在读任何章节之前,先花十分钟了解这份仓库的物理结构。这能省掉你后面大量的"这个文件在哪"的困惑。

1.3.1 目录结构总览

仓库根目录下有几个一级目录,职责泾渭分明:

text 复制代码
react-19.3.1/
├── packages/          # 所有 npm 包的源码,React 的核心全部在这里
├── compiler/          # React Compiler(Rust 重写 + TS 参考实现)
├── fixtures/          # 端到端 fixture 与测试应用
├── scripts/           # 构建、发布、测试、代码检查等工程脚本
├── flow-typed/        # Flow 的第三方库类型声明
├── babel.config*.js   # Babel 配置(构建/编译用)
├── ReactVersions.js   # 版本号唯一真相源
└── package.json       # yarn workspace 根配置

packages/ 下共 38 个目录(这是 React 19.3.1 的真实清单,第 1.6.7 节会逐一讲解):

text 复制代码
react  react-dom  react-dom-bindings  react-reconciler  scheduler
react-server  react-server-dom-webpack  react-server-dom-turbopack
react-server-dom-parcel  react-server-dom-esm  react-server-dom-unbundled
react-server-dom-fb  react-client  react-cache  react-is  react-art
react-noop-renderer  react-native-renderer  react-test-renderer
react-refresh  react-debug-tools  react-markup  react-flight-server-fb
react-devtools*(11 个)  eslint-plugin-react-hooks  jest-react
use-subscription  use-sync-external-store  shared  internal-test-utils
dom-event-testing-library  react-suspense-test-utils

1.3.2 Flow 与 TypeScript 的混用现状

这是新手读 React 源码最容易困惑的一点:同一个仓库里,reconciler 用 Flow,compiler 用 TypeScript,Scheduler 用 Flow。

具体分布:

区域 类型系统 说明
packages/react/src/ Flow 文件头有 @flow 注解
packages/react-reconciler/src/ Flow 全系列讲解的重点区域
packages/scheduler/src/ Flow 调度器
packages/react-dom-bindings/src/ Flow HostConfig
compiler/crates/ Rust 编译器核心
compiler/packages/babel-plugin-react-compiler/ TypeScript 编译器的 TS 参考实现

读 Flow 代码时只需要掌握三件事,就够用了:

  1. 类型注解写法 :function f(x: number): string ------和 TypeScript 几乎一样。
  2. opaque type :不透明类型,对外只暴露名字不暴露结构。比如 opaque type Lanes = number,意思是"Lanes 是个数字,但你不能把它当普通数字随便算"。
  3. mixed :等价于 TypeScript 的 unknown。

packages/react-reconciler/src/ReactInternalTypes.js 是整套 reconciler 的类型中枢,Fiber、FiberRoot、UpdateQueue 等核心类型都定义在那里。第 03 章会大量引用它。

1.3.3 packages/*/src 与 build/ 的映射

源码目录里的文件,不是最终发布到 npm 的文件。构建流程大致是:

flowchart LR A[&#34;packages/*/src/*.js<br/>(Flow/TS 源码)&#34;] -->|&#34;strip flow types<br/>+ 编译期替换&#34;| B[&#34;build/facebook-react/<br/>npm 包结构&#34;] B -->|&#34;rollup bundle<br/>+ closure compiler&#34;| C[&#34;dist/<br/>生产/开发构建产物&#34;]

关键点:

  • yarn build 走 scripts/rollup/build-all-release-channels.js,会产出 dist/ 下的多个构建变体(开发版带完整报错信息,生产版剥离)。
  • 很多变量是编译期常量,不是运行时变量 。例如 __DEV__、enableOptimisticKey、enableProfilerTimer 在构建时被替换成字面量 true/false。这意味着你读源码时看到 if (__DEV__) 分支,在生产构建里根本不存在------不要把它算进运行时开销。
  • 这个机制也解释了为什么源码里充斥着各种 enableXXX 开关:它们是功能开关,默认值决定这个功能是否进入正式发布。判断一个功能是否可用,要看它的 flag 默认值。

1.3.4 编译开关(feature flags)

React 有一整套 flag 系统,定义在 packages/shared/ReactFeatureFlags.js 以及各包自己的 *FeatureFlags.js。读源码时你会频繁遇到它们。几个高频的:

Flag 含义 阅读建议
__DEV__ 开发模式 忽略,不影响理解逻辑
enableOptimisticKey 乐观 key 优化 看到时理解为"对 key 的一种特殊兜底"
enableProfilerTimer Profiler 计时 只在讲性能时重要
enableLazy / enableCache lazy() / cache() React 19 已默认启用
enableViewTransition View Transition React 19 新能力

原则:首次阅读时,把所有 enableXXX 都当作"这一版的默认行为"来读,不要陷进去分析每个 flag 的组合。

1.3.5 入口文件速查表

后面所有章节都会反复引用这几个文件,先建立索引:

想找什么 打开哪个文件
React 对外暴露了什么 API packages/react/src/ReactClient.js
元素怎么创建 packages/react/src/ReactElement.js
JSX 编译产物长什么样 packages/react/src/jsx/ReactJSX.js
Hook 的实现 packages/react-reconciler/src/ReactFiberHooks.js
渲染主循环 packages/react-reconciler/src/ReactFiberWorkLoop.js
子节点 diff packages/react-reconciler/src/ReactChildFiber.js
优先级 Lane packages/react-reconciler/src/ReactFiberLane.js
副作用 Flags packages/react-reconciler/src/ReactFiberFlags.js
commit 阶段 packages/react-reconciler/src/ReactFiberCommitWork.js
DOM 操作 packages/react-dom-bindings/src/client/ReactDOMComponent.js
事件系统 packages/react-dom-bindings/src/events/DOMPluginEventSystem.js
调度器 packages/scheduler/src/forks/Scheduler.js

1.3.6 高效阅读方法论:顺序、工具与验证

有了地图之后,下一个问题是"从哪下手"。这一节给出一条经过验证的阅读路线、一套定位技巧,以及用测试验证理解 的方法。全部命令都基于本仓库真实存在的脚本(见 package.json 的 scripts 字段)。

三条阅读路线

React 源码有 5000 行以上的大文件十多个(ReactFiberWorkLoop.js 5683 行、ReactFiberHooks.js 5265 行、ReactFiberCommitWork.js 5618 行、ReactFiberConfigDOM.js 7027 行),从头顺读必然迷路。必须按"问题"切入:

路线 目标 阅读顺序 适合谁
A:渲染流程 搞懂一次更新怎么变成 DOM ReactDOMRoot.js → ReactFiberWorkLoop.js 的 performWorkOnRoot → ReactFiberBeginWork.js → ReactChildFiber.js 的 reconcileChildrenArray → ReactFiberCompleteWork.js → ReactFiberCommitWork.js 大多数人,最推荐
B:Hooks 搞懂 useState 到底存在哪 react/src/ReactHooks.js(薄壳)→ react-reconciler/src/ReactFiberHooks.js 的 renderWithHooks → mountState → dispatchSetState 只想搞懂 Hooks 的人
C:事件 搞懂点击怎么找到组件 ReactDOMRoot.js 的 listenToAllSupportedEvents → DOMPluginEventSystem.js → plugins/SimpleEventPlugin.js → SyntheticEvent.js 被事件坑过的人

三条路线有一个共同的起点:packages/react-dom/src/client/ReactDOMRoot.js(380 行,最小而完整的入口)。

必读的 12 个文件

如果只读 12 个文件,读这些(行数为 wc -l 实测值):

文件 行数 为什么必读
packages/react-dom/src/client/ReactDOMRoot.js 380 一切客户端渲染的入口:createRoot / hydrateRoot / flushSync 的调用都收敛到这里
packages/react-reconciler/src/ReactFiberWorkLoop.js 5683 渲染主循环 + commit 编排。performWorkOnRoot(:1137)、performUnitOfWork(:3081)、commitRoot(:3725) 全在这里
packages/react-reconciler/src/ReactFiber.js 965 createFiberFromXxx 工厂函数与 FiberNode 构造,第 03 章主角
packages/react-reconciler/src/ReactFiberBeginWork.js 4514 "向下协调"的全部逻辑:beginWork 大分发
packages/react-reconciler/src/ReactChildFiber.js 2259 diff 算法本体:reconcileChildFibers / reconcileChildrenArray / mapRemainingChildren
packages/react-reconciler/src/ReactFiberCompleteWork.js 2114 "向上完成":HostComponent 创建实例、bubbleProperties 冒泡
packages/react-reconciler/src/ReactFiberHooks.js 5265 所有 Hook 的真实实现(注意不是 react/src/ReactHooks.js)
packages/react-reconciler/src/ReactFiberLane.js 1308 Lane 位运算全集:getNextLanes、markRootUpdated、getHighestPriorityLane
packages/react-reconciler/src/ReactFiberCommitWork.js 5618 commit 三阶段真正干活的函数:commitBeforeMutationEffects / commitMutationEffects / commitLayoutEffects
packages/react-reconciler/src/ReactFiberConcurrentUpdates.js 288 理解"并发更新如何排队与合并"的最小文件
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js 7027 DOM 平台 HostConfig 全集,第 06 章主角
packages/scheduler/src/forks/Scheduler.js 613 整个调度器只有 613 行,第 05 章主角,性价比最高

不建议一开始就读的 :ReactFiberThrow.js(错误处理 unwind,需要先懂 beginWork)、ReactFiberSuspenseComponent.js(依赖 thenable 与 offscreen)、react-server/ 下的 Fizz 与 Flight(6000~7000 行的大文件,等第 12 章再读)、react-devtools-*(与渲染链路无关的独立应用)。

grep 定位四法

读这份源码,90% 的时间花在"找"。四个高频模式:

  1. 从调用反查定义 。想知道 scheduleUpdateOnFiber 在哪定义:全局搜 export function scheduleUpdateOnFiber,命中 ReactFiberWorkLoop.js:987。React 的源码组织很规整------导出函数几乎都是 export function 开头,极少用对象包装。
  2. 查一个 flag 的定义点与全部使用点 。比如 enableViewTransition:先搜 export const enableViewTransition,命中 packages/shared/ReactFeatureFlags.js:90(值为 true,即 19.3.1 默认开启);再搜 enableViewTransition 全库,能看到 react/src/ReactStartTransition.js、ReactFiberCommitViewTransitions.js 等所有消费点。判断一个功能是否"可用",就看它 flag 的布尔值。
  3. 查 HostConfig 的消费者 。搜 from './ReactFiberConfig',命中 reconciler 里十几个文件------这就是"平台无关引擎"的准确边界:凡是 import 这个模块的文件,都在和平台打交道。
  4. 查测试行为 。搜 ReactNoop.render(<App />),能直接找到 reconciler 的测试文件;React 的测试写得很可读,常常比注释更快告诉你"这段代码在什么场景下被触发"。

一个实用细节:先搜 __tests__ 再搜源码 。比如想搞懂 useDeferredValue,直接打开 packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js,比读 5265 行的 ReactFiberHooks.js 快十倍。

用单测验证你的理解

这是本系列最推荐的方法:每读完一个函数,就去找它的测试,改一行源码,看测试变红 。命令都真实可用(来自 package.json 的 scripts):

bash 复制代码
# 跑 reconciler 的 Hooks 测试(jest-cli 会把额外参数透传给 jest)
yarn test packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js

# 只跑匹配的用例
yarn test ReactHooksWithNoopRenderer -t "useState"

# 切到 stable channel 跑同一批测试------很多 flag 默认值会变
yarn test-stable packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js

# Facebook 内部的两个 www 变体
yarn test-www      # --release-channel=www-modern
yarn test-classic  # --release-channel=www-classic

# 类型检查(Flow)与 lint
yarn flow
yarn lint

为什么 --release-channel 重要?因为 scripts/jest/jest-cli.js:338 会根据 channel 设置环境变量:

js 复制代码
// scripts/jest/jest-cli.js:336-340(真实源码)
const envars = {
  NODE_ENV: argv.env || 'development',
  RELEASE_CHANNEL: argv.releaseChannel.match(/modern|experimental/)
    ? 'experimental'
    : 'stable',
  ...
};

而 scripts/jest/setupEnvironment.js:16-20 再把它变成全局常量:

js 复制代码
// scripts/jest/setupEnvironment.js:16-20(真实源码)
global.__EXPERIMENTAL__ =
  typeof RELEASE_CHANNEL === 'string'
    ? RELEASE_CHANNEL === 'experimental'
    : true;

__EXPERIMENTAL__ 正是 packages/shared/ReactFeatureFlags.js 里几十个 flag 的取值来源------比如 enableViewTransitionParentEnterExit = __EXPERIMENTAL__(:92)、enableOptimisticKey = __EXPERIMENTAL__(:110)。同一份测试代码,在 experimental 与 stable 两个 channel 下走的是不同分支,这是 React 团队在正式发布前验证破坏性改动的核心机制。

fixtures:端到端的观察窗

单元测试之外,fixtures/ 下有 24 个可运行的端到端 fixture 应用,是"看行为"而不是"读代码"的窗口:

Fixture 看什么
fixtures/dom 最完整的 DOM 行为 fixture,yarn test-dom-fixture 一键跑
fixtures/concurrent 并发特性(startTransition、useDeferredValue)的可见效果
fixtures/suspense 类(fixtures/ssr、fixtures/fizz) SSR 流式渲染与选择性注水的实际输出
fixtures/flight RSC 的 Flight 序列化在浏览器里的真实格式
fixtures/art Canvas 渲染器的完整示例(理解"渲染器抽象"的最佳活样本)
fixtures/packaging 用 webpack / browserify / requirejs / systemjs 等 10 种方式打包 React,验证产物兼容性

跑法(package.json 原样):cd fixtures/dom && yarn && yarn test。

1.3.7 测试体系:React 如何测试自己

React 的测试体系本身就是架构的镜子------它把"渲染器可替换"这条设计原则又实现了一遍。理解它,一举两得:既学会验证理解,又反过来印证 HostConfig 机制。

入口:scripts/jest/jest-cli.js

yarn test 实际执行 node ./scripts/jest/jest-cli.js(package.json 的 test 脚本)。这个 CLI 是 jest 的一层封装,核心能力是按 release channel 选择 jest 配置 。它的 --release-channel 参数有五个取值(scripts/jest/jest-cli.js:44-51):

js 复制代码
releaseChannel: {
  alias: 'r',
  describe: 'Run with the given release channel.',
  requiresArg: true,
  type: 'string',
  default: 'experimental',
  choices: ['experimental', 'stable', 'www-classic', 'www-modern', 'xplat'],
},

五个 channel 对应五套配置(scripts/jest/jest-cli.js:11-19):

Channel 配置文件 测的是什么
experimental(默认) config.source.js OSS 实验特性全开
stable config.source.js + RELEASE_CHANNEL=stable 正式版 flag 组合
www-modern config.source-www.js Facebook 内部 modern 构建
www-classic config.source-www.js + classic Facebook 内部 classic 构建
xplat config.source-xplat.js 跨平台(RN/FB)变体

注意 experimental 是默认值------默认跑的是"下一个大版本"的代码,这与 1.4.2 节的三条发布渠道一一呼应。

三种测试宿主环境

同一个 reconciler,测试跑在三种环境里:

  1. jsdom (scripts/jest/ReactJSDOMEnvironment.js):react-dom 的行为测试,147 个测试文件。
  2. node :文件头标注 @jest-environment node,用于无 DOM 的纯逻辑测试,比如 ReactHooksWithNoopRenderer-test.js。
  3. react-noop-renderer:reconciler 的主战场,77 个测试文件。它不产生任何 DOM,只把渲染结果记在内存里供断言。
react-noop-renderer:渲染器抽象的"零号病人"

packages/react-noop-renderer/src/ 只有 13 个文件,却把 HostConfig 的每个维度都做了一个最小实现:

文件 角色
ReactFiberConfigNoop.js 最小 HostConfig:实例就是一个普通 JS 对象
ReactFiberConfigNoopHydration.js 注水(hydration)变体
ReactFiberConfigNoopNoMutation.js 禁用 mutation,用来测试 persistent 模式
ReactFiberConfigNoopNoPersistence.js 禁用 persistent
ReactFiberConfigNoopResources.js / NoScopes.js / NoSingletons.js / NoTestSelectors.js 逐个关掉一种能力,测试 reconciler 的降级路径
createReactNoop.js 门面:把 reconciler 包一层,暴露 render / renderLegacySyncRoot 等测试 API
ReactNoop.js / ReactNoopPersistent.js / ReactNoopServer.js 三种模式的入口
ReactNoopFlightClient.js / ReactNoopFlightServer.js Flight 协议的最小实现,RSC 测试专用

文件头注释把动机写得非常直白(createReactNoop.js:9-14):

js 复制代码
/**
 * This is a renderer of React that doesn't have a render target output.
 * It is useful to demonstrate the internals of the reconciler in isolation
 * and for testing semantics of reconciliation separate from the host
 * environment.
 */

一个典型测试长这样(ReactHooksWithNoopRenderer-test.js 头部,节选):

js 复制代码
/**
 * @emails react-core
 * @jest-environment node
 */
describe('ReactHooksWithNoopRenderer', () => {
  beforeEach(() => {
    jest.resetModules();
    jest.useFakeTimers();
    ...

配合 scheduler/unstable_mock------注意测试里 import 的是 mock 版调度器(见下文),时间是完全可控的。ReactNoop.render(...) 之后可以用 waitForAll / waitFor / waitForThrow 精确推进到某个时间点。这是把"并发渲染"这种难以复现的行为变成确定性测试的关键。

internal-test-utils:React 自用的测试工具箱

packages/internal-test-utils/ 不是 npm 包,是测试基础设施:

文件 作用
ReactInternalTestUtils.js act 的内部版、assertConsoleErrorDev 等断言助手
internalAct.js 与公开 act 不同:它在 production 下行为一致(头注释原话:"designed to work identically in both production and development"),供测试 React 自身用
consoleMock.js 劫持 console.error/warn,配合 assertConsoleLogsCleared 做"不该有警告"断言
enqueueTask.js 受控的宏任务队列,替代 setTimeout
simulateBrowserEventDispatch.js 模拟浏览器派发事件的两个阶段(capture/bubble),绕开 jsdom 的事件实现差异
shouldIgnoreConsoleError.js 维护一张"已知可忽略警告"白名单
最精妙的一环:测试里也做 HostConfig 替换

rollup 用编译期替换注入 HostConfig,jest 没有编译期,于是用了 jest.mock------机制不同,效果相同。看 scripts/jest/setupHostConfigs.js:170-178:

js 复制代码
// When testing the custom renderer code path through `react-reconciler`,
// turn the export into a function, and use the argument as host config.
const shimHostConfigPath = 'react-reconciler/src/ReactFiberConfig';
jest.mock('react-reconciler', () => {
  return config => {
    jest.mock(shimHostConfigPath, () => config);
    return jest.requireActual('react-reconciler');
  };
});

也就是说,测试里 require('react-reconciler') 拿到的是一个函数 ,调用它并传入你的 HostConfig,就能得到一个自定义渲染器。而内置渲染器的入口(react-dom、react-art 等)则在 setupHostConfigs.js:215-232 被批量处理:

js 复制代码
inlinedHostConfigs.forEach(rendererInfo => {
  if (rendererInfo.shortName === 'custom') {
    // There is no inline entry point for the custom renderers.
    // Instead, it's handled by the generic `react-reconciler` entry point above.
    return;
  }
  rendererInfo.entryPoints.forEach(entryPoint => {
    jest.mock(entryPoint, () => {
      mockAllConfigs(rendererInfo);
      ...

mockAllConfigs(:170 上方定义)做的就是从 packages/react-reconciler/src/forks/ 下按 shortName 找对应文件------与 rollup 的 forks.js 用的是同一张 scripts/shared/inlinedHostConfigs.js 表、同一种"逐段削减后缀"的查找算法。构建期与测试期因此永远一致,不会出现"构建能过、测试不过"的分裂。

调度器同样被替换(setupHostConfigs.js:250):

js 复制代码
jest.mock('scheduler', () => jest.requireActual('scheduler/unstable_mock'));

SchedulerMock.js 把 performance.now 换成假时钟,shouldYield 变成可精确控制的布尔值------没有它,测试时间切片行为就只能靠 sleep 赌运气。

@gate:按 flag 门控的测试

测试文件里随处可见 @gate 注解,它由 scripts/jest/TestFlags.js 解析,本质是"这个用例只在某些 feature flag 组合下运行":

js 复制代码
// @gate enableViewTransition
test('runs view transition', () => {...});

TestFlags.js:31-44 把这些"环境派生的 flag"列得很清楚,其中 experimental: __EXPERIMENTAL__、stable: !__EXPERIMENTAL__ 直接来自 release channel。所以一条 @gate !stable 的测试,在 yarn test 下运行、在 yarn test-stable 下自动跳过。测试数量随 channel 变化,是正常现象,不是测试丢了。

规模速览

find packages -name "*test*.js" 实测共 372 个测试文件,分布:

包 测试文件数
react-dom 147
react-reconciler 77
react 25
scheduler 6
其余(react-server、react-art、react-devtools-shared、eslint-plugin-react-hooks 等) 约 117

对阅读者来说,这 372 个文件是比注释更可靠的文档 :每个文件头的 @emails、@gate、describe 名,精确标注了这段代码的适用场景。


1.4 React 19.3.1 是什么

1.4.1 版本号的唯一真相源

仓库根目录有一个不起眼但极其重要的文件:ReactVersions.js。它的注释写得很清楚------"This module is the single source of truth for versioning packages that we publish to npm. Packages will not be published unless they are added here."

源码(ReactVersions.js:21-33):

js 复制代码
const ReactVersion = '19.3.1';

const canaryChannelLabel = 'canary';

const rcNumber = 0;

本系列分析的就是这个版本。确认这一点很重要,因为 React 的源码在 18 → 19 之间发生了结构性迁移(最典型的是 host config 从 react-dom 抽到了 react-dom-bindings),拿 18 的源码去套 19 会到处找不到文件。

1.4.2 三条发布渠道

同一个文件里还定义了发布渠道的编码规则(ReactVersions.js:1-19 的注释):

text 复制代码
// The @latest channel uses the version as-is, e.g.:
//   19.3.1
//
// The @canary channel appends additional information, with the scheme
// <version>-<label>-<commit_sha>, e.g.:
//   19.3.1-canary-a1c2d3e4
//
// The @experimental channel doesn't include a version, only a date and a sha, e.g.:
//   0.0.0-experimental-241c4467e-20200129

三条渠道对应三种稳定性承诺,读源码时也对应三种"这段代码会不会变"的判断:

渠道 版本形态 含义 源码里的对应物
@latest 19.3.1 正式版,稳定 API stablePackages 里的全部包
@canary 19.3.1-canary-<sha> 下一个正式版的预览 canaryChannelLabel
@experimental 0.0.0-experimental-<sha>-<date> 实验特性,随时可能删 experimentalPackages

experimentalPackages 目前只有一个成员(ReactVersions.js:56):

js 复制代码
const experimentalPackages = ['react-markup'];

这意味着 react-markup 是一个纯实验包。反过来说,stablePackages 里的每个包都是承诺稳定的,这也是本系列敢于逐行讲解它们的原因。

1.4.3 stablePackages 完整清单

ReactVersions.js:35-51 列出了全部稳定包及其版本。这张表是理解 monorepo 依赖关系的钥匙:

js 复制代码
const stablePackages = {
  'eslint-plugin-react-hooks': '7.1.1',
  'jest-react': '0.18.0',
  react: '19.3.1',
  'react-art': '19.3.1',
  'react-dom': '19.3.1',
  'react-server-dom-webpack': '19.3.1',
  'react-server-dom-turbopack': '19.3.1',
  'react-server-dom-parcel': '19.3.1',
  'react-is': '19.3.1',
  'react-reconciler': '0.34.0',
  'react-refresh': '0.19.0',
  'react-test-renderer': '19.3.1',
  'use-subscription': '1.13.0',
  'use-sync-external-store': '1.7.0',
  scheduler: '0.28.0',
};

注意几个有意思的细节:

  1. react-reconciler 的版本是 0.34.0,不是 19.3.1。这说明 React 团队刻意不让这个包对外承诺 API 稳定------它的导出是给第三方渲染器(React Native、react-three-fiber 等)用的,属于"能用但不保证"的灰色地带。
  2. scheduler 是 0.28.0,同样独立版本号。它是可以被单独复用的调度原语。
  3. use-sync-external-store 是 1.7.0 ,use-subscription 是 1.13.0。这两个是"可以单独安装、用于 React 18 以下版本兼容"的辅助包。React 19 里它们的功能已经内置,但包仍然单独发布,供旧版 React 使用。
  4. react-server-dom-webpack/turbopack/parcel 三个包共享 19.3.1------它们是同一套 RSC 能力在不同打包器上的适配层。

1.4.4 与 React 18 的架构级差异概览

下面这张表列的是架构层面的变化,不是 API 清单(API 层面第 13 章详述)。理解这些,能避免你用 18 的心智模型去套 19 的源码:

变化 React 18 React 19.3.1 影响
HostConfig 位置 在 packages/react-dom/src/client/ 抽到独立包 packages/react-dom-bindings/ 读源码时路径全变
diffProperties / updatePayload render 阶段生成属性差异数组 已删除,改为 commit 阶段 updateProperties 内联 diff 第 04、06 章重点
commitRootImpl 一个巨型函数 拆成 commitRoot + flush{AfterMutation,Mutation,Layout}Effects 第 06 章重点
并发渲染 需 createRoot + 手动 opt-in 部分特性 默认即并发 第 08 章
编译器 无 React Compiler(Rust 实现)进入正式能力 第 11 章全新
事件系统 packages/react-dom/src/events/ packages/react-dom-bindings/src/events/ 第 10 章
React.use 无 统一读取 Context / Promise 第 07、13 章

其中"diffProperties 被删除"这一条,是最容易被忽略但影响最大的架构改动。React 18 里,HostComponent 在 render 阶段(completeWork)就会算出"哪些属性变了"并打包成一个 updatePayload 数组,commit 阶段再回放。React 19 把这个过程整体后移到 commit 阶段,好处是 render 阶段少做一次全属性遍历、少分配一个数组,也让细粒度的 mutation 追踪(trackHostMutation)成为可能。

1.4.5 React 版本演进对照:从 15 到 19.3 的源码痕迹

架构不是一天长成的。19.3.1 的源码里留着每一代的痕迹------有的成了文件,有的成了 flag,有的成了注释里的自嘲。把这些痕迹串起来,你看到的不是"版本更新日志",而是一部活的设计决策史。

演进总表
代次 架构主题 核心机制 在 19.3.1 源码里的痕迹
React 15 Stack Reconciler 递归遍历,不可中断 已整体删除,只剩注释里对旧算法的零星引用
React 16 Fiber 重写 链表 + 双缓冲 + 可中断 work loop ReactFiber.js、ReactFiberWorkLoop.js、ReactChildFiber.js 的 mapRemainingChildren 系列
React 16.3 新的 Context API ReactContext.js + ReactFiberNewContext.js ReactFiberLegacyContext.js 成为 flag 后面的遗产
React 16.6 Suspense / lazy ReactLazy.js、ReactFiberSuspenseComponent.js、thenable 追踪 ReactFiberThenable.js、REACT_SUSPENSE_LIST_TYPE
React 17 事件委托改造 从 document 委托改为 root container 委托 listenToAllSupportedEvents(rootContainerElement),见下文
React 18 并发成为默认 Lane 优先级、微任务调度、并发更新合并 ReactFiberRootScheduler.js、ReactFiberConcurrentUpdates.js
React 19 能力层爆发 use、Actions、ref as prop、RSC 正式化、编译器 ReactFiberThenable.js、ReactFiberViewTransitionComponent.js、react-dom-bindings
React 19.2--19.3 View Transition / Activity / Gesture 动画与手势进入主链路 ReactFiberApplyGesture.js、ReactFiberGestureScheduler.js、ReactFiberDuplicateViewTransitions.js
痕迹一:ReactFiberStack.js------Fiber 时代的"栈"

ReactFiberStack.js(91 行)是 React 16 的产物,名字里的 "Stack" 容易让人以为它是 Stack Reconciler 的遗物------其实恰恰相反,它是 Fiber 架构下对"栈"的替代实现。Stack Reconciler 用 JavaScript 调用栈保存遍历现场,不可中断;Fiber 把遍历改成循环后,需要一个"可以保存/恢复的值栈",这就是这个文件:

js 复制代码
// packages/react-reconciler/src/ReactFiberStack.js:14-24(真实源码)
const valueStack: Array<any> = [];

let fiberStack: Array<Fiber | null>;

if (__DEV__) {
  fiberStack = [];
}

let index = -1;

function createCursor<T>(defaultValue: T): StackCursor<T> {
  return {
    current: defaultValue,
  };
}

它维护一个全局 valueStack 和一个游标 index,push(cursor, value, fiber) 存旧值、pop(cursor, fiber) 恢复旧值。第 03 章的 unwind 逻辑、第 10 章的事件监听都在用它。读这个文件能治好一个常见误解:Fiber 不是"没有栈",而是把栈从调用栈搬到了堆上,因此可以被保存、被丢弃、被重建。

痕迹二:legacy mode 的"缓刑"------disableLegacyMode

React 19 有一条专门的 flag 来处理历史包袱(packages/shared/ReactFeatureFlags.js:202-205):

js 复制代码
// Disables legacy mode
// This allows us to land breaking changes to remove legacy mode APIs in experimental builds
// before removing them in stable in the next Major
export const disableLegacyMode: boolean = true;

值为 true,意思是 legacy mode(ReactDOM.render 那一套同步根)在新版里已默认关闭。但代码并没有被删掉,而是被 flag 圈起来------这是 React 的发布策略:先在 experimental 里删,验证一个版本,再到 stable 删。仓库里与 legacy mode 相关的活化石包括:

  • packages/react/src/ReactNoopUpdateQueue.js------legacy 模式给"已卸载组件 setState"兜底的 noop 队列;
  • packages/react-dom/src/server/ReactDOMLegacyServerImpl.js 一族------React 15 时代的 renderToString 实现,与 Fizz 并存;
  • ReactSharedInternalsClient.js 里的 isBatchingLegacy / didScheduleLegacyUpdate 字段------legacy 批处理的残留状态。

packages/react/src/ReactAct.js:80-83 甚至留着一句开发者的自嘲:

js 复制代码
// `isBatchingLegacy` gets reset using the regular stack, not the async
// one used to track `act` scopes. Why, you may be wondering? Because
// that's how it worked before version 18. Yes, it's confusing! We should
// delete legacy mode!!

同类 flag 还有 disableLegacyContext: boolean = true(:187)、disableLegacyContextForFunctionComponents: boolean = true(:191)、disableCommentsAsDOMContainers: boolean = true(:223)------注意这些都在"Ready for next major"区块,是给下一个大版本预备的破坏性改动。

痕迹三:事件系统从 document 委托到 root 委托

React 17 最容易被低估的改动:事件监听从 document 挪到了 React 根容器 。React 16 及以前,所有事件统一委托在 document 上;17 起改为委托在 createRoot(container) 的 container 上。

在 19.3.1 里,这个决定的证据链非常完整。入口在 ReactDOMRoot.js:254-258:

js 复制代码
// packages/react-dom/src/client/ReactDOMRoot.js:254-258(真实源码)
  const rootContainerElement: Document | Element | DocumentFragment =
    !disableCommentsAsDOMContainers && container.nodeType === COMMENT_NODE
      ? (container.parentNode as any)
      : container;
  listenToAllSupportedEvents(rootContainerElement);

实现里有两个值得注意的细节(DOMPluginEventSystem.js:432-458):其一,用 listeningMarker(一个随机字符串属性)防止同一个容器重复绑定;其二,selectionchange 被单独挂在 ownerDocument 上,因为它不冒泡:

js 复制代码
// packages/react-dom-bindings/src/events/DOMPluginEventSystem.js:436-443(真实源码)
    allNativeEvents.forEach(domEventName => {
      // We handle selectionchange separately because it
      // doesn't bubble and needs to be on the document.
      if (domEventName !== 'selectionchange') {
        if (!nonDelegatedEvents.has(domEventName)) {
          listenToNativeEvent(domEventName, false, rootContainerElement);
        }
        listenToNativeEvent(domEventName, true, rootContainerElement);
      }
    });

nonDelegatedEvents(:236-250)则列出另一批"不委托、直接绑在目标元素上"的事件(scroll、toggle、load、媒体事件等),理由注释写得很清楚:"these events do not consistently bubble in the DOM"。

这个迁移的收益是架构性的:多个 React 根可以互不干扰(每个根管自己的事件),React 嵌在非 React 页面里时也不会劫持全局事件流。第 10 章会完整展开。

痕迹四:并发更新的"排队论"------ReactFiberConcurrentUpdates.js

React 18 把并发做成默认,最难的部分不是"可中断"(16 就有了),而是中断之后怎么恢复、多个更新怎么合并 。288 行的 ReactFiberConcurrentUpdates.js 就是这个问题的集中答案,它管理两类队列:Hook 的 UpdateQueue 和 class 的 SharedQueue,处理"渲染到一半被打断""同一个组件连续两次 setState""Suspense 重试"等场景下队列的合并与重放。

与之配套的是 ReactFiberRootScheduler.js(本系列第 03 章主角):微任务调度 + 防饥饿 + lane 选择,三件事都在这个文件里。

痕迹五:19 的新文件即新能力的地图

React 19 引入的新机制,几乎都能在 reconciler 里找到同名的新文件:

新文件 对应能力 首次出现
ReactFiberThenable.js use(promise) 的 thenable 追踪与 suspend 判定 19.0
ReactFiberViewTransitionComponent.js <ViewTransition> 组件 19.2
ReactFiberApplyGesture.js 手势过渡的 host 侧操作 19.3
ReactFiberGestureScheduler.js startGestureTransition 的 lane 调度(用到了新的 GestureLane,ReactFiberLane.js:59) 19.3
ReactFiberDuplicateViewTransitions.js DEV 下重名 ViewTransition 检测 19.2
ReactFiberFragmentInstance.js Fragment ref 的实例句柄 19.x
ReactFiberPostPaintCallback.js 绘制后回调(requestPaint 机制) 19.x
ReactFiberMutationTracking.js trackHostMutation 细粒度 mutation 追踪(19 移除 diffProperties 后新增) 19.0
ReactFiberCacheComponent.js cache() / useCacheRefresh 的 Fiber 侧支撑 19.0

一个反向证据同样有意思:ReactFeatureFlags.js 里已经找不到 enableLazy 和 enableCache 了------它们已在 React 19 默认开启并从 flag 文件中删除。而 enableViewTransition: boolean = true(:90)说明 View Transition 在 19.3.1 已默认开启;enableGestureTransition = __EXPERIMENTAL__(:96)说明手势过渡还只在实验渠道。

痕迹六:Chopping Block------下一刀砍在哪

ReactFeatureFlags.js:207-213 有一节叫 Chopping Block("砧板"),注释原话是"Planned feature deprecations and breaking changes. Sorted roughly in order of when we plan to enable them."。它按计划排列着未来的破坏性改动,DOM 相关的还有独立一节(:215-223)。当前躺在砧板上的包括:

js 复制代码
// packages/shared/ReactFeatureFlags.js:221-224(真实源码)
// Disable support for comment nodes as React DOM containers. Already disabled
// in open source, but www codebase still relies on it. Need to remove.
export const disableCommentsAsDOMContainers: boolean = true;

export const enableTrustedTypesIntegration: boolean = true;

// Prevent the value and checked attributes from syncing with their related
// DOM properties
export const disableInputAttributeSyncing: boolean = false;

// Disables children for <textarea> elements
export const disableTextareaChildren: boolean = false;

读 flag 文件等于读 React 的路线图 ------值为 false 的是"还没砍但已登记在册",__EXPERIMENTAL__ 的是"实验渠道先行",true 的是"已经落地"。这比任何 roadmap 博客都更接近真相。

一张图总结演进逻辑
flowchart LR R15[&#34;React 15<br/>Stack Reconciler<br/>递归不可中断&#34;] -->|&#34;Fiber 重写&#34;| R16[&#34;React 16<br/>链表 + 双缓冲<br/>可中断 work loop&#34;] R16 -->|&#34;新 Context / Suspense&#34;| R163[&#34;React 16.3~16.6<br/>createContext / lazy&#34;] R163 -->|&#34;事件委托迁移&#34;| R17[&#34;React 17<br/>document → root container&#34;] R17 -->|&#34;Lane + 并发默认&#34;| R18[&#34;React 18<br/>createRoot / 自动批处理&#34;] R18 -->|&#34;HostConfig 拆分<br/>diffProperties 移除&#34;| R19[&#34;React 19<br/>use / Actions / RSC&#34;] R19 -->|&#34;ViewTransition / Activity&#34;| R192[&#34;React 19.2&#34;] R192 -->|&#34;Gesture / 手势过渡&#34;| R193[&#34;React 19.3.1<br/>(本系列基准)&#34;]

1.5 声明式 UI = f(state)

1.5.1 命令式 DOM 的问题

假设你要实现一个"待办列表":勾选一项,它要加删除线并移到底部。

命令式的写法是:

js 复制代码
function toggleDone(id) {
  const item = document.getElementById(id);
  item.classList.add('done');
  list.appendChild(item);          // 移到底部
  updateCounter();                 // 别忘了更新计数
  saveToLocalStorage();            // 别忘了持久化
}

这段代码的问题不在于"啰嗦",而在于它把"界面应该是什么样"和"怎么改 DOM"耦合在了一起。状态一变,你必须手工推导出 DOM 的每一个差异。项目一大,这类代码会出现三类经典 bug:

  • 漏更新 :改了 A 忘了改 B(比如上面的 updateCounter)。
  • 多更新:同一个 DOM 被改两次。
  • 顺序更新:先移后删和先删后移结果不同。

1.5.2 声明式是怎么解决的

React 的思路是把这两个关注点拆开:

jsx 复制代码
function TodoList({ items }) {
  const sorted = [...items].sort(byDone);
  return (
    <ul>
      {sorted.map(item => (
        <li key={item.id} className={item.done ? 'done' : ''}>
          {item.text}
        </li>
      ))}
    </ul>
  );
}

这段代码只描述"给定 items,界面应该长什么样"。它不碰 DOM。当 items 变化时,React 负责算出"从旧界面到新界面,DOM 最少需要动哪些地方"。

这就是 UI = f(state)。注意这个 f 有两个硬性要求,源码里到处都能看到它们:

  1. f 必须是纯函数 :同样的 state 必须得到同样的 UI 描述。所以 render 期间不能改外部变量、不能发请求、不能读时钟。React 19 用 React Compiler 的 Rules of React 校验 + $dispatcherGuard 运行时守卫来强制这一点(第 11 章)。
  2. f 的输出必须是廉价的描述对象,而不是真实 DOM。这就是"React 元素"------一个普通 JS 对象。第 02 章会逐行讲它。

1.5.3 一次 render = 一次纯函数调用

这个心智模型直接决定了 React 源码的形态。看 ReactFiberWorkLoop.js 的 performUnitOfWork,它做的事情本质上就是"调用一次组件函数,拿到返回值,然后决定接下来干什么":

js 复制代码
function performUnitOfWork(unitOfWork: Fiber): void {
  const current = unitOfWork.alternate;
  let next;
  // ... DEV / profiler 分支略
  next = beginWork(current, unitOfWork, entangledRenderLanes);

  unitOfWork.memoizedProps = unitOfWork.pendingProps;
  if (next === null) {
    // If this doesn't spawn new work, complete the current work.
    completeUnitOfWork(unitOfWork);
  } else {
    workInProgress = next;
  }
}

注意最后那个 if (next === null)。它的含义是:这个组件函数返回的 UI 描述里没有子节点需要继续处理了,于是走 completeUnitOfWork 向上回溯。这一行代码就是"深度优先遍历 + 回溯"的全部秘密,第 03 章会把它展开成完整的 work loop。

1.5.4 声明式的代价

必须公平地说,声明式不是没有代价:

  1. 你需要一套 diff 算法,而任何 diff 都有启发式,启发式就有猜错的时候。第 04 章会看到 React 的 diff 算法为了 O(n) 复杂度,牺牲了"最优移动序列"------它只保证正确,不保证移动次数最少。
  2. 你需要不可变数据流 ,否则 f 不纯,diff 也会失效。
  3. 抽象层让调试变难:出问题时你看到的是 React 的内部结构,不是你的代码。

React 19 引入编译器,某种程度上就是在还第一笔债:既然 diff 依赖"props 没变"这个前提,那就让编译器自动帮你把 props 变稳定。


1.6 monorepo 全貌

1.6.1 核心三层:react / react-reconciler / 渲染器

React 的架构可以浓缩成三层,这三层是理解整套代码的骨架:

flowchart TD subgraph layer1[&#34;第 1 层:react 包 ------ 平台无关的编程模型&#34;] A1[&#34;createElement / jsx&#34;] A2[&#34;Hooks API&#34;] A3[&#34;Component / memo / lazy / context&#34;] end subgraph layer2[&#34;第 2 层:react-reconciler ------ 平台无关的渲染引擎&#34;] B1[&#34;Fiber / Work Loop&#34;] B2[&#34;Reconciliation&#34;] B3[&#34;Lane 优先级&#34;] B4[&#34;Commit 三阶段&#34;] end subgraph layer3[&#34;第 3 层:渲染器 ------ 平台相关的落地&#34;] C1[&#34;react-dom(DOM)&#34;] C2[&#34;react-native-renderer(原生)&#34;] C3[&#34;react-art(Canvas)&#34;] C4[&#34;react-noop-renderer(测试)&#34;] end layer1 --> layer2 layer2 --> layer3

关键洞察:第 2 层不认识 DOM 。ReactFiberWorkLoop.js 里从头到尾没有 document.createElement,只有 createInstance(type, props) 这样的抽象调用。它调用的具体函数由第 3 层在编译期注入。这就是 1.7 节要讲的 HostConfig 机制。

1.6.2 react 包:公开 API 面

react 包的源码在 packages/react/src/,客户端入口是 ReactClient.js。这个文件是整个 React 对外 API 的"总装线",它把散落在十几个文件里的实现重新导出。

真实源码(packages/react/src/ReactClient.js:76-137,节选):

js 复制代码
export {
  Children,
  createRef,
  Component,
  PureComponent,
  createContext,
  forwardRef,
  lazy,
  memo,
  cache,
  cacheSignal,
  useCallback,
  useContext,
  useEffect,
  useEffectEvent,
  useImperativeHandle,
  useDebugValue,
  useInsertionEffect,
  useLayoutEffect,
  useMemo,
  useOptimistic,
  useActionState,
  useSyncExternalStore,
  useReducer,
  useRef,
  useState,
  REACT_FRAGMENT_TYPE as Fragment,
  REACT_PROFILER_TYPE as Profiler,
  REACT_STRICT_MODE_TYPE as StrictMode,
  REACT_SUSPENSE_TYPE as Suspense,
  createElement,
  cloneElement,
  isValidElement,
  ReactVersion as version,
  ReactSharedInternals as __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,
  ReactCompilerRuntime as __COMPILER_RUNTIME,
  // Concurrent Mode
  useTransition,
  startTransition,
  useDeferredValue,
  REACT_SUSPENSE_LIST_TYPE as unstable_SuspenseList,
  REACT_LEGACY_HIDDEN_TYPE as unstable_LegacyHidden,
  REACT_ACTIVITY_TYPE as Activity,
  getCacheForType as unstable_getCacheForType,
  useCacheRefresh as unstable_useCacheRefresh,
  use,
  // enableScopeAPI
  REACT_SCOPE_TYPE as unstable_Scope,
  // enableTransitionTracing
  REACT_TRACING_MARKER_TYPE as unstable_TracingMarker,
  // enableViewTransition
  REACT_VIEW_TRANSITION_TYPE as ViewTransition,
  addTransitionType as addTransitionType,
  // enableGestureTransition
  startGestureTransition as unstable_startGestureTransition,
  // enableOptimisticKey
  REACT_OPTIMISTIC_KEY as optimisticKey,
  // DEV-only
  useId,
  act,
  captureOwnerStack,
};

从这个文件能读出大量信息:

  1. Fragment / Profiler / StrictMode / Suspense 本质上只是 Symbol 。它们被 as 重命名导出,说明这些"组件"其实是类型标记,JSX 编译成 ReactElement 时把 type 设成这个 Symbol,reconciler 靠 $$typeof 识别。第 02 章详述。
  2. 命名即稳定性契约 :带 unstable_ 前缀的(unstable_SuspenseList、unstable_Scope、unstable_startGestureTransition)是实验 API;不带的是稳定 API。React 19 把 Activity、ViewTransition 提到了不带前缀的位置,说明它们已转正。
  3. use 和 useOptimistic、useActionState、useEffectEvent 在列表里------这四个是 React 19 的新编程原语,第 07、13 章讲解。
  4. ReactSharedInternals 被导出成一个名字极长的变量 :__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE。这个名字本身就是警告。它就是第 07 章的核心------ReactSharedInternals.H 是全局 dispatcher 指针,Hooks 全靠它分发。
  5. ReactCompilerRuntime as __COMPILER_RUNTIME :这是 React 19 为编译器预留的运行时入口,useMemoCache 的 c() 就挂在这里。第 11 章讲解。

值得注意的是,ReactClient.js 里 import 的来源揭示了 react 包的内部文件划分:

js 复制代码
import {Component, PureComponent} from './ReactBaseClasses';
import {createRef} from './ReactCreateRef';
import {forEach, map, count, toArray, only} from './ReactChildren';
import {createElement, cloneElement, isValidElement} from './jsx/ReactJSXElement';
import {createContext} from './ReactContext';
import {lazy} from './ReactLazy';
import {forwardRef} from './ReactForwardRef';
import {memo} from './ReactMemo';
import {cache, cacheSignal} from './ReactCacheClient';
import {...二十多个 Hook...} from './ReactHooks';

一个文件一个职责,这是 React 源码的组织方式。ReactHooks.js 本身极薄(第 07 章会看到它只是 resolveDispatcher() + 转发),真正实现在 reconciler 里。

1.6.3 scheduler 包

scheduler 是 React 的"发动机"。它不依赖 React 的任何东西,是一个独立的通用优先级调度原语(版本 0.28.0,可以单独装来用)。

源码在 packages/scheduler/src/,DOM 平台的实现在 forks/Scheduler.js(注意:这个版本里 SchedulerDOM.js 已经不存在,全部合并在 Scheduler.js 里)。同目录还有几个平台变体:

文件 用途
forks/Scheduler.js 浏览器 / Worker 环境的主实现(本系列第 05 章主角)
forks/SchedulerNative.js React Native 原生调度
forks/SchedulerPostTask.js 基于 scheduler.postTask 的实现
forks/SchedulerMock.js 测试与 SSR 用的假实现

scheduler 对外只暴露 unstable_scheduleCallback、unstable_cancelCallback、unstable_shouldYield、unstable_getCurrentPriorityLevel 等少数函数。它的全部价值在于:把"这段工作可以随时暂停,稍后继续"变成现实。第 05 章会逐行讲它的最小堆、时间切片和续体机制。

1.6.4 react-dom 与 react-dom-bindings

这两个包的关系是 React 19 最重要的结构变化,必须讲清楚:

flowchart LR subgraph rd[&#34;react-dom 包&#34;] D1[&#34;client/ReactDOMRoot.js<br/>createRoot / hydrateRoot&#34;] D2[&#34;server/<br/>renderToString / Fizz&#34;] D3[&#34;test-utils/&#34;] D4[&#34;events/(少量 glue)&#34;] end subgraph rdb[&#34;react-dom-bindings 包&#34;] E1[&#34;client/ReactFiberConfigDOM.js<br/>← 真正的 HostConfig&#34;] E2[&#34;client/ReactDOMComponent.js<br/>setProp / updateProperties&#34;] E3[&#34;events/<br/>整个事件系统&#34;] end RC[&#34;react-reconciler<br/>(平台无关)&#34;] D1 -->|&#34;注入 HostConfig&#34;| RC E1 --> RC RC -->|&#34;createInstance / setProp / ...&#34;| E1
  • react-dom :面向用户的入口。createRoot、hydrateRoot、flushSync、renderToString、renderToReadableStream 都在这里。
  • react-dom-bindings:React 19 新拆出来的包,装着所有"和 DOM 细节打交道"的代码------HostConfig、属性 diff、事件系统。

为什么要拆?三个现实原因:

  1. 让 react-server 也能复用 DOM 逻辑。RSC 场景下服务端不需要完整 DOM,但需要一部分 DOM 相关的字符串/属性处理。
  2. 让 react-dom 的入口更小,减少用户侧打包体积。
  3. 把"平台知识"集中管理,将来要做 React Native / Canvas 的同类拆分时有清晰模板。

这个拆分直接导致:你在 React 18 教程里看到的 packages/react-dom/src/client/ReactDOMComponent.js,在 19 里位于 packages/react-dom-bindings/src/client/ReactDOMComponent.js。本系列所有引用都基于新路径。

1.6.5 react-server-dom-* 家族

React 19 把服务端组件(RSC)做成了正式能力,对应五个包:

包 作用
react-server 服务端专用的 react 出口(react-server 条件导出),提供 cache、preload 等
react-server-dom-webpack Webpack 的 Flight 协议实现
react-server-dom-turbopack Turbopack 的 Flight 协议实现
react-server-dom-parcel Parcel 的 Flight 协议实现
react-server-dom-esm / react-server-dom-unbundled 无打包器场景的实现

"Flight"是 RSC 的序列化协议名------服务端把渲染结果序列化成一种流式格式,客户端反序列化。第 12 章讲解。

1.6.6 其它辅助包

包 作用 说明
react-is isValidElementType 等类型判断工具 被 devtools 大量使用
react-art Canvas / SVG 渲染器 "Learn Once, Write Anywhere" 的活证据
react-noop-renderer 什么都不渲染的渲染器 reconciler 单元测试专用
react-native-renderer React Native 渲染器 独立发布,不在本仓库 npm 流程里
react-test-renderer 把组件树渲染成 JS 对象快照 测试用
react-refresh 开发热更新 Fast Refresh 的核心
react-debug-tools DevTools 依赖的内部钩子 暴露测试专用 API
react-devtools-*(11 个) DevTools 浏览器扩展与核心 独立应用
react-cache cache API 的旧版兼容实现 React 19 已内置
use-sync-external-store / use-subscription 外部 store 订阅兼容层 供 React 18 以下使用
eslint-plugin-react-hooks Hooks 规则检查 独立版本 7.1.1
shared 跨包共享的内部工具 不是独立 npm 包
jest-react / internal-test-utils 测试基础设施 内部用

1.6.7 38 个 package 全表

按"是否值得源码级阅读"分类:

核心必读(本系列重点)

包 一句话
react 编程模型与公开 API
react-reconciler 渲染引擎(Fiber / diff / commit / Hooks 实现)
react-dom DOM 入口
react-dom-bindings DOM HostConfig + 事件系统
scheduler 优先级调度

扩展阅读

包 一句话
react-server / react-server-dom-* RSC 与 Flight
react-noop-renderer 理解渲染器抽象的最佳样本(最小 HostConfig)
react-art 第二个渲染器实例

工程/测试/工具(一般不必读)

react-test-renderer、react-refresh、react-debug-tools、react-devtools-*、react-cache、react-is、eslint-plugin-react-hooks、jest-react、use-subscription、use-sync-external-store、shared、internal-test-utils、dom-event-testing-library、react-suspense-test-utils、react-native-renderer、react-flight-server-fb、react-client、react-markup(experimental)。

给想深入的人的建议 :react-noop-renderer 是理解 HostConfig 抽象的最佳样本。它的 HostConfig 只有几十行,把所有平台差异压缩到最小。读完 react-dom-bindings 再读它,你会突然明白"渲染器到底是什么"。

1.6.8 ReactClient.js 公开 API 逐项深解

1.6.2 节列出了 react 包的导出清单,这一节把清单逐项展开 :每个 API 住在哪个源文件、类型是什么、受哪个 flag 控制、在 React 19 处于什么状态。全部信息可在 packages/react/src/ReactClient.js(全文仅 137 行)与 packages/shared/ReactSymbols.js 中核对。

先看结构:这个文件的"三段式"

ReactClient.js 全文分三段,这个结构本身就说明了 react 包的组织方式:

  1. import 段(:10-66) :从 21 个内部文件引入实现。注意 import 的来源揭示了文件划分------./ReactBaseClasses、./ReactHooks、./jsx/ReactJSXElement......一个文件一个职责。
  2. Children 对象组装(:68-74) :五个函数从 ./ReactChildren 引入后组装成一个对象导出,而不是五个独立导出。
  3. export 段(:76-137) :62 个导出项,带注释分组(// Concurrent Mode、// enableScopeAPI、// DEV-only 等)。
分类逐项表

按"实现来源"把 62 个导出分成七类。

第一类:来自 shared/ReactSymbols.js 的类型 Symbol(11 个)

这些不是函数,是 Symbol.for(...) 创建的全局 Symbol。JSX 编译产物的 type 字段、reconciler 的 $$typeof 判断,全靠它们:

导出名 Symbol 常量 注册字符串 定义位置
Fragment REACT_FRAGMENT_TYPE react.fragment ReactSymbols.js:20
Profiler REACT_PROFILER_TYPE react.profiler ReactSymbols.js:22
StrictMode REACT_STRICT_MODE_TYPE react.strict_mode ReactSymbols.js:21
Suspense REACT_SUSPENSE_TYPE react.suspense ReactSymbols.js:26
unstable_SuspenseList REACT_SUSPENSE_LIST_TYPE react.suspense_list ReactSymbols.js:27-29
unstable_LegacyHidden REACT_LEGACY_HIDDEN_TYPE react.legacy_hidden ReactSymbols.js:34-36
Activity REACT_ACTIVITY_TYPE react.activity ReactSymbols.js:33
unstable_Scope REACT_SCOPE_TYPE react.scope ReactSymbols.js:32
unstable_TracingMarker REACT_TRACING_MARKER_TYPE react.tracing_marker ReactSymbols.js:37-39
ViewTransition REACT_VIEW_TRANSITION_TYPE react.view_transition ReactSymbols.js:45-47
optimisticKey REACT_OPTIMISTIC_KEY react.optimistic_key ReactSymbols.js:69-71

源码原文(ReactSymbols.js:14-26,节选):

js 复制代码
// The Symbol used to tag the ReactElement-like types.
export const REACT_LEGACY_ELEMENT_TYPE: symbol = Symbol.for('react.element');
export const REACT_ELEMENT_TYPE: symbol = Symbol.for(
  'react.transitional.element',
);
export const REACT_PORTAL_TYPE: symbol = Symbol.for('react.portal');
export const REACT_FRAGMENT_TYPE: symbol = Symbol.for('react.fragment');
export const REACT_STRICT_MODE_TYPE: symbol = Symbol.for('react.strict_mode');
export const REACT_PROFILER_TYPE: symbol = Symbol.for('react.profiler');

三个值得注意的点:

  1. 用 Symbol.for 而不是 Symbol()。这意味着注册字符串是跨 realms 共享的------iframe 里的 React 和主窗格的 React 能识别同一个元素。这是 micro-frontend 场景的地基。
  2. REACT_ELEMENT_TYPE 用的是 react.transitional.element ,而旧的 REACT_LEGACY_ELEMENT_TYPE 是 react.element。React 19 同时认这两种(第 02 章会讲为什么需要"过渡型"元素类型)。
  3. optimisticKey 不是 Symbol 语义而是布尔语义 。ReactSymbols.js:73-76 的注释解释了这件怪事:"This is actually a symbol but Flow doesn't support comparison of symbols to refine. We use a boolean since in our code we often expect string (key) or number (index), so by pretending to be a boolean we cover a lot of cases"。它是 enableOptimisticKey(ReactFeatureFlags.js:110,__EXPERIMENTAL__)的配套物,作为"没有 key 时的兜底 key"使用。

第二类:元素与 JSX(3 个)

导出 源文件 说明
createElement react/src/jsx/ReactJSXElement.js 经典 API,返回 ReactElement
cloneElement 同上 克隆元素并合并 props
isValidElement 同上 判断 $$typeof 是否为元素类型

第三类:组件抽象(6 个)

导出 源文件 说明
Component react/src/ReactBaseClasses.js class 组件基类
PureComponent 同上 带 shouldComponentUpdate 浅比较的基类
createContext react/src/ReactContext.js 创建 Context 对象($$typeof 为 REACT_CONTEXT_TYPE,ReactSymbols.js:24)
forwardRef react/src/ReactForwardRef.js ref 转发包装(React 19 中函数组件已可直接收 ref,此 API 变为可选)
lazy react/src/ReactLazy.js 动态导入包装,$$typeof 为 REACT_LAZY_TYPE(:31)
memo react/src/ReactMemo.js 记忆化包装,$$typeof 为 REACT_MEMO_TYPE(:30)

第四类:Children 工具(1 个对象)

Children 由 react/src/ReactChildren.js 的五个函数组装(ReactClient.js:68-74):map、forEach、count、toArray、only。其中 only 在开发环境会校验"只能有一个子元素"。

第五类:缓存与并发(7 个)

导出 源文件 React 19 状态
cache react/src/ReactCacheClient.js 19 新增导出(RSC 的 cache 下沉到客户端可用)
cacheSignal 同上 19 新增,cache 的中止信号配套
useTransition react/src/ReactHooks.js → reconciler 稳定
startTransition react/src/ReactStartTransition.js 稳定
useDeferredValue react/src/ReactHooks.js → reconciler 稳定
addTransitionType react/src/ReactTransitionType.js 19 新增,配合 View Transition
unstable_startGestureTransition react/src/ReactStartTransition.js 实验(enableGestureTransition = __EXPERIMENTAL__)

第六类:Hooks(20 个)

ReactClient.js 从 ./ReactHooks 引入 21 个符号,其中 20 个直接作为 Hook 导出,另一个是 getCacheForType(改名导出)。这个文件的薄是刻意的------看 ReactHooks.js:24-42:

js 复制代码
// packages/react/src/ReactHooks.js:24-42(真实源码)
function resolveDispatcher() {
  const dispatcher = ReactSharedInternals.H;
  if (__DEV__) {
    if (dispatcher === null) {
      console.error(
        'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' +
          ' one of the following reasons:\n' +
          '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' +
          '2. You might be breaking the Rules of Hooks\n' +
          '3. You might have more than one copy of React in the same app\n' +
          'See https://react.dev/link/invalid-hook-call for tips about how to debug and fix this problem.',
      );
    }
  }
  // Will result in a null access error if accessed outside render phase. We
  // intentionally don't throw our own error because this is in a hot path.
  // Also helps ensure this is inlined.
  return dispatcher as any as Dispatcher;
}

每个 Hook 都是 resolveDispatcher().useXxx(...) 的三行转发,比如 useState(:66-71):

js 复制代码
export function useState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

这就是"一个 react 包驱动多个渲染器"的实现机制 :ReactSharedInternals.H 是个全局 dispatcher 指针,渲染器在渲染前把它换成自己的实现(ReactFiberHooks.js 里的 Dispatcher),渲染后换回 ContextOnlyDispatcher。业务代码 import {useState} from 'react',实际执行的是当前渲染器注册的函数。

按引入清单(ReactClient.js:38-60)逐个数:getCacheForType、useCallback、useContext、useEffect、useEffectEvent、useImperativeHandle、useDebugValue、useInsertionEffect、useLayoutEffect、useMemo、useSyncExternalStore、useReducer、useRef、useState、useTransition、useDeferredValue、useId、useCacheRefresh、use、useOptimistic、useActionState------共 21 个。

其中 React 19 新引入或新转正的有五个,值得单独记住:

Hook 新在何处 备注
use 全新 统一读取 Context 与 Promise;实现见 ReactHooks.js:207-210
useOptimistic 全新 乐观更新;ReactHooks.js:226-232
useActionState 全新(承接 useFormState) form action 状态机;ReactHooks.js:234-241
useEffectEvent 全新 "事件函数",19 实验、19.x 转正;ReactHooks.js:218-224
useCacheRefresh 全新(带 unstable_) 对应 unstable_useCacheRefresh 导出名

注意一个细节:useMemoCache 在 ReactHooks.js:212-216 有实现,但没有 出现在 ReactClient.js 的导出里------它是编译器专用 API,只通过 __COMPILER_RUNTIME 间接暴露(见下文)。

第七类:内部件与 DEV-only(6 个)

导出 源文件 性质
__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE react/src/ReactSharedInternalsClient.js 内部状态容器
__COMPILER_RUNTIME react/src/ReactCompilerRuntime.js 编译器运行时入口
version shared/ReactVersion.js(经 ReactVersion 引入) 版本号
useId ReactHooks.js:183-186 标在 // DEV-only 组,但生产可用
act react/src/ReactAct.js 生产抛错
captureOwnerStack react/src/ReactOwnerStack.js 生产返回 null

关于这组的三个事实,源码里都能坐实:

  1. ReactSharedInternals 的字段就是全局状态的清单 (ReactSharedInternalsClient.js:26-63):H(Hooks dispatcher)、A(Cache dispatcher)、T(当前 Transition)、S(transition 结束回调)、G(手势回调,enableGestureTransition 时才有),DEV 下还有 actQueue、thrownErrors、getCurrentStack 等。第 07 章讲 Hooks、第 08 章讲并发,本质上都在讲这几个字段的读写时序。
  2. act 在生产构建里直接抛错 (ReactAct.js:252-254):
js 复制代码
  } else {
    throw new Error('act(...) is not supported in production builds of React.');
  }
}

而 captureOwnerStack 的生产路径是 return null(ReactOwnerStack.js:11-24)。所以 // DEV-only 这行注释对这三个导出的含义并不相同:useId 生产照常工作,act 抛错,captureOwnerStack 静默返回 null。这类"注释是分组提示、行为要逐个核对"的细节,正是读源码而非读文档的价值。 3. __COMPILER_RUNTIME 只有一行 (ReactCompilerRuntime.js:10):

js 复制代码
export {useMemoCache as c} from './ReactHooks';

React Compiler 把自动记忆化的调用编译成 $ReactCompilerRuntime.c(size) 这种形式,运行时落到这里。第 11 章会看到编译器产物的全貌。

"命名即契约":unstable_ 前缀清单

ReactClient.js 里带 unstable_ 前缀的导出共 6 个:unstable_SuspenseList、unstable_LegacyHidden、unstable_getCacheForType、unstable_useCacheRefresh、unstable_Scope、unstable_TracingMarker、unstable_startGestureTransition------注意最后一个是 7 个。命名规则非常稳定:前缀表示"API 形态随时可能变",不代表"不可用" 。比如 unstable_useCacheRefresh 在实验渠道是完整可用的。

反过来,React 19 把 Activity 和 ViewTransition 放到了不带前缀的位置 (:118、:127),说明这两个在 19.x 已转正。对照 ReactFeatureFlags.js:enableViewTransition: boolean = true(:90)确认 ViewTransition 默认开启;enableScopeAPI: boolean = false(:56)、enableTransitionTracing: boolean = false(:117)确认 Scope 与 TracingMarker 还停在实验。

与 ReactServer.js 的对照

react 包在 react-server 条件下有另一套入口(packages/react/src/ReactServer.js),导出 cache、preload、useContext 等服务端专用 API,且没有 useState、useEffect 这些客户端 Hook。两个入口共享 shared/ReactSharedInternals 但实例化不同版本(客户端版 / 服务端版,见 1.7.5 节的 forks 表)。同一个 import {use} from 'react',在客户端和服务端走的是不同文件------这是 RSC "一套 import、两种环境"的实现基础,第 12 章详述。

1.6.9 五个核心包的 src/ 逐文件清单

这一节把 1.6.7 节的表格落到文件级。清单全部由 ls packages/*/src 实测得到(React 19.3.1),每个文件给一句职责说明。建议把这一节当作索引用:后面每一章开头都会说"本章涉及这些文件"。

packages/react/src/(31 个文件 + jsx/ 子目录)
文件 职责
ReactClient.js 客户端总装线,62 个导出(1.6.8 节主角)
ReactBaseClasses.js Component / PureComponent 两个基类
ReactCreateRef.js createRef,返回 {current: null} Ref 对象
ReactChildren.js Children.map/forEach/count/toArray/only
ReactContext.js createContext,Provider/Consumer 构造
ReactForwardRef.js forwardRef 包装
ReactLazy.js lazy(),REACT_LAZY_TYPE 元素构造
ReactMemo.js memo(),REACT_MEMO_TYPE 元素构造
ReactHooks.js 全部 Hook 的薄壳 + resolveDispatcher(1.6.8 节已引)
ReactCacheClient.js cache / cacheSignal 的客户端实现
ReactCacheImpl.js cache 的内部实现(被客户端与服务端共用)
ReactCacheServer.js react-server 条件的 cache 出口
ReactAct.js 公开 act API
ReactCompilerRuntime.js __COMPILER_RUNTIME,仅 useMemoCache as c
ReactOwnerStack.js captureOwnerStack,owner stack 的读取入口
ReactStartTransition.js startTransition / startGestureTransition
ReactTransitionType.js addTransitionType
ReactTaint.js / ReactTaintRegistry.js experimental_taintObjectReference 一族(实验)
ReactNoopUpdateQueue.js legacy 模式的 noop 更新队列(1.4.5 节的活化石)
ReactSharedInternalsClient.js 客户端全局状态容器(H/A/T/S/G)
ReactSharedInternalsServer.js 服务端版本
ReactServer.js react-server 条件入口(服务端 API)
ReactServer.experimental.js / ReactServer.fb.js / ReactServer.experimental.development.js 服务端入口的渠道变体
jsx/ReactJSXElement.js createElement / cloneElement / isValidElement
jsx/ReactJSX.js jsx / jsxs / jsxDEV 运行时(新 JSX transform 的落地处)
jsx/ReactJSXServer.js react-server 条件的 JSX 运行时

一句话总结:react 包 31 个文件里没有一个字节的渲染逻辑,全是"编程模型的外壳"。这验证了 1.6.1 节的分层图。

packages/react-dom/src/(4 个子目录)
子目录 文件 职责
client/ ReactDOMClient.js createRoot / hydrateRoot 的再导出门面
ReactDOMRoot.js 380 行,createRoot 的真正实现 + 事件绑定(1.4.5 节已引)
ReactDOMDefaultTransitionIndicator.js 19 新,默认 transition 指示器(enableDefaultTransitionIndicator)
ReactDOMClientFB.js / ReactDOMRootFB.js Facebook 内部变体
server/ ReactDOMFizzServerBrowser/Node/Edge/Bun.js Fizz 流式 SSR 的四个运行时适配
ReactDOMFizzStaticBrowser/Node/Edge.js 静态生成(prerender)适配
ReactDOMLegacyServerImpl.js + ReactDOMLegacyServerBrowser/Node.js React 15 时代的 renderToString 遗产
react-dom-server.*.js(8 个) 入口映射文件,把 react-dom/server 等导出条件指向具体实现
shared/ ReactDOM.js legacy ReactDOM.render / unmountComponentAtNode
ReactDOMFlushSync.js flushSync 实现
ReactDOMFloat.js preinit / preload / prefetchDNS 等资源预加载 API
ReactDOMBrowser.js / ReactDOMTypes.js 类型与浏览器环境判断
ensureCorrectIsomorphicReactVersion.js "react 与 react-dom 版本不一致"报错的出处
ReactDOMSharedInternals.js / ReactDOMSharedInternalsFB.js DOM 侧全局状态(d dispatcher、findDOMNode)
test-utils/ ReactTestUtils.js 旧版测试工具(已废弃,内部转调 act)
FizzTestUtils.js Fizz 的测试助手
events/ plugins/__tests__/ 只剩测试 ------React 19 把事件插件实体移到了 react-dom-bindings,测试留在旧址

注意 events/ 这个"空壳目录"是架构迁移的直接物证:packages/react-dom/src/events/plugins/__tests__/ 下还有 5 个测试文件(SimpleEventPlugin-test.js 等),而它们的被测对象在 packages/react-dom-bindings/src/events/plugins/。读旧教程时如果看到 react-dom/src/events/plugins/SimpleEventPlugin.js,在 19 里要换成新路径。

packages/react-dom-bindings/src/(4 个子目录,共 60+ 文件)
子目录 关键文件 职责
client/ ReactFiberConfigDOM.js HostConfig 全集 ,7027 行。createInstance(:534)、commitUpdate(:999)、appendChild、prepareForCommit、supportsMutation 等全在这里
ReactDOMComponent.js 3430 行。属性 diff(updateProperties(:1518))、setProp(:397)、样式处理、事件属性绑定
ReactDOMComponentTree.js 内部实例 ↔ Fiber 的映射,findDOMNode 的底座
ReactDOMContainer.js 容器节点操作(clearContainer 等)
ReactDOMInput.js / ReactDOMSelect.js / ReactDOMTextarea.js / ReactDOMOption.js 受控表单组件的值同步逻辑
inputValueTracking.js 输入框值追踪(restoreControlledState 的依据)
CSSPropertyOperations.js / CSSShorthandProperty.js style 对象的序列化与简写展开
validateDOMNesting.js HTML 嵌套合法性校验(DEV 警告的出处)
setTextContent.js / ToStringValue.js 文本内容设置与值转字符串
ReactDOMSelection.js / ReactInputSelection.js 选区保存与恢复(prepareForCommit 用)
ReactDOMUpdatePriority.js 事件优先级 → Lane 的映射表
ReactDOMSrcObject.js / estimateBandwidth.js 19 新,srcObject 与带宽估算(Suspensey 相关实验)
escapeSelectorAttributeValueInsideDoubleQuotes.js / getActiveElement.js / getNodeForCharacterOffset.js / HTMLNodeType.js / DOMNamespaces.js / DOMAccessibilityRoles.js 小型工具文件
events/ DOMPluginEventSystem.js 1010 行,事件委托核心(1.4.5 节已引)
plugins/(6 个) SimpleEventPlugin(大多数事件)、ChangeEventPlugin(受控输入)、SelectEventPlugin、EnterLeaveEventPlugin、BeforeInputEventPlugin、ScrollEndEventPlugin、FormActionEventPlugin
SyntheticEvent.js 合成事件的基类与 stopPropagation 语义
EventRegistry.js allNativeEvents 注册表
EventListener.js / ReactDOMEventListener.js 底层监听与派发入口
ReactDOMEventReplaying.js / CurrentReplayingEvent.js / FallbackCompositionState.js 输入法组合事件(composition)的重放机制
getListener.js / getEventTarget.js / isEventSupported.js / checkPassiveEvents.js 小型工具
forks/EventListener-www.js Facebook www 变体(TimeSlice 埋点)
server/ ReactFizzConfigDOM.js / ReactFizzConfigDOMLegacy.js Fizz 的 DOM 侧 HostConfig
ReactFlightServerConfigDOM.js Flight 的 DOM 侧配置
ReactDOMFlightServerHostDispatcher.js RSC 服务端的 DOM host dispatcher
escapeTextForBrowser.js HTML 转义
fizz-instruction-set/ Fizz 指令集(流式协议的字节码定义)
shared/ possibleStandardNames.js / getAttributeAlias.js DOM 属性名映射(DEV 警告"Unknown prop"的依据)
validAriaProperties.js / ReactDOMInvalidARIAHook.js ARIA 属性校验
hyphenateStyleName.js / isUnitlessNumber.js / warnValidStyle.js 样式名与单位处理
sanitizeURL.js / isAttributeNameSafe.js 安全过滤(javascript: URL 拦截在这里)
isCustomElement.js 自定义元素判定(决定用 setAttribute 还是属性赋值)
ReactDOMFormActions.js / ReactControlledValuePropTypes.js / ReactDOMUnknownPropertyHook.js / ReactDOMNullInputValuePropHook.js / ReactDOMResourceValidation.js / crossOriginStrings.js DEV 校验钩子一族
packages/react-reconciler/src/(82 个文件 + forks/)

这是本系列的主战场,按职责分组(组即"子系统",与后面章节一一对应):

分组 文件 讲解章节
类型与常量 ReactInternalTypes.js(Fiber/FiberRoot/UpdateQueue 类型中枢)、ReactWorkTags.js、ReactFiberFlags.js、ReactHookEffectTags.js、ReactRootTags.js、ReactReconcilerConstants.js、ReactTypeOfMode.js、ReactEventPriorities.js 第 03 章
Fiber 本体 ReactFiber.js(创建工厂)、ReactFiberStack.js(值栈,1.4.5 节已引)、ReactCurrentFiber.js(DEV current fiber) 第 03 章
主循环与调度 ReactFiberWorkLoop.js(5683 行)、ReactFiberRootScheduler.js、ReactFiberLane.js、ReactProfilerTimer.js 第 03、05 章
渲染阶段 ReactFiberBeginWork.js(4514 行)、ReactChildFiber.js(2259 行)、ReactFiberCompleteWork.js(2114 行)、ReactFiberThrow.js、ReactFiberUnwindWork.js、ReactFiberConcurrentUpdates.js(288 行)、ReactFiberTreeReflection.js 第 03、04 章
Hooks ReactFiberHooks.js(5265 行)、ReactFiberAsyncDispatcher.js、ReactFiberCacheComponent.js 第 07 章
组件类型 ReactFiberClassComponent.js、ReactFiberClassUpdateQueue.js、ReactFiberNewContext.js、ReactFiberLegacyContext.js、ReactFiberHostContext.js、ReactFiberPortal.js、ReactFiberFragmentInstance.js、ReactFiberOffscreenComponent.js、ReactFiberActivityComponent.js、ReactFiberSuspenseComponent.js、ReactFiberSuspenseContext.js、ReactFiberTracingMarkerComponent.js、ReactFiberViewTransitionComponent.js、ReactFiberScope.js、ReactFiberHiddenContext.js、ReactFiberThenable.js 第 04、07、08、09 章
commit 阶段 ReactFiberCommitWork.js(5618 行)、ReactFiberCommitHostEffects.js、ReactFiberCommitEffects.js、ReactFiberCommitViewTransitions.js、ReactFiberMutationTracking.js、ReactFiberHydrationContext.js、ReactFiberHydrationDiffs.js、ReactFiberShellHydration.js、ReactFiberPostPaintCallback.js 第 06 章
错误与栈 ReactFiberThrow.js、ReactCapturedValue.js、ReactFiberErrorLogger.js、ReactFiberComponentStack.js、ReactFiberCallUserSpace.js 第 06、14 章
手势与动画 ReactFiberGestureScheduler.js、ReactFiberApplyGesture.js、ReactFiberDuplicateViewTransitions.js 第 08 章
根与对外接口 ReactFiberRoot.js、ReactFiberReconciler.js(第三方渲染器的 API 面)、ReactFiberDevToolsHook.js、ReactFiberAsyncAction.js、ReactFiberTransition.js、ReactFiberTransitionTypes.js、ReactFiberTreeContext.js、ReactFiberAct.js、ReactFiberStrictModeWarnings.js、ReactFiberPerformanceTrack.js、ReactFiberHotReloading.js 各章
工具 getComponentNameFromFiber.js、clz32.js、Scheduler.js(ESM 包装 CommonJS Scheduler 的转发层) ---
forks/ ReactFiberConfig.dom.js(DOM)、.fabric.js(React Native)、.art.js(Canvas)、.noop.js(测试)、.test.js(test-renderer)、.markup.js(实验)、.custom.js(第三方) 1.7.5 节

forks/ReactFiberConfig.dom.js 全文只有两行导出,却是整个平台替换的枢纽:

js 复制代码
// packages/react-reconciler/src/forks/ReactFiberConfig.dom.js:10-11(真实源码)
export * from 'react-dom-bindings/src/client/ReactFiberConfigDOM';
export * from 'react-client/src/ReactClientConsoleConfigBrowser';
packages/scheduler/src/(6 个文件 + forks/)
文件 职责
forks/Scheduler.js 613 行,主实现(第 05 章主角):最小堆、时间切片、MessageChannel 调度
forks/SchedulerNative.js React Native 变体
forks/SchedulerPostTask.js 基于 scheduler.postTask() 的变体
forks/SchedulerMock.js 测试用的假时钟实现(1.3.7 节的测试体系靠它)
SchedulerMinHeap.js 任务队列的最小堆实现(约 120 行)
SchedulerPriorities.js 五级优先级常量
SchedulerProfiling.js Profiler 模式下的额外追踪
SchedulerFeatureFlags.js + forks/ 三个变体 enableProfiling 等开关的 www/native 变体

一个值得注意的事实:Scheduler 的全部源码不到 1500 行,却是 React 并发能力的地基。第 05 章会把它逐行讲完------这是整个系列里性价比最高的一章。

1.6.10 react-server-dom-* 与 react-devtools-* 家族

react-server-dom-*:一套协议的六种打包器适配

packages/ 下与 RSC 相关的包共 8 个(含 react-server 与 react-client 两个底座)。1.6.5 节已给过概览表,这里补充它们之间的真实差异。

先说共性:所有 react-server-dom-* 包的 src/ 结构都一样------client/、server/、shared/ 三个子目录加一个 *References.js 文件。差异只在两处:

  1. ReactFlight*References.js :每个包一个,负责把"客户端引用 / 服务端引用"的注册与解析对接到自己的打包器。比如 react-server-dom-webpack/src/ReactFlightWebpackReferences.js 对接 webpack 的模块系统,react-server-dom-parcel/src/ReactFlightParcelReferences.js 对接 Parcel。
  2. loader/register 文件 :Node 环境下用哪个加载器。ReactFlightWebpackNodeLoader.js、ReactFlightTurbopackNodeRegister.js、ReactFlightUnbundledNodeRegister.js、ReactFlightESMNodeLoader.js------无打包器场景(unbundled / esm)连 Node 加载器都要自己实现,这是它与 webpack 系最大的不同。

逐个说明:

包 差异点 阅读建议
react-server-dom-webpack 功能最全,有 ReactFlightWebpackPlugin.js(webpack 插件) RSC 章节的主样本
react-server-dom-turbopack 与 webpack 版结构对称,替换的是 Turbopack 的引用解析 只在与 webpack 版对比时读
react-server-dom-parcel 同上,Parcel 适配 一般不必读
react-server-dom-esm 面向浏览器原生 ESM:ReactFlightESMReferences.js + ReactFlightESMNodeLoader.js 想看"没有打包器的 Flight 长什么样"时读
react-server-dom-unbundled 面向无打包器的 Node:需要 NodeRegister.js 显式注册 理解 Flight 协议底线的好样本
react-server-dom-fb Facebook 内部变体(ReactDOMServerFB.js),不走 npm 忽略
react-server 底座包 :Fizz(ReactFizzServer.js 6706 行)与 Flight 服务端(ReactFlightServer.js 7047 行)的真正实现都在这里 第 12 章主角
react-client 客户端底座 :ReactFlightClient.js 与 19 个 ReactFlightClientConfig.*.js fork 第 12 章

react-client/src/forks/ 下有 19 个 ReactFlightClientConfig.<shortName>.js------这个数量本身就是证据:Flight 客户端必须为每种"运行环境 × 打包器"组合出一份配置 (浏览器、Node、Edge、Bun、ESM、FB......),命名与 scripts/shared/inlinedHostConfigs.js 的 shortName 一一对应(1.7.5 节会讲这个对应关系如何驱动构建)。

react-devtools-*:9 个包的独立应用群

1.6.7 节提到"react-devtools-*(11 个)",以本仓库实测为准:packages/ 下 devtools 相关目录共 9 个 (8 个 react-devtools-* + react-devtools 本体),且 react-devtools-extensions 内部还按浏览器分为 chrome/、firefox/、edge/ 三个子目录。它们与渲染链路完全解耦,属于独立应用,一般不必源码级阅读,但角色值得搞清楚:

包 角色
react-devtools 独立 Electron 应用("Use react-devtools outside of the browser"),含 app.js / preload.js
react-devtools-core 上述应用的核心(backend.js / standalone.js)
react-devtools-shell 开发用的外壳(webpack-server.js、e2e 页面)
react-devtools-shared 前后端共享代码:src/backend/(与 React 运行时通信)、src/frontend/(UI)
react-devtools-extensions 浏览器扩展产物(chrome / firefox / edge 三套 manifest)
react-devtools-facade "Building blocks for querying React runtime state"------可编程查询 React 运行时状态的库
react-devtools-fusebox 旧版扩展的兼容层(fusebox 打包器痕迹)
react-devtools-inline 把 DevTools 嵌入任意网站(Embed react-devtools within a website)
react-devtools-cdt-mcp 19.3 新增:向 chrome-devtools-mcp 注册 React 检查与性能工具的桥接

一个与源码阅读直接相关的事实:DevTools 与 React 运行时的通信不走公开 API,而是走 ReactSharedInternals 上挂的 __REACT_DEVTOOLS_GLOBAL_HOOK__(在 react-reconciler/src/ReactFiberDevToolsHook.js 里注入)。这也是 1.6.2 节那个超长导出名存在的原因------DevTools 必须与 React 同版本发布,才能安全地读这些内部字段。

一个数字修正:40 而不是 38

1.3.1 节说"packages/ 下共 38 个目录"。实测(ls -d packages/*/ 配合 package.json 存在性检查)为 40 个 workspace 成员 (根 package.json 的 workspaces: ["packages/*"])。差异来自 devtools 家族:若把 9 个 devtools 包全计入,则为 40。本节所有清单以实测为准;本系列后续章节引用包数量时,一律用"40 个 workspace 成员(其中 15 个发布到 npm,见 ReactVersions.js 的 stablePackages + experimentalPackages)"这个口径。


1.7 渲染器与协调器的解耦:HostConfig

这是 React 架构里最重要的一个设计决策,理解了它,后面所有章节都会顺。

1.7.1 为什么需要解耦

React 要同时支持 DOM、React Native、Canvas、测试环境。如果 reconciler 里直接写 document.createElement,那每加一个平台就要 fork 一份整个 reconciler------那是几千行核心算法,不可能维护四份。

所以 React 把"渲染逻辑"和"平台操作"切开:

  • 渲染逻辑(不可移植,必须唯一一份):树怎么 diff、优先级怎么排、副作用怎么标记、commit 分几个阶段。
  • 平台操作(每个平台一份):创建节点、插入节点、删节点、改属性、读属性。

1.7.2 HostConfig 接口

平台操作被抽象成一组函数,React 内部叫 HostConfig。reconciler 只 import 这组函数,从不直接碰 DOM。

以 ReactFiberConfigDOM.js(packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js)为例,它导出的核心函数包括:

分类 函数
创建 createInstance、createTextInstance
挂载属性 setInitialProperties、finalizeInitialChildren
树操作 appendInitialChild、appendChild、insertBefore、removeChild、appendChildToContainer、insertInContainerBefore、removeChildFromContainer
更新 commitUpdate、commitTextUpdate
查询 prepareForCommit、resetAfterCommit、clearContainer、getChildHostContext
能力声明 supportsMutation、supportsPersistence、supportsHydration、isHostHoistableType

reconciler 侧的用法长这样(ReactFiberCommitWork.js 里):

js 复制代码
import {
  supportsMutation,
  commitUpdate,
  commitMount,
  ...
} from './ReactFiberConfig';

注意它 import 的是 ./ReactFiberConfig------一个不包含任何实现的转发文件。真正的实现在编译期被替换成对应平台的版本。

1.7.3 ReactFiberConfig 的编译期替换

这是最精妙的一环。packages/react-reconciler/src/ 下有几个几乎为空的文件:

  • ReactFiberConfig.js ------ 转发到 DOM 版
  • ReactFiberConfigNative.js ------ 转发到 RN 版
  • ReactFiberConfigART.js ------ 转发到 Canvas 版
  • ReactFiberConfigNoop.js ------ 转发到 noop 版

构建时,scripts/rollup/ 下的配置根据目标平台把 ReactFiberConfig 替换成对应文件。于是同一份 reconciler 源码,能产出四个 npm 包。

flowchart TD SRC[&#34;react-reconciler/src/*<br/>(唯一一份引擎源码)&#34;] subgraph cfg[&#34;编译期选择 HostConfig&#34;] F1[&#34;ReactFiberConfig.js<br/>→ ReactFiberConfigDOM.js&#34;] F2[&#34;ReactFiberConfigNative.js&#34;] F3[&#34;ReactFiberConfigART.js&#34;] F4[&#34;ReactFiberConfigNoop.js&#34;] end OUT1[&#34;react-dom&#34;] OUT2[&#34;react-native-renderer&#34;] OUT3[&#34;react-art&#34;] OUT4[&#34;react-noop-renderer&#34;] SRC --> F1 --> OUT1 SRC --> F2 --> OUT2 SRC --> F3 --> OUT3 SRC --> F4 --> OUT4

1.7.4 这一决策的代价

公平地说,解耦不是免费的:

  1. 间接层带来性能开销 。每次 DOM 操作都要多一次函数调用,而且很难被内联。React 19 在 ReactFiberCommitHostEffects.js 里又包了一层 commitHostUpdate / commitHostPlacement 来做错误边界和 DEV 上下文注入------这是三层调用。
  2. debug 变难。调用栈里塞满了转发函数。
  3. 平台适配有隐藏契约 。HostConfig 不只是"提供几个函数",还有时序要求(比如 prepareForCommit 必须在 mutation 前调用、resetAfterCommit 必须在 mutation 后调用)。违反契约不会报错,只会出诡异的 bug。

但这些代价,相对于"四份 reconciler"的维护成本,是完全值得的。这是 React 能做到"Learn Once, Write Anywhere"的工程基础。


1.8 一次更新的完整生命周期

现在把前面所有概念串起来。假设页面上有一个按钮,用户点击它,触发 setCount(c => c + 1)。从这一瞬间到屏幕更新,源码里发生了什么?

1.8.1 宏观流程图

sequenceDiagram participant U as 用户交互 participant E as 事件系统 participant F as Fiber/WorkLoop participant S as Scheduler participant H as HostConfig(DOM) participant B as 浏览器 U->>E: click 事件 E->>F: dispatchSetState → scheduleUpdateOnFiber F->>F: markRootUpdated(写 root.pendingLanes) F->>F: ensureRootIsScheduled → 微任务 Note over F: processRootScheduleInMicrotask<br/>markStarvedLanesAsExpired(防饥饿)<br/>getNextLanes(选车道) F->>S: scheduleCallback(priority, performWorkOnRootViaSchedulerTask) Note over S: MessageChannel 宏任务<br/>workLoop(5ms 时间切片) S->>F: performWorkOnRoot(root, lanes) F->>F: renderRootConcurrent / renderRootSync Note over F: workLoop → performUnitOfWork<br/>beginWork(向下协调)<br/>completeWork + bubbleProperties(向上冒泡) F->>F: commitRoot Note over F: ① before mutation<br/>② mutation(改 DOM,root.current 切换)<br/>③ layout(ref/lifecycle) F->>H: insertBefore / setProp / ... H->>B: 真实 DOM 变更 B-->>U: 屏幕更新

这张图是整套系列的骨架。下面逐段拆开。

1.8.2 触发:scheduleUpdateOnFiber

setCount 内部并不是"直接重新渲染"。它调用 dispatchSetStateInternal(ReactFiberHooks.js),最终走到 scheduleUpdateOnFiber(ReactFiberWorkLoop.js:987)。

这个函数只做一件关键的事:把"有活要干"这个事实登记到 FiberRoot 上。

js 复制代码
// 概念示意(真实实现见 ReactFiberWorkLoop.js:987 与 ReactFiberLane.js)
export function scheduleUpdateOnFiber(root, fiber, lane) {
  markRootUpdated(root, lane);          // 把 lane 写进 root.pendingLanes
  ensureRootIsScheduled(root);          // 确保这个 root 被调度
}

注意这里没有任何"开始渲染"的动作。React 把"状态变了"和"什么时候渲染"彻底分开。这个分开是并发特性的前提。

1.8.3 调度:从微任务到 Scheduler

ensureRootIsScheduled(ReactFiberRootScheduler.js)之后,调度链路是这样的:

text 复制代码
ensureRootIsScheduled
  → 注册一个微任务 processRootScheduleInMicrotask
      ├── markStarvedLanesAsExpired(root, now)   // 防饥饿:给等待太久的 lane 标记过期
      ├── getNextLanes(root)                     // 选出本次要渲染的 lane 集合
      └── scheduleTaskForRootDuringMicrotask
            ├── (同步 lane)→ 微任务末尾 flushSyncWorkAcrossRoots_impl
            └── (异步 lane)→ scheduleCallback(priority, performWorkOnRootViaSchedulerTask)

几个关键点:

  1. 用微任务(microtask)而非同步执行 :好处是同一个事件回调里的多次 setState 会被合并成一次渲染(batching)。这是 React 18 以来自动批处理的基础。
  2. markStarvedLanesAsExpired 是防饥饿机制:如果一个低优先级更新一直被高优先级更新抢占,它会计算"过期时间",过期后强制走同步渲染。第 03 章详述。
  3. getNextLanes 决定这次渲染哪些 lane,它有"中断保护"------正在渲染的 Transition 不会被后来的 DefaultLane 抢占。第 03 章详述。
  4. 真正交给 Scheduler 的只是"一个回调" :performWorkOnRootViaSchedulerTask。Scheduler 不知道 React 的存在,它只管在合适的时间调这个回调。

1.8.4 render:构建 workInProgress 树

Scheduler 在合适时机回调 performWorkOnRoot,它根据 lane 类型决定同步还是并发:

js 复制代码
// ReactFiberWorkLoop.js:1168(真实源码)
const shouldTimeSlice =
  (!forceSync &&
    !includesBlockingLane(lanes) &&
    !includesExpiredLane(root, lanes)) ||
  checkIfRootIsPrerendering(root, lanes);

let exitStatus: RootExitStatus = shouldTimeSlice
  ? renderRootConcurrent(root, lanes)
  : renderRootSync(root, lanes, true);

然后进入 work loop。这个阶段完全不碰 DOM,只在内存里构建一棵新的 Fiber 树(workInProgress 树):

text 复制代码
prepareFreshStack
  → workInProgress = createWorkInProgress(root.current, null)
  → workLoop
      ├── performUnitOfWork
      │     ├── beginWork(current, wip, lanes)   ← 向下:协调子节点
      │     └── 若 next === null → completeUnitOfWork
      │             ├── completeWork             ← 创建 DOM 实例/标记 Update
      │             └── bubbleProperties         ← 向上冒泡 subtreeFlags / childLanes
      └── 直到 workInProgress === null

beginWork 返回的第一个子 Fiber 会成为新的 workInProgress,如此一路向下;没有子节点了就 completeUnitOfWork 沿 sibling → return 向上回溯。这是一个深度优先遍历 ,用 workInProgress 这一个全局变量 + Fiber 上的 child/sibling/return 三个指针模拟了递归栈。第 03 章会详细讲为什么这么做(答案:可中断)。

并发模式下,workLoopConcurrentByScheduler 每跑一个单元就检查一次 shouldYield(),超时就保存现场、退出,把控制权还给浏览器。"保存现场"之所以可行,全靠 workInProgress 这个全局变量------它精确记录了"我遍历到哪了"。

1.8.5 commit:三子阶段

render 完成后,commitRoot 接手。React 的注释把设计意图写得很明白(ReactFiberWorkLoop.js:3848):

js 复制代码
// The commit phase is broken into several sub-phases. We do a separate pass
// of the effect list for each phase: all mutation effects come before all
// layout effects, and so on.

三个阶段:

子阶段 入口函数 做什么 DOM 状态
Before Mutation commitBeforeMutationEffects getSnapshotBeforeUpdate、clearContainer 旧 DOM,只读
Mutation commitMutationEffects 插入/删除/更新节点、卸载 ref、执行 componentWillUnmount;结束后 root.current = finishedWork 变成新 DOM
Layout commitLayoutEffects useLayoutEffect、componentDidMount/Update、挂载 ref 新 DOM,current 已切换

root.current 的切换时机有精确要求,源码注释(ReactFiberWorkLoop.js:4047):

js 复制代码
// The work-in-progress tree is now the current tree. This must come after
// the mutation phase, so that the previous tree is still current during
// componentWillUnmount, but before the layout phase, so that the finished
// work is current during componentDidMount/Update.
root.current = finishedWork;

翻译:卸载时要能访问旧树,挂载时要能访问新树。这一行代码的顺序,决定了 componentWillUnmount 和 componentDidMount 各自能看到什么。第 06 章会把这写成一张时序表。

commit 结束后,useEffect 的清理与执行还没发生------它们被推迟到 commit 之后的一个 Scheduler 任务里异步执行(ReactFiberWorkLoop.js:3806):

js 复制代码
scheduleCallback(NormalSchedulerPriority, () => {
  // ...
  flushPassiveEffects();
  return null;
});

这就是 useEffect 是"异步"的真相:它不是"另一个线程",而是"commit 之后排进 Scheduler 的一个普通任务"。第 07 章详述。

1.8.6 浏览器绘制

DOM 变更完成后,控制权回到浏览器,浏览器在下一个帧绘制。React 19 还能通过 needsPaint / requestPaint 机制让 Scheduler 在浏览器即将绘制时主动让出(Scheduler.js:459 的 shouldYieldToHost)。第 05 章详述。

1.8.7 数据闭环

把整条链路收敛成一句话:

一次点击 → 事件系统找到 fiber → scheduleUpdateOnFiber 只登记 lane → 微任务里选 lane → 交给 Scheduler → Scheduler 在合适时机回调 → performWorkOnRoot 构建 WIP 树(可中断)→ commitRoot 分三阶段把副作用落到 DOM → 浏览器绘制。

这条链上,唯一真正碰 DOM 的地方是 commit 阶段。前面全部在内存里。这就是 React 能做到"渲染可中断"的根本原因------中断只是丢掉半棵内存里的树,不影响屏幕。


1.9 并发渲染心智模型

1.9.1 单线程与长任务

JavaScript 是单线程的。浏览器主线程要同时干:跑你的 JS、样式计算、布局、绘制、响应输入。任何一件占太久,其他全堵住。

传统渲染的问题是:一次大更新会把主线程占住几十甚至上百毫秒,期间用户点击没反应、页面掉帧。React 15 的 Stack Reconciler 就是这样------递归一旦开始就必须跑完。

1.9.2 可中断渲染

React 16 引入 Fiber,核心目的就是把渲染拆成可中断的小片。

做法:把"递归遍历组件树"改成"用一个全局变量 + 三个指针的循环"。每处理完一个 Fiber,就检查一次"要不要让出"。这样渲染就不是一个不可分割的长任务,而是很多个 5ms 左右的小片。

flowchart LR subgraph before[&#34;React 15:Stack Reconciler&#34;] A1[&#34;一次更新<br/>递归跑完 100ms<br/>不可中断&#34;] end subgraph after[&#34;React 16+:Fiber&#34;] B1[&#34;5ms 片 1&#34;] B2[&#34;让出 → 处理输入&#34;] B3[&#34;5ms 片 2&#34;] B4[&#34;让出 → 绘制&#34;] B5[&#34;5ms 片 3<br/>完成&#34;] end before -->|&#34;Fiber 改造&#34;| after

1.9.3 优先级

可中断带来新问题:中断了,什么时候继续?被谁打断?

React 的答案是 Lane(车道)------一个 31 位的位掩码,每个 bit 代表一类工作:

Lane 典型场景
SyncLane flushSync、离散事件(click)的同步更新
InputContinuousLane 连续输入(滚动、拖动、键盘)
DefaultLane 普通 setState
TransitionLanes startTransition 包裹的更新
RetryLanes Suspense 重试
IdleLane 空闲任务

"数值越小优先级越高"。一次渲染可以同时包含多个 lane(位掩码的好处),用 getHighestPriorityLanes 收敛成同一优先级簇一起渲染。

位掩码的巧妙之处在于集合运算是 O(1) 的:

js 复制代码
// ReactFiberLane.js(真实源码)
export function getHighestPriorityLane(lanes: Lanes): Lane {
  return lanes & -lanes;      // 取出最低置位 bit
}
export function mergeLanes(a, b) {
  return a | b;
}

第 03 章会把 31 个 lane 的位布局、饥饿处理、中断保护全部讲透。

1.9.4 优先级不是并行

必须澄清一个常见误解:React 的并发渲染不是多线程。没有 Web Worker,没有并行计算。全部工作仍在主线程上,只是"可以分段、可以让出、可以按优先级决定谁先跑"。

所以:

  • 并发渲染能让你的一次大更新不阻塞用户输入。
  • 并发渲染不能让你的计算量变小。一个必然要算 100ms 的组件,并发模式下还是要算 100ms,只是中间插入了让出。
  • 真正让计算变小的是 React Compiler(第 11 章)和合理的 memo 策略(第 14 章)。

1.10 React 19 的关键变化(能力层概览)

架构层差异已在 1.4.4 节列出。这里从"能力"角度概览,细节见对应章节:

变化 说明 详见
React Compiler 自动插入 memoization,目标是把 React.memo / useMemo / useCallback 变成"不再需要"的概念 第 11 章
Actions <form action={fn}> 与异步 transition;useFormStatus / useActionState 第 13 章
use 原语 统一读取 Context 与 Promise;读 Promise 时可被 Suspense 捕获 第 07、13 章
useOptimistic 乐观更新,关联 transition 完成后自动回滚 第 13 章
ref 作为普通 prop 不再强制 forwardRef;函数 ref 可返回清理函数 第 13 章
文档元数据 组件内渲染 <title> / <meta> / <link> 自动提升到 <head> 第 13 章
<Context> 直接作 Provider 不再必须写 <Context.Provider> 第 13 章
HostConfig 拆分 react-dom-bindings 新包 1.4.4 节
diffProperties 移除 属性 diff 从 render 阶段后移到 commit 阶段 第 04、06 章
移除 defaultProps(函数组件) 让 JSX 默认参数机制接管 第 13 章

1.11 本章源码索引表

主题 文件 关键符号 / 行号
版本真相源 ReactVersions.js ReactVersion = '19.3.1'(:21)、stablePackages(:35)、experimentalPackages(:56)
workspace 配置 package.json workspaces(:3)、packageManager(:163)、build(:143)
react 公开 API packages/react/src/ReactClient.js 导出块(:76-137)
类型中枢 packages/react-reconciler/src/ReactInternalTypes.js Fiber、FiberRoot、UpdateQueue
渲染主循环 packages/react-reconciler/src/ReactFiberWorkLoop.js performUnitOfWork(:3081)、scheduleUpdateOnFiber(:987)、performWorkOnRoot(:1137)、commitRoot(:3725)、root.current 切换(:4047)
调度编排 packages/react-reconciler/src/ReactFiberRootScheduler.js ensureRootIsScheduled(:116)、scheduleTaskForRootDuringMicrotask(:384)
优先级 packages/react-reconciler/src/ReactFiberLane.js getHighestPriorityLane(:756)、mergeLanes(:792)
调度器 packages/scheduler/src/forks/Scheduler.js scheduleCallback(:335)、workLoop(:193)、shouldYieldToHost(:459)
DOM HostConfig packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js createInstance(:534)、commitUpdate(:999)
DOM 入口 packages/react-dom/src/client/ReactDOMRoot.js createRoot、hydrateRoot
属性 diff packages/react-dom-bindings/src/client/ReactDOMComponent.js updateProperties(:1518)、setProp(:397)

1.12 本章 FAQ

Q1:react、react-dom、react-reconciler 到底谁包含谁? A:三者是平级的 npm 包,但逻辑上是分层依赖。react 提供编程模型 API(ReactClient.js);react-dom 提供 DOM 入口与 HostConfig(入口在 react-dom,实现在 react-dom-bindings);react-reconciler 是引擎,被 react-dom 在编译期注入 HostConfig 后打包。最终你 npm i react react-dom,两者各自独立发布、版本号保持一致(19.3.1)。

Q2:为什么 react-reconciler 和 scheduler 的版本号不是 19.3.1? A:因为它们不对外承诺 API 稳定。react-reconciler 是 0.34.0(给第三方渲染器用),scheduler 是 0.28.0(可独立复用)。只有 stablePackages 里跟 ReactVersion 绑定的那些包才跟着 React 大版本走。

Q3:读源码时 if (__DEV__) 要理解吗? A:要理解逻辑,但不要计入性能分析。__DEV__ 是编译期常量,生产构建里这些分支会被整体删除。

Q4:React 19 里 ReactDOMComponent.js 在哪? A:在 packages/react-dom-bindings/src/client/ReactDOMComponent.js。React 19 把 host config 抽到了独立的 react-dom-bindings 包,React 18 里它在 packages/react-dom/src/client/ 下。

Q5:并发渲染会让我的应用变快吗? A:不一定。并发渲染改善的是"响应性"(输入不被长任务阻塞),不是"计算总量"。计算量要靠减少不必要的渲染(第 11 章编译器、第 14 章 memo 策略)来降。

Q6:createElement 和 jsx 有什么区别? A:产物几乎一样,区别在于 jsx 是 React 19 新 JSX transform 的入口,开发环境走 jsxDEV,会附加 source/self 调试信息并做更严格的 key 校验。详见第 02 章。

Q7:为什么 React 要用 31 位的位掩码表示优先级,而不是一个数字? A:因为一次渲染常常要同时处理多类工作,位掩码能用一次位运算完成集合的合并、剔除、取最高优先级。数字模型(React 16 的 expirationTime)无法表达"这批更新属于同一优先级簇,一起渲染"。第 03 章详述。

Q8:ReactSharedInternals 为什么要导出成一个名字那么长的变量? A:那个名字 __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE 本身就是警告------它内部结构随时可能变,只给 DevTools、编译器运行时这类"与 React 同版本发布"的模块用。业务代码碰它会在升级时炸。

Q9:我想读源码,从哪个文件开始最好? A:packages/react-dom/src/client/ReactDOMRoot.js(入口)→ ReactFiberWorkLoop.js 的 performWorkOnRoot(主流程)→ ReactChildFiber.js 的 reconcileChildrenArray(最经典的算法)。配合本系列第 3、4、6 章阅读效果最佳。

Q10:React 19 的"编译器"是默认开启的吗? A:不是默认开启的,需要你在 Babel 配置里显式启用 babel-plugin-react-compiler 并指定 sources。它也不是"运行时魔法",而是构建期代码转换。详见第 11 章。



1.14 附:事件系统的版本演进与"为什么要读源码"(配套第 10 章)

本节原属第 10 章《事件系统》。因为内容偏"版本演进与方法论",而事件系统的实现细节本身已超出单章篇幅上限,故移至本章作为附录阅读。读第 10 章前可先看本节建立历史坐标。

1.2 历史演进(为什么要读源码而不是文档)
版本 事件委托位置 关键变化
React 0.9 ~ 15 document(单一顶层委托) 所有事件挂在 document,通过 ReactEventListener 分发
React 16(Fiber 重写) document 引入 Fiber 树反查,事件系统随 reconciler 重构
React 17 root container(每个 root 单独委托) 不再挂 document;放弃事件池化(pooling)的公开 API;onScroll 等不再冒泡
React 18 root container 并发特性引入事件重放(replay)以支持 Suspense/Activity 阻塞
React 19 root container 事件系统从 react-dom 抽离到独立包 react-dom-bindings;新增 FormActionEventPlugin、ScrollEnd polyfill;强化 hydration 重放与 createEventHandle

值得强调的两个"现代真相":

  • 委托目标已从 document 改为 root container(自 React 17 固化)。这是本章反复提及的"为什么委托到 root 而非 document"问题的答案基点。
  • 事件池化已在 React 17 彻底移除 ,但代码里仍保留了 persist() 空实现与 isPersistent() 恒真,以保证向后引用不报错(详见 4.4 节)。

十四、版本演进深度对照(基于真实提交历史)

要把 React 事件系统的"为什么"讲透,必须把它放在时间轴上看。下面逐版本拆解关键改动,并标注对应源码线索。

14.1 React 15 及更早:document 单一顶层委托

在 Fiber 重写之前,事件系统位于 react/lib/ReactEventListener 与 react/lib/EventPluginHub。核心特征:

  • 所有事件(无论冒泡与否)都被统一挂在 document 上,且只有一个顶层监听器入口 ReactEventListener.dispatchEvent。
  • 维护一个全局的 eventPluginHub 和 listenerBank,按 registrationName 把每个节点的 onClick 等处理器存入二维数组 listenerBank[registrationName][rootNodeID]。注意这里的"按节点 ID 存处理器"------这正是后来 React 16 改成"从 Fiber props 读"之前的方式。
  • 使用事件池(pooling) :EventPool 复用 SyntheticEvent 对象,分发后 release 回池。
  • 缺点:document 级委托在微前端/多实例场景互相污染;事件池导致异步访问陷阱;不支持 Portal(Portal 是 React 16 才引入的)。
14.2 React 16:Fiber 重写,事件系统迁移到 react-dom

React 16 引入 Fiber,事件系统随之重构,但仍挂在 document。关键变化:

  • 事件处理器不再按 rootNodeID 存进 listenerBank,而是随 Fiber 的 memoizedProps 走;分发时用 getListener 从 Fiber 的 DOM 节点反查 props。
  • 引入 getClosestInstanceFromNode(当时叫 getClosestInstanceFromNode)做 DOM→Fiber 反查,为 Portal 与并发打基础。
  • 仍保留事件池(直到 17 才移除)。
14.3 React 17:root container 委托 + 移除事件池(最重要的拐点)

React 17 是一次"无痛升级"大版本,事件系统几乎全部重写,源码从 react-dom/src/events 起步(本章分析的位置),关键改动:

  • 委托目标从 document 改为 root container (listenToAllSupportedEvents 挂在 root)。原因见第 4.2 节(多 root 隔离、Portal 正确穿透、避免污染第三方 document 监听器、精确控制 passive)。
  • 彻底移除事件池 ,合成事件不再复用;persist() 保留为空函数,isPersistent() 恒真。这消除了"异步回调里事件字段被清空"的最大坑。
  • onScroll 等不再向上冒泡 (对齐浏览器原生语义),并引入 nonDelegatedEvents 把 scroll/load/error 等挂到元素自身而不是 root。
  • 原生事件现在用 addEventListener 挂在 root,而非 document;dispatchEvent 前的 findRootNode 逻辑改为 listenToAllSupportedEvents 的 listeningMarker 幂等保证。
14.4 React 18:并发特性引入事件重放(Replay)

为支持 Suspense 流式 SSR 与并发渲染,React 18 在事件系统里加入了阻塞/重放机制:

  • findInstanceBlockingEvent 在 ReactDOMEventListener 中建立,判断事件目标是否落在未 hydrate 的 Suspense/脱水边界内。
  • queueIfContinuousEvent / clearIfContinuousEvent(ReactDOMEventReplaying.js)对连续事件(scroll/mouseover/drag 等)排队,待边界 hydrate 后重放。
  • attemptContinuousHydration / attemptSynchronousHydration 在分发时主动提升被阻塞边界的 hydrate 优先级。
14.5 React 19:独立包 + 新插件

React 19 把事件系统从 react-dom 整体抽到 react-dom-bindings(本章所有源码路径已据此更新)。新增与调整:

  • FormActionEventPlugin :支撑 <form action={fn}> 的渐进增强提交(Server Actions 的客户端落地),extractEvents 检测原生 submit 并把 action 函数通过 startHostTransition 调度(详见第 6.x 节)。
  • ScrollEndEventPlugin :在浏览器不原生支持 scrollend 时给出 polyfill(基于 scroll + 防抖 200ms 推断)。
  • CreateEventHandle(createEventHandle/useEvent 雏形)继续以 IS_EVENT_HANDLE_NON_MANAGED_NODE 标志走独立路径。
  • 持续打磨 hydration 重放与 getCurrentEventPriority 联动。
14.6 浏览器怪癖:事件系统为什么要做这么多"脏活"

源码里大量注释指向真实浏览器 bug,理解它们才能明白"合成事件"不是过度设计:

  1. Firefox 右键 click :FF 会在右键点击时派发 click(button === 2),SimpleEventPlugin 在 click 分支 if (nativeEvent.button === 2) return;(SimpleEventPlugin.js:104-106)过滤。
  2. Firefox keypress 功能键 :FF 对所有功能键也发 keypress,通过 getEventCharCode(nativeEvent) === 0 过滤(SimpleEventPlugin.js:73-82)。
  3. IE 的 keyCode 与 key :getEventKey/translateToKey(SyntheticEvent.js:313-409)做历史映射,把 Esc→Escape、Spacebar→' ' 等归一化。
  4. 老 Safari 非交互元素 click 不冒泡 :trapClickOnNonInteractiveElement 设 node.onclick = noop(ReactDOMComponent.js:375-386)唤醒冒泡。
  5. IE9 input 事件在删除文本时不触发 :ChangeEventPlugin 通过 propertychange 监听 + 重写 .value setter 来兜底(ChangeEventPlugin.js:145-202)。
  6. SVG <use> 事件目标归一化 :getEventTarget 处理 correspondingUseElement(getEventTarget.js:22-25)。
  7. Safari 文本节点触发事件 :getEventTarget 把 TEXT_NODE 上抛到 parentNode(getEventTarget.js:27-29)。
  8. wheel/touchstart/touchmove 的 passive 干预 :addTrappedEventListener 在 root 上以 passive 注册,避免滚动卡顿(DOMPluginEventSystem.js:476-490)。

这些"脏活"如果被交给每个应用手写,成本极高------这正是 React 事件系统存在的根本价值之一。

14.7 委托模型三方对比
维度 原生 addEventListener React ≤15 (document) React 17+ (root container)
监听挂载点 每个元素 document 每个 root container
监听器数量 O(节点数) O(事件类型) O(事件类型)(常数级)
多实例隔离 自然隔离 互相污染 自然隔离
Portal 支持 需手动 不支持 原生支持
处理器存储 闭包 listenerBank 数组 Fiber props 句柄
重渲染成本 需 remove/add 需 remove/add 零(仅换 props)
浏览器差异 自行处理 React 处理 React 处理
异步访问事件 原生 池化陷阱 安全

本章所有结论均落在 react-19.3.1 仓库真实源码之上;行号基于 19.3.1 发行版对应的源码结构,若后续源码微调请以实际为准。


1.13 本章小结

本章建立了整套系列的地基,核心要点:

  1. React 的三层架构 :react(编程模型)→ react-reconciler(平台无关渲染引擎)→ 渲染器(react-dom 等平台落地),三层通过 HostConfig 在编译期解耦。这是"Learn Once, Write Anywhere"的工程基础。
  2. 版本体系 :本系列基于 19.3.1;三条发布渠道(@latest / @canary / @experimental)对应三种稳定性承诺;stablePackages 是承诺稳定的完整清单。
  3. 一次更新的完整链路 :事件 → scheduleUpdateOnFiber(只登记 lane)→ 微任务选 lane → Scheduler → performWorkOnRoot 构建 WIP 树 → commitRoot 三阶段落 DOM → 浏览器绘制。只有 commit 阶段碰 DOM,这是渲染可中断的前提。
  4. 并发渲染的本质:单线程下的"可中断 + 优先级 + 时间切片",不是并行计算。Lane 位掩码是优先级系统的核心数据结构。
  5. React 19 的架构级变化 :HostConfig 拆分到 react-dom-bindings、diffProperties 移除、commitRoot 拆分、编译器成为正式能力。

下一章,我们从最底层的数据结构开始:React 元素 。它是整条链路的起点------UI = f(state) 里 f 的返回值。

相关推荐
鶴哥只手遮天1 小时前
React 19.3.1 源码深度剖析-第 4 章 Reconciliation 协调算法(Diff 算法全解)
前端·react.js·开源
小小龙学IT1 小时前
Nginx 开源 Web 服务器与反向代理深度解
前端·nginx·开源
miofly1 小时前
腾讯开源 Hy Memory 2.0 记忆系统,3B 激活参数超越 claude-sonnet-5-5
开源·github
鶴哥只手遮天1 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 2 章 响应式内幕(一):`effect` 与依赖追踪机制
前端·vue.js·开源
鶴哥只手遮天1 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 06 章 编译器内幕(二):`transform` 管线与结构指令
前端·vue.js·开源
鶴哥只手遮天1 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 09 章 编译器内幕(五):compiler-sfc的SFC解析与<script setup> 编译
前端·vue.js·开源
码银2 小时前
【web】基于HTML/CSS,JavaScript + Three.js做3D流程图
前端·css·html
鶴哥只手遮天2 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 17 章 运行时内幕(五):`runtime-dom`、自定义元素与内置组件
前端·vue.js·开源
鶴哥只手遮天2 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 13 章 运行时组件系统:实例、props、emit、slots 与生命周期
前端·vue.js·开源