單倉庫下的四十個模組 —— 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/Webpack 5)、CJS(給 Node.js)、UMD(給瀏覽器 <script> 標籤),還要輸出 .d.ts(給 TypeScript 型別檢查),還要分 DEV 版(帶警告)和 PROD 版(精簡)。一個包變八個檔案,四十個包就是三百多個檔案。管理這三百多個檔案,手動?腳本?還是真的去伺服器上香?

React 團隊的解法,不是迴避這些痛苦,而是用極其嚴格的工程紀律,把每一種痛苦都關進制度的籠子。這是我的淺顯理解。


二、四層骨架,撐起四十個模組

打開 packages/ 目錄,四十多個模組橫在那裡。但不是亂堆的——React 的模組有嚴格的層次,有點像人的骨架,每一塊骨頭都知道自己該長在哪裡。

第零層 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 的原生視圖;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 体验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.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 路徑,在不同建置目標下載入不同的檔案

這個魔術的實作,藏在 scripts/rollup/forks.js 裡。

4.1 Fork 路由的決策鏈

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/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 体验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。查找順序:

  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 体验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 中會被替換成 truefalse。測試跑兩遍:一遍開,一遍關。確保程式碼在兩種模式下都能工作。

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:

  • 動態 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 体验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-domreact 也打包進去,頁面上就有兩份 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 的骨架,到我們自己的工程

依賴拓撲比程式碼規範更有約束力

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


原文出處:https://juejin.cn/post/7646780176223010826


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝12   💬4   ❤️1
478
🥈
alicec
📝1   ❤️2
88
#4
我愛JS
💬1  
3
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登