01|从 Monorepo 到发布产物:React 仓库全景与构建链路
本栏目是「React 源码剖析」系列:我会以源码为证据、以架构为线索,讲清 React 从构建、运行时到生态边界的关键设计。开源仓库:https://github.com/facebook/react
引言:为什么第一篇要从"构建"讲起?
很多人读 React 源码,第一站就冲进 react-reconciler 或 ReactFiberWorkLoop。这当然刺激,但很容易陷入一个问题:
- 你看到的源文件 并不等于 用户安装的
react/react-dom包里的代码。 - 同一个入口(比如
react-dom/client)会根据 release channel(stable/experimental) 、bundle type(NODE/ESM/FB_WWW/RN 等) 、甚至 平台 fork 生成不同产物。
所以第 1 篇我想做的是:先把这套仓库如何"出货"讲清楚------你才能在后续文章里精准回答:"我现在看的这段实现,最终会以什么形式被打进 npm 包?在什么条件下生效?"
核心概念:读懂 React 构建链路需要的几组术语
1) Workspaces:仓库不是一个包,而是一组包
根目录 package.json 声明了 Yarn workspaces:
json
{
"private": true,
"workspaces": [
"packages/*"
]
}
这意味着 packages/react、packages/react-dom、packages/scheduler 等都是独立包,但在同一个仓库内协作开发。
为什么这么做?(Trade-offs)
- 好处:
- 原子式重构:跨包改动可以一次提交、一次 CI。
- 共享内部工具链:构建、lint、测试都能统一。
- 代价:
- 工具链复杂度显著上升:你需要一个"中央打包系统"把一堆包组合成最终产物。
2) Release channel:同一份源码要产出多个"风味"
根目录 package.json 的 build 脚本指向:
json
{
"scripts": {
"build": "node ./scripts/rollup/build-all-release-channels.js"
}
}
也就是说:真正的总入口不是 rollup build,而是一个定制脚本,会同时处理 stable 与 experimental。
3) Bundle type:同一入口要面向不同运行时与分发格式
在 scripts/rollup/bundles.js 里,React 把输出矩阵显式编码为 bundleTypes:
js
const bundleTypes = {
NODE_ES2015: 'NODE_ES2015',
ESM_DEV: 'ESM_DEV',
ESM_PROD: 'ESM_PROD',
NODE_DEV: 'NODE_DEV',
NODE_PROD: 'NODE_PROD',
NODE_PROFILING: 'NODE_PROFILING',
// ... RN / FB / BrowserScript / DTS 等
};
并把包分为 moduleTypes:
js
const moduleTypes = {
ISOMORPHIC: 'ISOMORPHIC',
RENDERER: 'RENDERER',
RENDERER_UTILS: 'RENDERER_UTILS',
RECONCILER: 'RECONCILER',
};
这其实是一种"显式构建 DSL":
- 构建不是"扫目录自动推导",而是一个手写的产物矩阵。
- 好处:确定性强、可控、能承载历史包袱(FB/RN/WWW)
- 代价:维护成本高,但 React 选择用"工程纪律"换取"发布稳定性"。
源码依次解析:从 yarn build 到 npm 包落地
下面我们沿着真实代码路径走一遍。
Step 1:顶层入口 yarn build 做了什么?
在根 package.json,build 指向 build-all-release-channels.js:
json
{
"scripts": {
"build": "node ./scripts/rollup/build-all-release-channels.js"
}
}
这行看似平平无奇,但它隐含了一个关键事实:React 的发布不是"构建一次"。
Step 2:build-all-release-channels.js ------ 用环境变量切换 release channel
文件:scripts/rollup/build-all-release-channels.js
2.1 先生成一个"占位版本号"
js
const {
ReactVersion,
stablePackages,
experimentalPackages,
canaryChannelLabel,
rcNumber,
} = require('../../ReactVersions');
const sha = String(spawnSync('git', ['rev-parse', 'HEAD']).stdout).slice(0, 8);
const PLACEHOLDER_REACT_VERSION =
ReactVersion + '-' + canaryChannelLabel + '-' + sha + '-' + dateString;
fs.writeFileSync(
'./packages/shared/ReactVersion.js',
`export default '${PLACEHOLDER_REACT_VERSION}';\n`
);
逐段解读(Code Review 视角):
ReactVersions.js是"单一事实源"(single source of truth),里面写死了ReactVersion = '19.3.0',并列出了哪些包会被发布。PLACEHOLDER_REACT_VERSION是一个构建期 hack :先把版本写进源码(packages/shared/ReactVersion.js),等产物出来后再做字符串替换。
为什么这么写?(Trade-offs)
- 理想做法是"构建参数注入版本",但 React 的构建产物矩阵太大、历史包袱太多。
- 他们选择了一个非常务实的策略:
- 先保证产物能统一打出来
- 再通过
updatePlaceholderReactVersionInCompiledArtifacts进行替换
这不是优雅,但足够稳定,且 CI 可控。
2.2 用 RELEASE_CHANNEL 驱动底层构建
js
function buildForChannel(channel, total, index) {
const {status} = spawnSync(
'node',
['./scripts/rollup/build.js', ...process.argv.slice(2)],
{
stdio: ['pipe', process.stdout, process.stderr],
env: {
...process.env,
RELEASE_CHANNEL: channel,
CI_TOTAL: total,
CI_INDEX: index,
},
}
);
if (status !== 0) {
process.exit(status);
}
}
关键点:
- release channel 并不是通过不同分支/不同源码目录实现 ,而是通过
RELEASE_CHANNEL让后续脚本走不同分支(包括 entry fork、产物命名、产物筛选)。 - 同时支持
CI_TOTAL/CI_INDEX的分片构建,说明构建矩阵很大,CI 并行是刚需。
Step 3:scripts/rollup/build.js ------ 构建核心管线
文件:scripts/rollup/build.js
3.1 默认 experimental:这很"React"
js
const RELEASE_CHANNEL = process.env.RELEASE_CHANNEL;
const __EXPERIMENTAL__ =
typeof RELEASE_CHANNEL === 'string'
? RELEASE_CHANNEL === 'experimental'
: true;
点评:
- 没传
RELEASE_CHANNEL时默认 experimental。 - 这和 React 团队的研发节奏匹配:主分支优先面向实验特性,稳定渠道由流水线兜底。
3.2 buildEverything():把 bundles×bundleTypes 展开成一张大表
js
async function buildEverything() {
if (!argv['unsafe-partial']) {
await asyncRimRaf('build');
}
let bundles = [];
for (const bundle of Bundles.bundles) {
bundles.push(
[bundle, NODE_ES2015],
[bundle, ESM_DEV],
[bundle, ESM_PROD],
[bundle, NODE_DEV],
[bundle, NODE_PROD],
[bundle, NODE_PROFILING],
// ... 还有 RN/FB/BrowserScript/DTS
);
}
bundles = bundles.filter(([bundle, bundleType]) => {
return !shouldSkipBundle(bundle, bundleType);
});
for (const [bundle, bundleType] of bundles) {
if (bundle.prebuild) {
runShellCommand(bundle.prebuild);
}
await createBundle(bundle, bundleType);
}
await Packaging.copyAllShims();
await Packaging.prepareNpmPackages();
console.log(Stats.printResults());
if (!forcePrettyOutput) {
Stats.saveResults();
}
}
buildEverything();
逐块解读:
Bundles.bundles来自scripts/rollup/bundles.js,是手写的入口清单。bundles.push([bundle, NODE_DEV], ...)说明构建矩阵是通过"笛卡尔展开"生成的。shouldSkipBundle会根据 CLI 参数、release channel 等筛掉不需要的组合。- 构建结束后不是"完事",而是进入
Packaging.*阶段:- copy shims(为不同环境准备 shim 文件)
- prepareNpmPackages(把 build 目录变成可发布结构)
这段代码很像一个经典的流水线:
- Plan(列清单) → Build(生成产物) → Package(封装发布) → Report(输出统计)
你甚至可以把它当成一个 Build System 的最小实现。
Step 4:bundles.js ------ "产物矩阵"是如何描述的?
我们看一条非常典型的 bundle:ReactDOM。
文件:scripts/rollup/bundles.js
js
{
bundleTypes: [NODE_DEV, NODE_PROD],
moduleType: RENDERER,
entry: 'react-dom',
global: 'ReactDOM',
minifyWithProdErrorCodes: true,
wrapWithModuleBoundaries: true,
externals: ['react'],
},
逐字段解释:
entry: 'react-dom':这会被require.resolve(bundle.entry)解析为真正的入口文件。externals: ['react']:明确告诉构建系统 ReactDOM 不打包 React。wrapWithModuleBoundaries: true:提示后续会在 bundle 外面包一层边界(用于调试/错误隔离/内部工具)。minifyWithProdErrorCodes: true:生产构建会把错误信息转成错误码(你在生产 React 里看到的"Minified React error #xxx"就是这条链路的一部分)。
这背后是一个很重要的架构判断:
React 的构建描述不是 Rollup 配置文件,而是一个"面向领域的描述结构"(类似 manifest)。
- 好处:
- 把"我们要发布什么"从"怎么打包"里分离出来
- 容易做校验、统计、差异化处理
- 代价:
- 需要自己维护 Rollup 插件链与各种 corner case
Step 5:createBundle() ------ Rollup 配置不是关键,关键是"外部依赖策略"
文件:scripts/rollup/build.js
5.1 外部依赖:来自 externals + package.json deps/peerDeps
js
const peerGlobals = Modules.getPeerGlobals(bundle.externals, bundleType);
let externals = Object.keys(peerGlobals);
const deps = Modules.getDependencies(bundleType, bundle.entry);
externals = externals.concat(deps);
再看 scripts/rollup/modules.js 的 getDependencies:
js
function getDependencies(bundleType, entry) {
const packageJson = require(entry.replace(/(\/.*)?$/, '/package.json'));
return Array.from(
new Set([
...Object.keys(packageJson.dependencies || {}),
...Object.keys(packageJson.peerDependencies || {}),
])
);
}
点评:
- React 的策略是:
- 明确写在
bundles.js的externals - 再加上该包自身
dependencies/peerDependencies - 共同决定最终 Rollup 的
external()行为
- 明确写在
这比"让 Rollup 自动分析"更稳。
5.2 关键防线:禁止从依赖里 import /src/
js
external(id) {
const containsThisModule = pkg => id === pkg || id.startsWith(pkg + '/');
const isProvidedByDependency = externals.some(containsThisModule);
if (isProvidedByDependency) {
if (id.indexOf('/src/') !== -1) {
throw Error(
'You are trying to import ' +
id +
' but ' +
externals.find(containsThisModule) +
' is one of npm dependencies, ' +
'so it will not contain that source file. You probably want ' +
'to create a new bundle entry point for it instead.'
);
}
return true;
}
return !!peerGlobals[id];
}
这段代码非常"架构师味道":它是在用构建系统强制一个团队约束:
- 你可以依赖别的包,但不能跨包去偷用它的源码实现(
/src/)。 - 真想共享实现?请把它变成正式的入口 ,进入
bundles.js的产物矩阵。
这类约束如果放在 Code Review 里会很累(人会漏)。
放在构建系统里就变成了"自动化守门人"。
5.3 Tree-shaking 的现实主义:用 importSideEffects 显式声明
在 scripts/rollup/modules.js:
js
const importSideEffects = Object.freeze({
fs: HAS_NO_SIDE_EFFECTS_ON_IMPORT,
// ...
scheduler: HAS_NO_SIDE_EFFECTS_ON_IMPORT,
react: HAS_NO_SIDE_EFFECTS_ON_IMPORT,
'react-dom': HAS_NO_SIDE_EFFECTS_ON_IMPORT,
});
而在 createBundle() 中:
js
const importSideEffects = Modules.getImportSideEffects();
const pureExternalModules = Object.keys(importSideEffects).filter(
module => !importSideEffects[module]
);
treeshake: {
moduleSideEffects: (id, external) =>
!(external && pureExternalModules.includes(id)),
propertyReadSideEffects: false,
},
为什么要这么写?
Tree-shaking 最大的坑之一是:
- 某些依赖"看起来没用",但 import 时有副作用(polyfill、全局 patch 等)。
React 的做法是:
- 不赌运气
- 不让 Rollup 自己猜
- 用一个白名单/黑名单(这里用布尔含义)显式声明
代价是维护成本;收益是产物行为可预测。
Step 6:Babel 配置:Flow 是源码事实,Babel 负责"可发布 JS"
根目录 babel.config.js:
js
module.exports = {
plugins: [
'@babel/plugin-syntax-jsx',
'@babel/plugin-transform-flow-strip-types',
['@babel/plugin-proposal-class-properties', {loose: true}],
'syntax-trailing-function-commas',
['@babel/plugin-proposal-object-rest-spread', {loose: true, useBuiltIns: true}],
['@babel/plugin-transform-template-literals', {loose: true}],
'@babel/plugin-transform-literals',
'@babel/plugin-transform-arrow-functions',
// ...
],
};
逐块解读:
@babel/plugin-transform-flow-strip-types是关键:React 源码大量使用 Flow 标注,发布前必须剥离类型。- 一堆
loose: true的配置不是"随便写写",而是为了:- 避免引入过多 runtime helper
- 让产物体积更可控
Trade-offs:
loose模式会牺牲部分语义严格性,但 React 作为底层库更在乎:- 产物体积
- 运行时开销
- 兼容性与可控性
这就是"库作者"和"应用作者"的不同:库作者常常要为用户承担工程复杂度。
Step 7:Wrapper:为什么 React 要给 bundle 外面再包一层?
文件:scripts/rollup/wrappers.js
比如 NODE_DEV 的 top-level wrapper:
js
[NODE_DEV](source, globalName, filename, moduleType) {
return `'use strict';
if (process.env.NODE_ENV !== "production") {
(function() {
${source}
})();
}`;
},
这里的设计动机通常有三类:
- 隔离作用域:避免 bundle 顶层变量污染。
- 分支裁剪:让生产环境不会执行 DEV-only 逻辑。
- 对齐不同环境的全局约定 :比如
__DEV__、process.env.NODE_ENV。
这也解释了你后续读源码时经常看到的模式:
if (__DEV__) { ... }if (process.env.NODE_ENV !== 'production') { ... }
它们并不只是"运行时判断",很多时候也是为了让构建工具能做 dead code elimination。
Step 8:Packaging:产物不是"一个 JS 文件",而是一整个 npm 包目录
文件:scripts/rollup/packaging.js
js
async function prepareNpmPackage(name) {
await Promise.all([
asyncCopyTo('LICENSE', `build/node_modules/${name}/LICENSE`),
asyncCopyTo(
`packages/${name}/package.json`,
`build/node_modules/${name}/package.json`
),
asyncCopyTo(
`packages/${name}/README.md`,
`build/node_modules/${name}/README.md`
),
asyncCopyTo(`packages/${name}/npm`, `build/node_modules/${name}`),
]);
filterOutEntrypoints(name);
const tgzName = (
await asyncExecuteCommand(`npm pack build/node_modules/${name}`)
).trim();
await asyncRimRaf(`build/node_modules/${name}`);
await asyncExtractTar(getTarOptions(tgzName, name));
unlinkSync(tgzName);
}
async function prepareNpmPackages() {
if (!existsSync('build/node_modules')) {
return;
}
const builtPackageFolders = readdirSync('build/node_modules').filter(
dir => dir.charAt(0) !== '.'
);
await Promise.all(builtPackageFolders.map(prepareNpmPackage));
}
逐块解读:
- 先把
LICENSE / package.json / README.md / npm/拷贝到build/node_modules/<pkg>。 - 然后
npm pack------ 这一步非常关键:- 它让最终结构与 "真实发布到 npm 的内容" 完全一致
- 避免你本地构建出来的目录结构和 npm 上实际拿到的不一致
Trade-offs:
npm pack + 解包看起来绕,但它把"发布行为"变成可复现的流程。- 对于 React 这种基础设施项目,可复现(reproducible build) 往往比"少一步命令"更重要。
一图看懂:React 从源码到产物的主流程
用 Mermaid 把本文的主流程串起来(你可以把它当成后续阅读的导航图)。
stable
experimental
根目录 package.json: yarn build
scripts/rollup/build-all-release-channels.js
写入 packages/shared/ReactVersion.js 占位版本
releaseChannel?
spawn node scripts/rollup/build.js RELEASE_CHANNEL=stable
spawn node scripts/rollup/build.js RELEASE_CHANNEL=experimental
buildEverything(): 遍历 Bundles.bundles x bundleTypes
createBundle(): 生成 rollupConfig - externals - plugins - output
rollup.rollup + write
Packaging.copyAllShims
Packaging.prepareNpmPackages 复制元数据 + npm pack + 解包
最终产物: build/node_modules/* 以及 facebook-www / react-native 等
总结:本篇你应该带走的 4 个设计思想
- 构建系统是架构的一部分
React 并不是"写完 JS 就完了",它的行为很大一部分由 release channel、bundle type、fork 和 wrapper 决定。读源码时,如果你不清楚它最终会被打进哪个产物,很容易在后续文章里误解"这段代码到底什么时候会执行"。
- 用 manifest(
bundles.js)管理复杂度,而不是堆 Rollup 配置
React 把构建矩阵写成结构化数据:entry/moduleType/bundleTypes/externals/...。这是典型的"配置即产品定义"。
- 把团队规范下沉到构建阶段
external() 里禁止 import 依赖包的 /src/,本质上是在构建系统里做"跨包边界治理"。它比 Code Review 更可靠。
- 用可复现流程对抗发布复杂度
npm pack 再解包看似笨,但能最大化保证"你构建出来的就是你发布出去的"。对于 React 这种底层库,发布一致性比优雅更重要。
下一篇预告
第 2 篇我们会从 packages/react-dom/client.js 开始,顺着 createRoot 走进 Reconciler:
createRoot到底创建了什么?- 为什么 ReactDOM 看起来像入口,实际上只是一个"路由层"?
updateContainer与FiberRoot的边界在哪里?
(到那里,我们就正式进入"引擎室"。)