前司做的是做商城 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 的模块有严格的层次,有点像人的骨架,每一块骨头都知道自己该长在哪。
第零层 shared/ + scheduler :不依赖任何 React 包,是整个系统的地基。shared/ReactSymbols.js 里的 REACT_ELEMENT_TYPE,shared/shallowEqual.js 里的浅比较------这些工具函数太底层了,react 包自己都指着它们活着。
但 shared/ 不是一个独立的 npm 包。去 npm 上搜 @react/shared,搜不到。它是通过编译时内联 的方式被打进各个包里的。Rollup 构建 react-dom 时,会把 shared/ 里的模块直接嵌入产物。结果就是用户安装 react-dom 时,不需要额外装一个 shared 包------零运行时依赖。
第一层 react :唯一的核心定义包。组件模型(Component、PureComponent)、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 可以 import 自 react,但 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.js、react-dom.js、react-native-renderer.js 中------同样的常量定义,被打包进了三个不同的文件。
但收益也极其清晰:
- 零运行时依赖 。用户装
react和react-dom,只有两个包。没有@react/shared这种东西拖累安装体验。 - 版本自治 。
shared/的改动不需要发独立的版本号。它跟着引用它的包一起发布,永远"版本对齐"。 - 环境隔离 。同一个
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 路径,在不同构建目标下加载不同的文件。
这个魔术的实现,藏在 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/fabric 用 native-fb.js,eslint-plugin-react-hooks 用 eslint-plugin.www.js,react-test-renderer 用 test-renderer.js。每个入口都有自己的特性开关配置,因为它们暴露的功能集不同,需要控制的开关也不同。
显式的错误处理 。当 entry 和 bundleType 的组合不在预期范围内时,直接 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。查找顺序:
- 先试
DefaultPrepareStackTrace.rn-fb-prod.js--- 不存在 - 试
DefaultPrepareStackTrace.rn-fb.js--- 不存在 - 试
DefaultPrepareStackTrace.rn.js--- 不存在 - 返回 null,fallback 到默认文件
但如果是 fb-www-prod:
- 试
DefaultPrepareStackTrace.fb-www-prod.js--- 不存在 - 试
DefaultPrepareStackTrace.fb-www.js--- 不存在 - 试
DefaultPrepareStackTrace.fb.js--- 不存在 - 返回 null
这种最短前缀匹配 的策略,让维护者不需要为每一种 bundleType 组合都创建一个 fork 文件。只要一个 fb.js 或 www.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 中会被替换成 true 或 false。测试跑两遍:一遍开,一遍关。确保代码在两种模式下都能工作。
而 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 内部的发布节奏变成了这样:
新功能合并 → 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-dom 把 react 也打包进去,页面上就有两份 React 代码,Hooks 的 dispatcher 会乱掉。但有些依赖如 ReactNativeInternalFeatureFlags 只在特定环境下存在,不需要也不应该被打包进去。
minifyWithProdErrorCodes 这个字段值得单独说。React 有一个内部系统叫 error-codes------开发模式的错误信息是完整的字符串("You are mounting a new component when..."),生产模式被替换成一个数字代码(如 r.123),然后有一个 JSON 文件映射数字到完整信息。这能把生产包的体积砍掉好几 KB。不是所有包都开启这个功能------比如 test-utils 就不开,因为测试环境不需要体积优化。
六、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 的模块分层------shared → react → renderer → tools------不是写在文档里的"建议"。它是通过构建系统的 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 加了三条硬性规定:
packages/utils的改动必须触发全仓库的 CI(不只是自己的测试)- 每个新功能必须带 Feature Flag,Flag 必须设到期日
- 底层包的 API 变更必须走 RFC 流程,至少两个业务负责人确认
规则让人不舒服。它们拖慢了开发速度,增加了沟通成本,偶尔还会被同事吐槽 "太官僚"。但规则也是免疫系统------没有它,一个 console.log 就能在凌晨三点把交易系统打崩。