前司做的是做商城 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 的模块有严格的层次,有点像人的骨架,每一块骨头都知道自己该长在哪。

第三层:工具

eslint-plugin-react-hooks

react-devtools*

jest-react

第二层:渲染

react-reconciler
Diff + 调度

react-dom
浏览器 DOM

react-native-renderer
Native 视图

react-server
服务端 HTML 流

第一层:核心

react
组件模型、Hooks、JSX

第零层:地基

shared/

scheduler

第零层 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 中,有一个关键环节:

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

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

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 路径,在不同构建目标下加载不同的文件

Fork 路由:import 'shared/ReactFeatureFlags.js' 实际加载谁?

import 路径:
shared/ReactFeatureFlags.js

entry = react-native-renderer/fabric
→ forks/ReactFeatureFlags.native-fb.js

entry = eslint-plugin-react-hooks
→ forks/ReactFeatureFlags.eslint-plugin.www.js

entry = react-test-renderer
→ forks/ReactFeatureFlags.test-renderer.js

bundleType = FB_WWW_*
→ forks/ReactFeatureFlags.www.js

bundleType = RN_FB_*
→ forks/ReactFeatureFlags.native-fb.js

fallback:
→ 默认 ReactFeatureFlags.js

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

4.1 Fork 路由的决策链

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

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

// 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 的路由:

'./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 这个名字已经很说明问题了。它的实现是这样的:

// 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

// 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 版本)的做法更有意思:

// 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 内部的发布节奏变成了这样:

特性发布流程

代码合并
enableFoo = __VARIANT__

FB 内部 CI
测试跑两遍
(true/false)

GK 灰度
5% → 20% → 50%

全量或回退
GK 100% 或 0%

代码固化
enableFoo = true
删掉开关

新功能合并 → 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 定义:

// 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 就不开,因为测试环境不需要体积优化。

react-dom 的产物矩阵

RN FB

RN_FB_DEV

RN_FB_PROD

external: ReactNativeInternalFeatureFlags

浏览器(开源)

ESM_DEV
~200KB

ESM_PROD
~40KB

NODE_DEV
CJS

Facebook 内部

FB_WWW_DEV

FB_WWW_PROD

entry = ReactDOMFB.js


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

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

// 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 就能在凌晨三点把交易系统打崩。

更多推荐