React 19.3.1 源码深度剖析-第 1 章 总览与架构哲学
本章是整套系列的地基。它要回答的不是"React 怎么用",而是四个更底层的问题: React 的代码为什么被切成这个样子?一次点击之后,数据在源码里究竟走了哪些地方? 并发渲染到底在并发什么?以及 React 19 相比 18,架构层面真正变了什么?
1.1 这本系列要解决什么问题
大多数开发者对 React 的理解,停留在"写组件、调 Hook、传 props"这一层。这层知识足以应付日常业务,但只要遇到下面这几类问题,就会立刻不够用:
- 性能问题 :组件明明没变却疯狂重渲染;一次输入卡顿;列表一长就掉帧。要定位它,你必须知道
updateReducerImpl是怎么按 Lane 过滤更新的、bubbleProperties是怎么把childLanes往上冒泡的、useMemo的依赖比较到底比较的是什么。 - 诡异 bug :
useEffect清理函数执行时机不对;ref 拿到旧值;key用了数组下标导致输入框内容串味;严格模式下 effect 跑了两遍。这些现象的根因,全部埋在 reconciler 的链表与双缓冲实现里。 - 升级困惑 :React 19 把
forwardRef变成可选项、把defaultProps从函数组件上移除、引入了use与useActionState、把编译器作为正式能力推出。这些不是"新 API",而是架构层面的转向。 - 知其然而不知其所以然 :知道"用
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 导出的东西。
四条推荐阅读路径:
- 只想彻底搞懂渲染流程(最推荐,覆盖面最广):第 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 代码时只需要掌握三件事,就够用了:
- 类型注解写法 :
function f(x: number): string------和 TypeScript 几乎一样。 opaque type:不透明类型,对外只暴露名字不暴露结构。比如opaque type Lanes = number,意思是"Lanes 是个数字,但你不能把它当普通数字随便算"。mixed:等价于 TypeScript 的unknown。
packages/react-reconciler/src/ReactInternalTypes.js 是整套 reconciler 的类型中枢,Fiber、FiberRoot、UpdateQueue 等核心类型都定义在那里。第 03 章会大量引用它。
1.3.3 packages/*/src 与 build/ 的映射
源码目录里的文件,不是最终发布到 npm 的文件。构建流程大致是:
关键点:
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% 的时间花在"找"。四个高频模式:
- 从调用反查定义 。想知道
scheduleUpdateOnFiber在哪定义:全局搜export function scheduleUpdateOnFiber,命中ReactFiberWorkLoop.js:987。React 的源码组织很规整------导出函数几乎都是export function开头,极少用对象包装。 - 查一个 flag 的定义点与全部使用点 。比如
enableViewTransition:先搜export const enableViewTransition,命中packages/shared/ReactFeatureFlags.js:90(值为true,即 19.3.1 默认开启);再搜enableViewTransition全库,能看到react/src/ReactStartTransition.js、ReactFiberCommitViewTransitions.js等所有消费点。判断一个功能是否"可用",就看它 flag 的布尔值。 - 查 HostConfig 的消费者 。搜
from './ReactFiberConfig',命中 reconciler 里十几个文件------这就是"平台无关引擎"的准确边界:凡是 import 这个模块的文件,都在和平台打交道。 - 查测试行为 。搜
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,测试跑在三种环境里:
- jsdom (
scripts/jest/ReactJSDOMEnvironment.js):react-dom的行为测试,147 个测试文件。 - node :文件头标注
@jest-environment node,用于无 DOM 的纯逻辑测试,比如ReactHooksWithNoopRenderer-test.js。 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',
};
注意几个有意思的细节:
react-reconciler的版本是0.34.0,不是19.3.1。这说明 React 团队刻意不让这个包对外承诺 API 稳定------它的导出是给第三方渲染器(React Native、react-three-fiber 等)用的,属于"能用但不保证"的灰色地带。scheduler是0.28.0,同样独立版本号。它是可以被单独复用的调度原语。use-sync-external-store是1.7.0,use-subscription是1.13.0。这两个是"可以单独安装、用于 React 18 以下版本兼容"的辅助包。React 19 里它们的功能已经内置,但包仍然单独发布,供旧版 React 使用。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 博客都更接近真相。
一张图总结演进逻辑
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 有两个硬性要求,源码里到处都能看到它们:
f必须是纯函数 :同样的 state 必须得到同样的 UI 描述。所以 render 期间不能改外部变量、不能发请求、不能读时钟。React 19 用 React Compiler 的 Rules of React 校验 +$dispatcherGuard运行时守卫来强制这一点(第 11 章)。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 声明式的代价
必须公平地说,声明式不是没有代价:
- 你需要一套 diff 算法,而任何 diff 都有启发式,启发式就有猜错的时候。第 04 章会看到 React 的 diff 算法为了 O(n) 复杂度,牺牲了"最优移动序列"------它只保证正确,不保证移动次数最少。
- 你需要不可变数据流 ,否则
f不纯,diff 也会失效。 - 抽象层让调试变难:出问题时你看到的是 React 的内部结构,不是你的代码。
React 19 引入编译器,某种程度上就是在还第一笔债:既然 diff 依赖"props 没变"这个前提,那就让编译器自动帮你把 props 变稳定。
1.6 monorepo 全貌
1.6.1 核心三层:react / react-reconciler / 渲染器
React 的架构可以浓缩成三层,这三层是理解整套代码的骨架:
关键洞察:第 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,
};
从这个文件能读出大量信息:
Fragment/Profiler/StrictMode/Suspense本质上只是Symbol。它们被as重命名导出,说明这些"组件"其实是类型标记,JSX 编译成ReactElement时把type设成这个 Symbol,reconciler 靠$$typeof识别。第 02 章详述。- 命名即稳定性契约 :带
unstable_前缀的(unstable_SuspenseList、unstable_Scope、unstable_startGestureTransition)是实验 API;不带的是稳定 API。React 19 把Activity、ViewTransition提到了不带前缀的位置,说明它们已转正。 use和useOptimistic、useActionState、useEffectEvent在列表里------这四个是 React 19 的新编程原语,第 07、13 章讲解。ReactSharedInternals被导出成一个名字极长的变量 :__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE。这个名字本身就是警告。它就是第 07 章的核心------ReactSharedInternals.H是全局 dispatcher 指针,Hooks 全靠它分发。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 最重要的结构变化,必须讲清楚:
react-dom:面向用户的入口。createRoot、hydrateRoot、flushSync、renderToString、renderToReadableStream都在这里。react-dom-bindings:React 19 新拆出来的包,装着所有"和 DOM 细节打交道"的代码------HostConfig、属性 diff、事件系统。
为什么要拆?三个现实原因:
- 让
react-server也能复用 DOM 逻辑。RSC 场景下服务端不需要完整 DOM,但需要一部分 DOM 相关的字符串/属性处理。 - 让
react-dom的入口更小,减少用户侧打包体积。 - 把"平台知识"集中管理,将来要做 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 包的组织方式:
- import 段(:10-66) :从 21 个内部文件引入实现。注意 import 的来源揭示了文件划分------
./ReactBaseClasses、./ReactHooks、./jsx/ReactJSXElement......一个文件一个职责。 Children对象组装(:68-74) :五个函数从./ReactChildren引入后组装成一个对象导出,而不是五个独立导出。- 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');
三个值得注意的点:
- 用
Symbol.for而不是Symbol()。这意味着注册字符串是跨 realms 共享的------iframe 里的 React 和主窗格的 React 能识别同一个元素。这是 micro-frontend 场景的地基。 REACT_ELEMENT_TYPE用的是react.transitional.element,而旧的REACT_LEGACY_ELEMENT_TYPE是react.element。React 19 同时认这两种(第 02 章会讲为什么需要"过渡型"元素类型)。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 |
关于这组的三个事实,源码里都能坐实:
ReactSharedInternals的字段就是全局状态的清单 (ReactSharedInternalsClient.js:26-63):H(Hooks dispatcher)、A(Cache dispatcher)、T(当前 Transition)、S(transition 结束回调)、G(手势回调,enableGestureTransition时才有),DEV 下还有actQueue、thrownErrors、getCurrentStack等。第 07 章讲 Hooks、第 08 章讲并发,本质上都在讲这几个字段的读写时序。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 文件。差异只在两处:
ReactFlight*References.js:每个包一个,负责把"客户端引用 / 服务端引用"的注册与解析对接到自己的打包器。比如react-server-dom-webpack/src/ReactFlightWebpackReferences.js对接 webpack 的模块系统,react-server-dom-parcel/src/ReactFlightParcelReferences.js对接 Parcel。- 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 包。
1.7.4 这一决策的代价
公平地说,解耦不是免费的:
- 间接层带来性能开销 。每次 DOM 操作都要多一次函数调用,而且很难被内联。React 19 在
ReactFiberCommitHostEffects.js里又包了一层commitHostUpdate/commitHostPlacement来做错误边界和 DEV 上下文注入------这是三层调用。 - debug 变难。调用栈里塞满了转发函数。
- 平台适配有隐藏契约 。HostConfig 不只是"提供几个函数",还有时序要求(比如
prepareForCommit必须在 mutation 前调用、resetAfterCommit必须在 mutation 后调用)。违反契约不会报错,只会出诡异的 bug。
但这些代价,相对于"四份 reconciler"的维护成本,是完全值得的。这是 React 能做到"Learn Once, Write Anywhere"的工程基础。
1.8 一次更新的完整生命周期
现在把前面所有概念串起来。假设页面上有一个按钮,用户点击它,触发 setCount(c => c + 1)。从这一瞬间到屏幕更新,源码里发生了什么?
1.8.1 宏观流程图
这张图是整套系列的骨架。下面逐段拆开。
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)
几个关键点:
- 用微任务(microtask)而非同步执行 :好处是同一个事件回调里的多次
setState会被合并成一次渲染(batching)。这是 React 18 以来自动批处理的基础。 markStarvedLanesAsExpired是防饥饿机制:如果一个低优先级更新一直被高优先级更新抢占,它会计算"过期时间",过期后强制走同步渲染。第 03 章详述。getNextLanes决定这次渲染哪些 lane,它有"中断保护"------正在渲染的 Transition 不会被后来的 DefaultLane 抢占。第 03 章详述。- 真正交给 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 左右的小片。
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,理解它们才能明白"合成事件"不是过度设计:
- Firefox 右键 click :FF 会在右键点击时派发
click(button === 2),SimpleEventPlugin 在click分支if (nativeEvent.button === 2) return;(SimpleEventPlugin.js:104-106)过滤。 - Firefox keypress 功能键 :FF 对所有功能键也发
keypress,通过getEventCharCode(nativeEvent) === 0过滤(SimpleEventPlugin.js:73-82)。 - IE 的
keyCode与key:getEventKey/translateToKey(SyntheticEvent.js:313-409)做历史映射,把Esc→Escape、Spacebar→' '等归一化。 - 老 Safari 非交互元素 click 不冒泡 :
trapClickOnNonInteractiveElement设node.onclick = noop(ReactDOMComponent.js:375-386)唤醒冒泡。 - IE9
input事件在删除文本时不触发 :ChangeEventPlugin 通过propertychange监听 + 重写.valuesetter 来兜底(ChangeEventPlugin.js:145-202)。 - SVG
<use>事件目标归一化 :getEventTarget处理correspondingUseElement(getEventTarget.js:22-25)。 - Safari 文本节点触发事件 :
getEventTarget把TEXT_NODE上抛到parentNode(getEventTarget.js:27-29)。 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 本章小结
本章建立了整套系列的地基,核心要点:
- React 的三层架构 :
react(编程模型)→react-reconciler(平台无关渲染引擎)→ 渲染器(react-dom等平台落地),三层通过 HostConfig 在编译期解耦。这是"Learn Once, Write Anywhere"的工程基础。 - 版本体系 :本系列基于
19.3.1;三条发布渠道(@latest/@canary/@experimental)对应三种稳定性承诺;stablePackages是承诺稳定的完整清单。 - 一次更新的完整链路 :事件 →
scheduleUpdateOnFiber(只登记 lane)→ 微任务选 lane → Scheduler →performWorkOnRoot构建 WIP 树 →commitRoot三阶段落 DOM → 浏览器绘制。只有 commit 阶段碰 DOM,这是渲染可中断的前提。 - 并发渲染的本质:单线程下的"可中断 + 优先级 + 时间切片",不是并行计算。Lane 位掩码是优先级系统的核心数据结构。
- React 19 的架构级变化 :HostConfig 拆分到
react-dom-bindings、diffProperties移除、commitRoot拆分、编译器成为正式能力。
下一章,我们从最底层的数据结构开始:React 元素 。它是整条链路的起点------UI = f(state) 里 f 的返回值。