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 的边界在哪里?

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

相关推荐
web小白成长日记18 分钟前
企业级 Vue3 + Element Plus 主题定制架构:从“能用”到“好用”的进阶之路
前端·架构
APIshop1 小时前
Python 爬虫获取 item_get_web —— 淘宝商品 SKU、详情图、券后价全流程解析
前端·爬虫·python
风送雨1 小时前
FastMCP 2.0 服务端开发教学文档(下)
服务器·前端·网络·人工智能·python·ai
XTTX1101 小时前
Vue3+Cesium教程(36)--动态设置降雨效果
前端·javascript·vue.js
LYFlied2 小时前
WebGPU与浏览器边缘智能:开启去中心化AI新纪元
前端·人工智能·大模型·去中心化·区块链
Setsuna_F_Seiei2 小时前
2025 年度总结:人生重要阶段的一年
前端·程序员·年终总结
model20052 小时前
alibaba linux3 系统盘网站迁移数据盘
java·服务器·前端
han_3 小时前
从一道前端面试题,谈 JS 对象存储特点和运算符执行顺序
前端·javascript·面试
aPurpleBerry3 小时前
React 01 目录结构、tsx 语法
前端·react.js
jayaccc3 小时前
微前端架构实战全解析
前端·架构