单仓库下的四十模块 —— React Monorepo 工程架构拆解

前司做的是做商城 SaaS 的,在 Monorepo 里,一个同事下午在 packages/utils 里加了个 console.log 调试用,当时 CR 代码眼花了没注意,结果就这一行代码。部署到线上后。线上炸了。
因为 packages/utils 被二十三个上层包依赖。二十三个包的产物,全部带着那行 console.log。而我们的日志收集系统刚刚在那周刚扩容,带宽是平时的三倍。结果呢?日志管道被灌爆,连带着监控告警系统一起崩了。交易系统因为拿不到健康检查响应,被负载均衡判定为"不健康",一台接一台地摘掉。
第一反应是回滚。但是二十三个包,发布节奏各不相同。有的每小时一版,有的每天一版,有的周更。我们花了四个小时才把所有带毒产物清理干净。

我当时就有个疑问,为什么 React 也是同样是四十多个模块,React 是怎么做到十年都不崩的?


一、四十个模块的 Monorepo,是一座活火山

Monorepo 的诱惑谁都懂。代码放一起,重构可以大刀阔斧地改,版本天然对齐,一个新功能从底层到上层可以一次提交搞定。想想就很爽。

爽到你真正经历了几年业务迭代之后,才发现这是座活火山,有的时候真的会被原地飞升:

改了底层一个模块,上层全炸。 packages/shared 里动了个工具函数,CI 里三十个包同时报错。你修了 A,B 又挂了;修了 B,C 出警告。改一行代码,三天泡在构建失败的泥潭里。

同一个功能,在不同环境下表现不同。 浏览器里正常,React Native 里白屏,服务端渲染直接抛异常。不是业务代码的问题,是底层工具在不同平台下的行为差异------但同一个包被打到了所有平台。

新功能上线后发现问题,回退比登天还难。 没有开关,没有灰度,一上线就是全量。出问题怎么办?发 hotfix。但 hotfix 又要走完整的 CI 流程。

构建产物混乱到令人发指。 一个包要同时输出 ESM(给 Vite/Webpack5)、CJS(给 Node.js)、UMD(给浏览器 <script> 标签),还要输出 .d.ts(给 TypeScript 类型检查),还要分 DEV 版(带警告)和 PROD 版(精简)。一个包变八个文件,四十个包就是三百多个文件。管理这三百多个文件,手工?脚本?还是真的去服务器上香?

React 团队的解法,不是回避这些痛苦,而是用极其严格的工程纪律,把每一种痛苦都关进了制度的笼子 这是我的浅显的理解。


二、四层骨架,撑起四十个模块

打开 packages/ 目录,四十多个模块横在那里。但不是乱堆的------React 的模块有严格的层次,有点像人的骨架,每一块骨头都知道自己该长在哪。

flowchart TD subgraph L1["第零层:地基"] direction LR S["shared/"] SCH["scheduler"] end subgraph L2["第一层:核心"] direction LR R["react<br/>组件模型、Hooks、JSX"] end subgraph L3["第二层:渲染"] direction LR RR["react-reconciler<br/>Diff + 调度"] RDOM["react-dom<br/>浏览器 DOM"] RN["react-native-renderer<br/>Native 视图"] RS["react-server<br/>服务端 HTML 流"] end subgraph L4["第三层:工具"] direction LR ESL["eslint-plugin-react-hooks"] DT["react-devtools*"] JT["jest-react"] end L1 --> L2 L2 --> L3 L1 --> L3 L2 --> L4 style L1 fill:#1a3a4a,stroke:#58a6ff,color:#fff style L2 fill:#1a4a2a,stroke:#3fb950,color:#fff style L3 fill:#4a3a1a,stroke:#f0883e,color:#fff style L4 fill:#3a1a3a,stroke:#a371f7,color:#fff

第零层 shared/ + scheduler :不依赖任何 React 包,是整个系统的地基。shared/ReactSymbols.js 里的 REACT_ELEMENT_TYPEshared/shallowEqual.js 里的浅比较------这些工具函数太底层了,react 包自己都指着它们活着。

shared/ 不是一个独立的 npm 包。去 npm 上搜 @react/shared,搜不到。它是通过编译时内联 的方式被打进各个包里的。Rollup 构建 react-dom 时,会把 shared/ 里的模块直接嵌入产物。结果就是用户安装 react-dom 时,不需要额外装一个 shared 包------零运行时依赖。

第一层 react :唯一的核心定义包。组件模型(ComponentPureComponent)、Hooks API、Context、memo/lazy/Suspense。注意:react 本身不碰 DOM,不碰 Native。它只产出虚拟描述(ReactElement)。DOM 怎么画?那是别人的事。

第二层 渲染器层react-reconciler 是调度中枢------Fiber 树在这里生成、Diff 在这里做、优先级在这里排序。react-dom 负责把虚拟树刷到浏览器 DOM;react-native-renderer 刷到 iOS/Android 的 Native 视图;react-server 在服务端生成 HTML 字符串流。这三个渲染器互不认识,但都认 react-reconciler 当老大。

第三层 工具层:ESLint 插件、DevTools、测试辅助------辅助开发,不参与运行时。

铁律只有一条:下层不知道上层存在react-dom 可以 importreact,但 react 代码里不能出现 react-dom 的导入。这条规则被写进了构建系统------敢打破,Rollup 直接报 Cannot find module

为什么这条规则如此重要?因为一旦反向依赖成立,循环依赖就出现了。A 依赖 B,B 依赖 A,构建时谁先谁后?代码变更时影响范围怎么追踪?在我经历的那个事故里,根因就是 utils 被二十三个包依赖,但 utils 自己也不知道谁在用它------没有任何约束告诉开发的那个人:"你改的这一行,会炸掉二十三个包。"

React 用层次划分回答了这个根本问题:每个模块的位置决定了它的影响半径


三、shared/ 的编译时内联------看不见的血液循环

packages/shared/ 是 React Monorepo 里最被低估的模块。几十个工具文件,却是整个系统的"血液循环系统"。但真正有意思的,是它如何在不成为独立包的情况下,被四十多个模块共享

答案在 Rollup 的构建配置里。scripts/rollup/build.js 的 pipeline 中,有一个关键环节:

javascript 复制代码
// 伪代码示意 Rollup 的 resolveId 钩子
resolveId(source, importer) {
  if (source.startsWith('shared/')) {
    // 把 'shared/ReactSymbols' 解析到本地文件系统路径
    return path.resolve('packages/shared', source + '.js');
  }
  // ...
}

react-dom/src/client/ReactDOMRoot.js 写下这样一行:

javascript 复制代码
import { REACT_ELEMENT_TYPE } from 'shared/ReactSymbols';

Rollup 不会把它当成外部依赖(external)。它会找到 packages/shared/ReactSymbols.js,把里面的内容直接内联react-dom 的产物里。最终用户拿到的 react-dom.production.min.js 中,REACT_ELEMENT_TYPE 的定义就在文件里------不需要从任何外部包加载。

这种设计的代价是什么?代码重复REACT_ELEMENT_TYPE 同时存在于 react.jsreact-dom.jsreact-native-renderer.js 中------同样的常量定义,被打包进了三个不同的文件。

但收益也极其清晰:

  1. 零运行时依赖 。用户装 reactreact-dom,只有两个包。没有 @react/shared 这种东西拖累安装体验。
  2. 版本自治shared/ 的改动不需要发独立的版本号。它跟着引用它的包一起发布,永远"版本对齐"。
  3. 环境隔离 。同一个 shared/ReactFeatureFlags.js,在不同包中可以被替换成不同的实现。这是 fork 系统的基础------下面会深入。

这种"用体积换简单性"的取舍,是 React 工程判断的一个典型缩影。 Facebook 有全世界最复杂的构建系统,但他们选择让用户的安装体验保持极简。代码重复的那几 KB,在 gzip 后几乎可以忽略。


四、Fork 系统------同一个文件,八种活法

这是 React Monorepo 架构中最精妙的设计,没有之一。

React 跑在多少种环境上?浏览器、Node.js(SSR)、React Native(iOS/Android)、Facebook 内部的 www 系统、Facebook 内部的 Native 系统......每种环境的特性开关值都不同。

比如 enableTransitionTracing------在浏览器开源版里它是 false,但在 Facebook 内部,React 团队想提前试用,所以它应该被打开。怎么办?

维护五个分支?发五个版本?React 的选择是:同一个 import 路径,在不同构建目标下加载不同的文件

flowchart TD subgraph Fork["Fork 路由:import 'shared/ReactFeatureFlags.js' 实际加载谁?"] direction TB IMP["import 路径:<br/>shared/ReactFeatureFlags.js"] E1["entry = react-native-renderer/fabric<br/>→ forks/ReactFeatureFlags.native-fb.js"] E2["entry = eslint-plugin-react-hooks<br/>→ forks/ReactFeatureFlags.eslint-plugin.www.js"] E3["entry = react-test-renderer<br/>→ forks/ReactFeatureFlags.test-renderer.js"] E4["bundleType = FB_WWW_*<br/>→ forks/ReactFeatureFlags.www.js"] E5["bundleType = RN_FB_*<br/>→ forks/ReactFeatureFlags.native-fb.js"] EF["fallback:<br/>→ 默认 ReactFeatureFlags.js"] end IMP --> E1 IMP --> E2 IMP --> E3 IMP --> E4 IMP --> E5 IMP --> EF style IMP fill:#1a3a4a,stroke:#58a6ff,stroke-width:2px,color:#fff style E1 fill:#2c3e50,color:#c9d1d9 style E2 fill:#2c3e50,color:#c9d1d9 style E3 fill:#2c3e50,color:#c9d1d9 style E4 fill:#2c3e50,color:#c9d1d9 style E5 fill:#2c3e50,color:#c9d1d9 style EF fill:#2c3e50,color:#c9d1d9

这个魔术的实现,藏在 scripts/rollup/forks.js 里。

4.1 Fork 路由的决策链

forks.js 里有一个冻结的对象,键是原始文件路径,值是一个函数。这个函数接收当前构建的 bundleType(产物格式)、entry(入口模块)、dependencies(依赖列表)、_moduleType(模块角色),返回一个字符串------实际要加载的文件路径。

ReactFeatureFlags.js 的路由逻辑,这是一段值得逐行品味的代码:

javascript 复制代码
// https://github.com/facebook/react/blob/main/scripts/rollup/forks.js
'./packages/shared/ReactFeatureFlags.js': (bundleType, entry) => {
  switch (entry) {
    // React Native Fabric 渲染器
    case 'react-native-renderer/fabric':
      switch (bundleType) {
        case RN_FB_DEV:
        case RN_FB_PROD:
        case RN_FB_PROFILING:
          return './packages/shared/forks/ReactFeatureFlags.native-fb.js';
        case RN_OSS_DEV:
        case RN_OSS_PROD:
        case RN_OSS_PROFILING:
          return './packages/shared/forks/ReactFeatureFlags.native-oss.js';
        default:
          throw Error(`Unexpected entry (${entry}) and bundleType (${bundleType})`);
      }
    // ESLint 插件
    case 'eslint-plugin-react-hooks/src/index.ts':
      switch (bundleType) {
        case FB_WWW_DEV:
        case FB_WWW_PROD:
        case FB_WWW_PROFILING:
          return './packages/shared/forks/ReactFeatureFlags.eslint-plugin.www.js';
      }
      return null;  // 非 FB 环境用默认
    // 测试渲染器
    case 'react-test-renderer':
      switch (bundleType) {
        case RN_FB_DEV:
        case RN_FB_PROD:
        case RN_FB_PROFILING:
          return './packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js';
        case FB_WWW_DEV:
        case FB_WWW_PROD:
        case FB_WWW_PROFILING:
          return './packages/shared/forks/ReactFeatureFlags.test-renderer.www.js';
      }
      return './packages/shared/forks/ReactFeatureFlags.test-renderer.js';
    // 默认情况:根据 bundleType 判断
    default:
      switch (bundleType) {
        case FB_WWW_DEV:
        case FB_WWW_PROD:
        case FB_WWW_PROFILING:
          return './packages/shared/forks/ReactFeatureFlags.www.js';
        case RN_FB_DEV:
        case RN_FB_PROD:
        case RN_FB_PROFILING:
          return './packages/shared/forks/ReactFeatureFlags.native-fb.js';
      }
  }
  return null;  // 没有匹配的 fork,用默认文件
}

这段代码展现了一种极其严谨的思维模式。

精确到 entry 级别的路由 。不是粗略地"FB 环境用 www.js",而是 react-native-renderer/fabricnative-fb.jseslint-plugin-react-hookseslint-plugin.www.jsreact-test-renderertest-renderer.js。每个入口都有自己的特性开关配置,因为它们暴露的功能集不同,需要控制的开关也不同。

显式的错误处理 。当 entrybundleType 的组合不在预期范围内时,直接 throw Error。不是静默忽略,不是 fallback 到默认值------而是让整个构建失败。这种" fail fast "的工程判断让问题在构建阶段就暴露,而不是跑到生产环境才发现开关值不对。

null 的含义 。返回 null 表示"没有 fork 匹配,用默认文件"。这和返回一个路径是不同的语义------null 是主动声明"我不覆盖",而不是"我忘了处理"。

4.2 findNearestExistingForkFile------渐进回退的查找艺术

Fork 系统还有一层更细的机制。看看 DefaultPrepareStackTrace.js 的路由:

javascript 复制代码
'./packages/shared/DefaultPrepareStackTrace.js': (
  bundleType, entry, dependencies, moduleType
) => {
  if (moduleType !== RENDERER && moduleType !== RECONCILER) {
    return null;  // 只有渲染器和协调器才需要 fork
  }
  const bundleTypeName = bundleType.replace(/_/g, '-').toLowerCase();
  const path = './packages/shared/forks/';
  const suffix = '.js';
  return (
    findNearestExistingForkFile(path, bundleTypeName, suffix) ||
    new Error('Cannot find fork of DefaultPrepareStackTrace for ' + bundleType)
  );
}

findNearestExistingForkFile 这个名字已经很说明问题了。它的实现是这样的:

javascript 复制代码
// https://github.com/facebook/react/blob/main/scripts/rollup/forks.js
function findNearestExistingForkFile(path, segmentedIdentifier, suffix) {
  const segments = segmentedIdentifier.split('-');
  while (segments.length) {
    const candidate = segments.join('-');
    const forkPath = path + candidate + suffix;
    try {
      fs.statSync(forkPath);
      return forkPath;  // 找到了
    } catch (error) {
      // 没找到,缩短标识符再试
    }
    segments.pop();
  }
  return null;
}

假设当前构建目标是 RN_FB_PROD,标识符变成 rn-fb-prod。查找顺序:

  1. 先试 DefaultPrepareStackTrace.rn-fb-prod.js --- 不存在
  2. DefaultPrepareStackTrace.rn-fb.js --- 不存在
  3. DefaultPrepareStackTrace.rn.js --- 不存在
  4. 返回 null,fallback 到默认文件

但如果是 fb-www-prod

  1. DefaultPrepareStackTrace.fb-www-prod.js --- 不存在
  2. DefaultPrepareStackTrace.fb-www.js --- 不存在
  3. DefaultPrepareStackTrace.fb.js --- 不存在
  4. 返回 null

这种最短前缀匹配 的策略,让维护者不需要为每一种 bundleType 组合都创建一个 fork 文件。只要一个 fb.jswww.js 就能覆盖一组相关环境。这和 CSS 的类继承、路由的最长前缀匹配是同一个设计模式------在"精确控制"和"维护成本"之间找到了平衡点。

4.3 动态开关:__VARIANT__ 与 GateKeeper

上面说的 fork 系统,解决的是构建时 的环境差异。但 React 还有一个更厉害的能力------运行时的功能开关。

打开 packages/shared/forks/ReactFeatureFlags.www-dynamic.js

javascript 复制代码
// https://github.com/facebook/react/blob/main/packages/shared/forks/ReactFeatureFlags.www-dynamic.js
// In www, these flags are controlled by GKs. Because most GKs have some
// population running in either mode, we should run our tests that way, too.
//
// Use __VARIANT__ to simulate a GK. The tests will be run twice: once
// with the __VARIANT__ set to `true`, and once set to `false`.

export const enableTransitionTracing: boolean = __VARIANT__;
export const enableViewTransition: boolean = __VARIANT__;
export const enableSuspenseyImages: boolean = __VARIANT__;
export const enableParallelTransitions: boolean = __VARIANT__;
// ... 还有更多

注释已经说得很清楚了。GK 是 Facebook 内部的 GateKeeper 系统------一个配置平台,可以按用户百分比、按地区、按设备类型来灰度功能。__VARIANT__ 是一个构建时的占位符,在 Facebook 的 CI 中会被替换成 truefalse。测试跑两遍:一遍开,一遍关。确保代码在两种模式下都能工作。

ReactFeatureFlags.www.js(非 dynamic 版本)的做法更有意思:

javascript 复制代码
// https://github.com/facebook/react/blob/main/packages/shared/forks/ReactFeatureFlags.www.js
// 从 Facebook 内部的运行时模块加载
const dynamicFeatureFlags = require('ReactFeatureFlags');

export const {
  enableTransitionTracing,
  enableViewTransition,
  enableSuspenseyImages,
  // ... 动态 flags
} = dynamicFeatureFlags;

// 静态 flags ------ 不会被 GK 控制
export const enableTrustedTypesIntegration: boolean = true;
export const enableLegacyFBSupport: boolean = true;
export const enableMoveBefore: boolean = false;

这里分了两类 flags:

  • 动态 flags :从 require('ReactFeatureFlags') 解构出来。这个模块是 Facebook 内部的运行时配置系统,值可以在服务器端随时调整。enableTransitionTracing 今天对 5% 的用户是 true,明天可以立刻调成 0%------不需要重新构建、不需要重新部署。
  • 静态 flags:硬编码的布尔值。这些已经经过充分验证,不会回退,直接固化在代码里。

这种动静分离的设计,让 React 在 Facebook 内部的发布节奏变成了这样:

flowchart LR subgraph Flow["特性发布流程"] direction LR A["代码合并<br/>enableFoo = __VARIANT__"] B["FB 内部 CI<br/>测试跑两遍<br/>(true/false)"] C["GK 灰度<br/>5% → 20% → 50%"] D["全量或回退<br/>GK 100% 或 0%"] E["代码固化<br/>enableFoo = true<br/>删掉开关"] end A --> B --> C --> D --> E style A fill:#1a3a4a,stroke:#58a6ff,color:#fff style B fill:#2c3e50,stroke:#58a6ff,color:#c9d1d9 style C fill:#4a3a1a,stroke:#f0883e,color:#fff style D fill:#1a4a2a,stroke:#3fb950,color:#fff style E fill:#3a1a3a,stroke:#a371f7,color:#fff

新功能合并 → CI 在两种模式下都跑通 → GateKeeper 给 5% 用户打开 → 观察一周没问题推到 50% → 再推全量 → 最后把 __VARIANT__ 改成 true,删掉开关。整个过程不需要发新版本。

这才是渐进式发布 的终极形态。不是"先发到 canary 再发到 stable"------那是版本维度的渐进。这是用户维度的渐进,细到每一个用户、每一次请求。


五、Rollup 构建链------一个矩阵式的产物工厂

说完了模块怎么组织,说说模块怎么变成用户可以安装的文件。

React 的构建不是"一个入口一个包"这么简单。它是一个矩阵------横向是环境(浏览器 ESM/CJS、Node.js、FB www、RN FB、RN OSS、Bun),纵向是模式(DEV/PROD/PROFILING),两两组合,产出二十多种 bundle。

这个矩阵的定义在 scripts/rollup/bundles.js 里。看看 react-dom 的 bundle 定义:

javascript 复制代码
// https://github.com/facebook/react/blob/main/scripts/rollup/bundles.js
{
  bundleTypes: [
    NODE_DEV,      // Node.js CJS 开发版
    NODE_PROD,     // Node.js CJS 生产版
    NODE_PROFILING,// Node.js CJS 性能分析版
    ESM_DEV,       // ESM 开发版
    ESM_PROD,      // ESM 生产版
  ],
  moduleType: RENDERER,        // 角色:渲染器
  entry: 'react-dom',           // 入口
  global: 'ReactDOM',           // UMD 全局变量名
  minifyWithProdErrorCodes: true,
  wrapWithModuleBoundaries: true,
  externals: ['react'],         // react 不打包进来
},
{
  bundleTypes: [
    FB_WWW_DEV,     // Facebook www 开发版
    FB_WWW_PROD,    // Facebook www 生产版
    FB_WWW_PROFILING,
  ],
  moduleType: RENDERER,
  entry: 'react-dom/src/ReactDOMFB.js',  // 注意:不同的入口!
  global: 'ReactDOM',
  externals: ['react'],
},
{
  bundleTypes: [
    RN_FB_DEV,      // React Native FB 开发版
    RN_FB_PROD,
    RN_FB_PROFILING,
  ],
  moduleType: RENDERER,
  entry: 'react-dom',           // 同一个入口
  global: 'ReactDOM',
  externals: [
    'react',
    'ReactNativeInternalFeatureFlags'  // 额外外部依赖
  ],
},

三段定义,同一个 react-dom,三种不同的"活法"。

第一段是开源浏览器版 ------五种 bundle 类型,entry 是默认的 react-dom,external 只有 react。这是我们最熟悉的版本,npm install react-dom 装的就是它。

第二段是Facebook www 版 ------entry 指向了 react-dom/src/ReactDOMFB.js,不是默认入口。所以 Facebook www 用的 ReactDOM 有一组自己的初始化逻辑、自己的 polyfill、自己的错误处理。但源码和开源版在同一个文件树里,只是入口不同。

第三段是React Native FB 版 ------external 里多了一个 ReactNativeInternalFeatureFlags,这是 Facebook Native 内部的特性开关模块。Rollup 不会尝试打包它,而是在产物里保留 require('ReactNativeInternalFeatureFlags') 调用。

这三个定义的差异,透露了 React 构建系统的几个核心判断:

同一个包可以有多个入口react-dom 开源用户走默认入口,Facebook www 用户走 ReactDOMFB.js,测试环境走 unstable_testing。不需要分支,不需要复制代码------只需要在 bundles.js 里加一行定义。

externals 精确控制依赖边界react 永远是 external------因为用户已经装了 react,如果 react-domreact 也打包进去,页面上就有两份 React 代码,Hooks 的 dispatcher 会乱掉。但有些依赖如 ReactNativeInternalFeatureFlags 只在特定环境下存在,不需要也不应该被打包进去。

minifyWithProdErrorCodes 这个字段值得单独说。React 有一个内部系统叫 error-codes------开发模式的错误信息是完整的字符串("You are mounting a new component when..."),生产模式被替换成一个数字代码(如 r.123),然后有一个 JSON 文件映射数字到完整信息。这能把生产包的体积砍掉好几 KB。不是所有包都开启这个功能------比如 test-utils 就不开,因为测试环境不需要体积优化。

flowchart LR subgraph Matrix["react-dom 的产物矩阵"] direction TB subgraph Browser["浏览器(开源)"] B1["ESM_DEV<br/>~200KB"] B2["ESM_PROD<br/>~40KB"] B3["NODE_DEV<br/>CJS"] end subgraph FB["Facebook 内部"] F1["FB_WWW_DEV"] F2["FB_WWW_PROD"] F3["entry = ReactDOMFB.js"] end subgraph Native["RN FB"] N1["RN_FB_DEV"] N2["RN_FB_PROD"] N3["external: ReactNativeInternalFeatureFlags"] end end Browser ~~~ FB ~~~ Native style Browser fill:#1a3a4a,stroke:#58a6ff,color:#fff style FB fill:#4a2a1a,stroke:#f0883e,color:#fff style Native fill:#1a4a2a,stroke:#3fb950,color:#fff

六、ReactFeatureFlags.js------特性开关的生死簿

回到 packages/shared/ReactFeatureFlags.js,看看开关是怎么分类管理的。

javascript 复制代码
// https://github.com/facebook/react/blob/main/packages/shared/ReactFeatureFlags.js

// ---------------------------------------------------------------------------
// Land or remove (zero effort)
// Flags that can likely be deleted or landed without consequences
// ---------------------------------------------------------------------------
// (currently none)

// ---------------------------------------------------------------------------
// Killswitch
// Flags that exist solely to turn off a change in case it causes a regression
// when it rolls out to prod. We should remove these as soon as possible.
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// Land or remove (moderate effort)
// ---------------------------------------------------------------------------
export const disableSchedulerTimeoutInWorkLoop: boolean = false;

// ---------------------------------------------------------------------------
// Slated for removal in the future (significant effort)
// ---------------------------------------------------------------------------
export const enableSuspenseCallback: boolean = false;
export const enableScopeAPI: boolean = false;
export const enableCreateEventHandleAPI: boolean = false;
export const enableLegacyFBSupport: boolean = false;

// ---------------------------------------------------------------------------
// Experiments
// ---------------------------------------------------------------------------
export const enableTransitionTracing: boolean = false;
export const enableCustomElementPropertySupport: boolean = false;
export const enableInfiniteRenderLoopDetection: boolean = false;
export const enableYieldingBeforePassive: boolean = false;

这种分类不是装饰性注释。它是一套开关生命周期管理体系

类别 含义 预期寿命 清理责任
Land or remove (zero effort) 即将落地的开关,代码准备好,无副作用 几天到一周 功能稳定后立即删掉
Killswitch 应急开关------"万一出问题能关掉" 尽可能短 观察期结束后移除
Land or remove (moderate effort) 需要迁移内部调用或跑性能测试 几周到一个月 有人主动推进
Slated for removal (significant effort) 实验失败但内部代码已依赖,需逐步迁移 数月甚至更长 专门安排重构窗口
Experiments 正在验证的新功能 不确定 验证通过后转为 killswitch 或直接落地

最令我印象深刻的不是分类本身,而是注释里的那段空白------"Killswitch" 类别下什么都没有

这说明了什么?React 团队的文化里,killswitch 是一个临时手段,不是常态。一旦功能稳定,开关就要被清理。如果一个 killswitch 长期存在,那说明团队的发布流程有问题------不是"我们有一个开关可以救命",而是"我们为什么还需要这个开关"。

对比我在实际项目中见过的场景:一个 ENABLE_V2_UI 的开关在代码里躺了三年,从没人敢删掉------因为"可能有人还在用 V1"。这种恐惧驱动的技术债积累,最终会拖垮整个代码库。React 的分类系统本质上是在对抗这种恐惧:每个开关从出生那天起就带了一个"到期日",到期不还,就是债,这样就会在日常的开发中去进行化债。


七、从 React 的骨架,到我们自己的工程

依赖拓扑比代码规范更有约束力

React 的模块分层------sharedreactrenderertools------不是写在文档里的"建议"。它是通过构建系统的 externals 配置、fork 路由的 entry 校验、findNearestExistingForkFile 的回退机制强制执行的。

在自己的 Monorepo 里,画出依赖图。找出地基模块(被所有人依赖的)、核心模块(定义业务模型的)、适配器模块(对接不同平台的)。然后在构建系统里加约束------地基模块不能依赖任何人,核心模块只能依赖地基,适配器模块可以依赖核心但不能反向依赖。这比一百页代码规范都管用。

给一线开发者:每个新功能都该带着一个"关闭开关"

React 的 Feature Flag 系统告诉我们:没有开关的功能上线,等于裸奔。开关不是可选的,是强制的。

更关键的是开关的生命周期管理。给我的团队定一条规矩:

  • Killswitch:最长存在两周
  • Experiment:最长存在两个月
  • 到期不还,自动转化为 P1 技术债,必须安排时间清理

不要让开关在代码里无限堆积。每一个遗留的开关,都是在给未来的自己挖坑。

渐进式发布不是"发多个版本",而是"控制每个用户看到什么"

React 在 Facebook 内部的发布模式------GateKeeper 控制 __VARIANT__,按用户百分比灰度------这才是真正的渐进式发布。不是"先发 canary 再发 stable",而是同一个版本,不同用户看到不同功能

这种能力需要一个前提:代码在两种模式下都必须能工作 。React 的 CI 跑两遍测试,一遍 __VARIANT__=true,一遍 __VARIANT__=false。这是额外的工程投入,但它换来的安全感------随时可以回退、随时可以灰度------是值得的。

如果团队还没有配置中心,先去搭一个。如果有了配置中心但只用来改"每页显示条数"这种业务配置,那它的真正价值还没有被发挥出来。把功能开关也接入进去,让每个新功能都带一个"关闭按钮"。

React 的做法 迁移策略
Fork 系统实现多环境差异化 不同部署环境(开发/测试/预发/生产)加载不同配置;多业务线(Web/小程序/App)用环境变量控制主题和行为
Feature Flags 分生命周期管理 新功能强制带开关,开关带到期日,到期不还自动转 P1
__VARIANT__ 双模式 CI 测试 关键功能变更在 CI 中跑两套测试(开/关),确保回退路径可用
Externals 精确控制依赖边界 核心库作为 external 不打包进业务包;用构建系统的 externals 配置强制约束,不用口头约定
矩阵式构建(环境 × 模式) 为每个业务包定义构建矩阵,DEV 带 sourcemap 和警告,PROD 做代码精简和错误码替换

八、工程的纪律是架构的免疫系统

回头看 React 的 Monorepo,最打动我的不是某个 clever trick。Fork 系统很精妙,Feature Flags 分类很严谨,Rollup 矩阵构建很强大------但这些是 ,不是

真正的因,是一种贯穿始终的工程纪律

四十多个模块,每一块都知道自己该待在哪一层。shared/ 不发布为独立包,而是编译时内联------牺牲一点点体积,换来零依赖的简洁。Feature Flags 从出生就带到期日------不让开关在代码里腐烂。同一个包通过不同的 entry 和 externals 适配七八种环境------不复制代码,不维护分支。构建产物像矩阵一样整齐排列------每种格式都有明确的用户和用途。

这套纪律能运转十余年,靠的不是 Sebastian Markbåge 或 Andrew Clark 某个人的天才,而是一代又一代维护者对规则的坚守

我在那个事故后,给团队的 Monorepo 加了三条硬性规定:

  1. packages/utils 的改动必须触发全仓库的 CI(不只是自己的测试)
  2. 每个新功能必须带 Feature Flag,Flag 必须设到期日
  3. 底层包的 API 变更必须走 RFC 流程,至少两个业务负责人确认

规则让人不舒服。它们拖慢了开发速度,增加了沟通成本,偶尔还会被同事吐槽 "太官僚"。但规则也是免疫系统------没有它,一个 console.log 就能在凌晨三点把交易系统打崩。

相关推荐
lichenyang4531 小时前
鸿蒙路由研读:为什么公司项目用 HMRouterMgr 而不用原生 Navigation
前端
gf13211111 小时前
【精确查找python脚本是否在运行】
linux·前端·python
mCell1 小时前
别急着骂运营商,你家路由器里可能藏着一台 PCDN 盒子
前端·http·cdn
PILIPALAPENG1 小时前
Skills篇-findskills:告别手动迁移Skill!跨AI工具通用能力,才是真高效
前端·人工智能·后端
假如让我当三天老蒯1 小时前
Composables和Utils的区别(自学用)
前端
kungggyoyoyo1 小时前
从0开发一套geo优化软件:系统定位与整体架构
前端
用户713874229001 小时前
PKCE 的 S256 算法深度剖析:从协议设计到密码学原理
前端
闪闪发光得欧1 小时前
StreamTokenizer的源码分析和使用方法详细分析
前端
李剑一1 小时前
华为一面就问网络安全?面试官:请简述一下 XSS/CSRF 的攻击面与前端侧的防护
前端·面试