01|从 Monorepo 到发布产物:React 仓库全景与构建链路

01|从 Monorepo 到发布产物:React 仓库全景与构建链路

本栏目是「React 源码剖析」系列:我会以源码为证据、以架构为线索,讲清 React 从构建、运行时到生态边界的关键设计。开源仓库:https://github.com/facebook/react

引言:为什么第一篇要从"构建"讲起?

很多人读 React 源码,第一站就冲进 react-reconcilerReactFiberWorkLoop。这当然刺激,但很容易陷入一个问题:

  • 你看到的源文件 并不等于 用户安装的 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/reactpackages/react-dompackages/scheduler 等都是独立包,但在同一个仓库内协作开发。

为什么这么做?(Trade-offs)

  • 好处:
    • 原子式重构:跨包改动可以一次提交、一次 CI。
    • 共享内部工具链:构建、lint、测试都能统一。
  • 代价:
    • 工具链复杂度显著上升:你需要一个"中央打包系统"把一堆包组合成最终产物。

2) Release channel:同一份源码要产出多个"风味"

根目录 package.jsonbuild 脚本指向:

json 复制代码
{
  "scripts": {
    "build": "node ./scripts/rollup/build-all-release-channels.js"
  }
}

也就是说:真正的总入口不是 rollup build,而是一个定制脚本,会同时处理 stableexperimental

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.jsonbuild 指向 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.jsgetDependencies

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.jsexternals
    • 再加上该包自身 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 个设计思想

  1. 构建系统是架构的一部分

React 并不是"写完 JS 就完了",它的行为很大一部分由 release channel、bundle type、fork 和 wrapper 决定。读源码时,如果你不清楚它最终会被打进哪个产物,很容易在后续文章里误解"这段代码到底什么时候会执行"。

  1. 用 manifest(bundles.js)管理复杂度,而不是堆 Rollup 配置

React 把构建矩阵写成结构化数据:entry/moduleType/bundleTypes/externals/...。这是典型的"配置即产品定义"。

  1. 把团队规范下沉到构建阶段

external() 里禁止 import 依赖包的 /src/,本质上是在构建系统里做"跨包边界治理"。它比 Code Review 更可靠。

  1. 用可复现流程对抗发布复杂度

npm pack 再解包看似笨,但能最大化保证"你构建出来的就是你发布出去的"。对于 React 这种底层库,发布一致性比优雅更重要。


下一篇预告

第 2 篇我们会从 packages/react-dom/client.js 开始,顺着 createRoot 走进 Reconciler:

  • createRoot 到底创建了什么?
  • 为什么 ReactDOM 看起来像入口,实际上只是一个"路由层"?
  • updateContainerFiberRoot 的边界在哪里?

(到那里,我们就正式进入"引擎室"。)

相关推荐
飘尘4 分钟前
前端转型全栈(Java后端)的快速上手指引
前端·后端·全栈
一颗烂土豆14 分钟前
Meshopt 压缩深度解析,为什么它比 Draco 更快
前端·javascript·webgl
YFF菲菲兔1 小时前
调度系统和调和系统的桥梁
react.js
浏览器工程师1 小时前
AI Agent 接浏览器任务,先别让它一路点到底
前端·后端
雨季mo浅忆1 小时前
VSCode自动格式化三要素
前端
爱勇宝2 小时前
深扒 Anthropic 1680 位工程师简历:应届生几乎没机会,AI 公司最缺的不是博士
前端·后端·程序员
kyriewen3 小时前
同事每天催我 Code Review,我写了个脚本让 AI 替我 review PR——现在他反过来催 AI 了
前端·javascript·ai编程
user20585561518135 小时前
Windows 项目安装时报 `node-sass` 错误,如何快速处理
前端
LiaCode5 小时前
Redis 在生产项目的使用
前端·后端
LiaCode5 小时前
一天学完 redis 的爽翻版核心知识总结
前端·后端