> 前公司的業務是做商城 SaaS 的,在 Monorepo 裡,有個同事下午在 `packages/utils` 裡加了一個 `console.log` 當除錯用,當時 CR 程式碼看得眼花沒注意到,結果就這一行程式碼。部署到線上後,線上炸了。
因為
packages/utils被二十三個上層包依賴。二十三個包的產物,全都帶著那行console.log。而我們的日誌收集系統剛好在那週剛擴容,頻寬是平常的三倍。結果呢?日誌管線被灌爆,連帶監控告警系統一起崩了。交易系統因為拿不到健康檢查回應,被負載平衡器判定為「不健康」,一台接一台地摘掉。第一反應是回滾。但是二十三個包,發佈節奏各不相同。有的每小時一版,有的每天一版,有的每週更一次。我們花了四個小時才把所有帶毒產物清理乾淨。
我當時就有個疑問,為什麼 React 也是同樣四十多個模組,React 是怎麼做到十年都不崩的?
Monorepo 的誘惑誰都懂。程式碼放一起,重構可以大刀闊斧地改,版本天然對齊,一個新功能從底層到上層可以一次提交搞定。想想就很爽。
爽到你真正經歷了幾年業務迭代之後,才發現這是一座活火山,有的時候真的會被原地飛升:
改了底層一個模組,上層全炸。 packages/shared 裡動了一個工具函式,CI 裡三十個包同時報錯。你修了 A,B 又掛了;修了 B,C 出警告。改一行程式碼,三天泡在建置失敗的泥潭裡。
同一個功能,在不同環境下表現不同。 瀏覽器裡正常,React Native 裡白屏,伺服器端渲染直接丟例外。不是業務程式碼的問題,是底層工具在不同平台下的行為差異——但同一個包被打到了所有平台。
新功能上線後發現問題,回退比登天還難。 沒有開關,沒有灰度,一上線就是全量。出問題怎麼辦?發 hotfix。但 hotfix 又要走完整的 CI 流程。
建置產物混亂到令人髮指。 一個包要同時輸出 ESM(給 Vite/Webpack 5)、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 的原生視圖;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 体验AI代码助手 代码解读复制代码// 偽代碼示意 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 体验AI代码助手 代码解读复制代码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 後幾乎可以忽略。
這是 React Monorepo 架構中最精妙的設計,沒有之一。
React 跑在多少種環境上?瀏覽器、Node.js(SSR)、React Native(iOS/Android)、Facebook 內部的 www 系統、Facebook 內部的 Native 系統……每種環境的特性開關值都不同。
比如 enableTransitionTracing——在瀏覽器開源版裡它是 false,但在 Facebook 內部,React 團隊想提前試用,所以它應該被打開。怎麼辦?
維護五個分支?發五個版本?React 的選擇是:同一個 import 路徑,在不同建置目標下載入不同的檔案。
這個魔術的實作,藏在 scripts/rollup/forks.js 裡。
forks.js 裡有一個凍結的物件,鍵是原始檔案路徑,值是一個函式。這個函式接收當前建置的 bundleType(產物格式)、entry(入口模組)、dependencies(依賴列表)、_moduleType(模組角色),回傳一個字串——實際要載入的檔案路徑。
看 ReactFeatureFlags.js 的路由邏輯,這是一段值得逐行品味的程式碼:
javascript 体验AI代码助手 代码解读复制代码// 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 是主動聲明「我不覆蓋」,而不是「我忘了處理」。
findNearestExistingForkFile——漸進回退的查找藝術Fork 系統還有一層更細的機制。看看 DefaultPrepareStackTrace.js 的路由:
javascript 体验AI代码助手 代码解读复制代码'./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 体验AI代码助手 代码解读复制代码// 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 — 不存在但如果是 fb-www-prod:
DefaultPrepareStackTrace.fb-www-prod.js — 不存在DefaultPrepareStackTrace.fb-www.js — 不存在DefaultPrepareStackTrace.fb.js — 不存在這種最短前綴匹配的策略,讓維護者不需要為每一種 bundleType 組合都建立一個 fork 檔案。只要一個 fb.js 或 www.js 就能覆蓋一組相關環境。這和 CSS 的類別繼承、路由的最長前綴匹配是同一個設計模式——在「精確控制」和「維護成本」之間找到了平衡點。
__VARIANT__ 與 GateKeeper上面說的 fork 系統,解決的是建置時的環境差異。但 React 還有一個更厲害的能力——執行時的功能開關。
打開 packages/shared/forks/ReactFeatureFlags.www-dynamic.js:
javascript 体验AI代码助手 代码解读复制代码// 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 体验AI代码助手 代码解读复制代码// 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:
require('ReactFeatureFlags') 解構出來。這個模組是 Facebook 內部的執行期設定系統,值可以在伺服器端隨時調整。enableTransitionTracing 今天對 5% 的使用者是 true,明天可以立刻調成 0%——不需要重新建置、不需要重新部署。這種動靜分離的設計,讓 React 在 Facebook 內部的發佈節奏變成了這樣:
新功能合併 → CI 在兩種模式下都跑通 → GateKeeper 給 5% 使用者打開 → 觀察一週沒問題推到 50% → 再推全量 → 最後把 __VARIANT__ 改成 true,刪掉開關。整個過程不需要發新版本。
這才是漸進式發佈的終極形態。不是「先發到 canary 再發到 stable」——那是版本維度的漸進。這是使用者維度的漸進,細到每一個使用者、每一次請求。
說完了模組怎麼組織,再說說模組怎麼變成使用者可以安裝的檔案。
React 的建置不是「一個入口一個包」這麼簡單。它是一個矩陣——橫向是環境(瀏覽器 ESM/CJS、Node.js、FB www、RN FB、RN OSS、Bun),縱向是模式(DEV/PROD/PROFILING),兩兩組合,產出二十多種 bundle。
這個矩陣的定義在 scripts/rollup/bundles.js 裡。看看 react-dom 的 bundle 定義:
javascript 体验AI代码助手 代码解读复制代码// 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 体验AI代码助手 代码解读复制代码// 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 的模組分層——shared → react → renderer → tools——不是寫在文件裡的「建議」。它是透過建置系統的 externals 設定、fork 路由的 entry 驗證、findNearestExistingForkFile 的回退機制強制執行的。
在自己的 Monorepo 裡,畫出依賴圖。找出地基模組(被所有人依賴的)、核心模組(定義業務模型的)、適配器模組(對接不同平台的)。然後在建置系統裡加約束——地基模組不能依賴任何人,核心模組只能依賴地基,適配器模組可以依賴核心但不能反向依賴。這比一百頁程式碼規範都管用。
React 的 Feature Flag 系統告訴我們:沒有開關的功能上線,等於裸奔。開關不是可選的,是強制的。
更關鍵的是開關的生命週期管理。給我的團隊定一條規矩:
不要讓開關在程式碼裡無限堆積。每一個遺留的開關,都是在給未來的自己挖坑。
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(不只是自己的測試)規則讓人不舒服。它們拖慢了開發速度,增加了溝通成本,偶爾還會被同事吐槽「太官僚」。但規則也是免疫系統——沒有它,一個 console.log 就能在凌晨三點把交易系統打崩。