2022 年 3 月 6 日週日清晨,Stripe 鎖住了整個 monorepo,跑完最後一輪 QA,然後把一個包含 370 萬行改動的 pull request 合進 main——「乾淨地合進去了,自動測試全綠」。隔天早上,幾百名工程師打開編輯器,發現自己手上的 Flow 全部變成了 TypeScript。
一個 PR 遷走 370 萬行——Stripe 怎麼從 Flow 跳到 TypeScript
大規模型別遷移的常識做法是 file-by-file——一次轉一個檔,跑紅變綠,慢慢推進,半年一年都算正常。Stripe 的 Dashboard 沒這樣做:他們花幾個月把一個 codemod 養到能可靠地把整個 codebase 從 Flow 轉成 TypeScript,然後在一個週末把 370 萬行一次性合進去。這篇是這個 big-bang PR 的時間線——為什麼 Flow 撐不下去、為什麼選一次合而不是逐檔遷、codemod 怎麼補上 Flow 與 TS 的語義落差、怎麼在不凍結 repo 的前提下把一個巨大 PR 落地,以及切換那一刻發生了什麼。
這個故事的關鍵不是「他們很大膽」,而是一連串把風險拆掉的工程決定:把 codemod 設計成可重跑而非一次性 migration、用 @ts-expect-error 把「轉換」與「修錯」解耦、把切換窗口壓到一個週末。每一個決定單獨看都不戲劇化,但疊在一起,就把「370 萬行一次合」這件聽起來像賭命的事,變成了一個風險被逐項馴服的部署。
值得先講清楚這篇要拆解的對象。Stripe 不是第一個做大規模型別遷移的公司,但「single PR、370 萬行、一個週末」這個組合在公開案例裡仍屬罕見。多數團隊談遷移,談的是「我們花了 N 個季度逐步把 X% 的檔轉過去」;Stripe 談的是「我們花了 N 個月讓一個自動轉換器夠可靠,然後在某個週日清晨按下按鈕」。前者把成本攤在時間軸上,後者把成本集中在一個工具上——而工具的好處是它可以被測試、被重跑、被信任。先把時間軸攤開,下面這條 scrubber 可以拖,看任一時刻 main 與 migration branch 各自在做什麼。
拖動把手沿著時間軸移動 · 涵蓋 2020 末到 2022.03.06 的六個節點
互動圖表
Stripe 花逾一年養出可重跑的 codemod,才能在 2022.03.06 週日清晨把 370 萬行改動一次合入 main。
Flow 為什麼撐不下去
Stripe 的 Dashboard 是一個前端 monorepo,規模大到以「tens of thousands of modules」計。它原本用 Flow 做型別檢查——Flow 是 Facebook 出的 JavaScript 型別系統,跟 TypeScript 在同一個生態位競爭。問題不在 Flow 的型別表達力,而在它周邊的一切都在退化。
第一個痛點是效能與資源。官方說法是 type checker 的記憶體用量「would lock up laptops」——大到能把工程師的筆電鎖死;in-editor 的整合又「frequently slow and unreliable」,慢而且不穩。對一個每天要在這個 codebase 上跑無數次 type check 的團隊,這是直接打在生產力上的稅。型別檢查器的價值在於即時回饋——你打錯一個 prop 名字,編輯器當下就紅一條波浪線。一旦這個回饋變慢或變不可靠,型別系統就退化成「跑 CI 才知道」的事後檢查,喪失了它最大的存在理由。工程師會開始繞過它、忽略它,型別覆蓋於是悄悄爛掉。
這裡有個容易被略過的細節:Flow 是漸進式型別系統,它允許你在沒標型別的地方靜默放行。這個寬容在剛導入時是優點(不必一次標完全部),但日積月累,「沒標型別」的區域會自然擴張——尤其當 in-editor 體驗差到沒人想跟它互動時。型別系統的健康度跟它的人因體驗是綁在一起的,這是 Stripe 這段經驗裡很實際的一課。
第二個痛點是生態。2020 年前後 TypeScript 因為更好的 tooling 與社群支援徹底爆發,第三方型別定義(@types/* 那一整套)豐富到幾乎任何 npm 套件都有現成型別。Flow 沒有這個——很多套件的 Flow libdef 要嘛沒人維護、要嘛根本不存在。諷刺的是,這直接影響型別覆蓋率:後面會看到,即便 Stripe 轉完 TS 後 suppression 數字反而更多,TS 量到的 type coverage 仍然高於 Flow,原因正是這個——TS 那套豐富的第三方型別補上了 Flow 缺的大片空白。
第三個痛點是人。TypeScript 成了 Stripe 工程師「a top request」——招進來的新人多半在前一家公司就寫 TS,回到 Flow 像是退回上一個世代。這不是錦上添花,而是 hiring 與 morale 的實際成本。一位工程師事後在內部寫下的話很能說明轉換前的氛圍:
「Seriously unreal. I remember a short time ago laughing at the idea of typescript ever landing at Stripe, and then I woke up Christmas Monday morning and it was here.」
「曾經嘲笑 TypeScript 永遠不可能在 Stripe 落地」——這句話點出了問題的另一面:大家都想要 TS,但沒人相信能搬得動。三百多萬行、上萬個 module、緊耦合的前端 codebase,逐檔遷看起來像是一條望不到頭的路。真正要回答的問題因此不是「該不該遷」,而是「怎麼遷才不會拖死自己」。
為這件事,Stripe 在 2020 年底專門組了一個 JavaScript Infrastructure team。值得注意的是時間尺度:這個團隊不是花一個 sprint 寫個腳本就上,而是花了超過一年把整套遷移機制養成熟——這本身就是 big-bang 路線的隱性成本,也是它的門檻。沒有一個專責團隊、沒有願意投入一年以上的耐心,這條路根本走不通。換句話說,「一個週末合 370 萬行」的前提,是「一年多的準備」。戲劇性的是那個週末,但工程量在前面那一年。
為什麼是 big-bang,而不是逐檔遷
逐檔遷(incremental)是教科書答案:建一個 Flow 與 TS 並存的互通層,一次轉一小撮檔,紅變綠、合進去、再轉下一撮。風險小、可隨時停。Stripe 沒選它,理由是這個 codebase 的形狀讓 incremental 的成本被放大。
官方描述 Dashboard 是「tight coupling between disparate components and no cleanly factored dependency graph」——元件之間緊耦合、沒有乾淨切分的依賴圖。在這種 codebase 上做 incremental,意味著在很長一段時間裡,開發者得同時面對兩種語言:改一個 feature 可能一半檔是 Flow、一半是 TS,而且兩邊的型別定義要靠一個互通層持續同步。這個互通層本身就是要維護的成本,而且它存在多久,「在兩種語言間切換」的認知負擔就持續多久。
把這個互通層的成本拆細看,它不只是「多寫一些 adapter」。Flow 與 TypeScript 的型別系統不是同構的——同一個概念在兩邊的寫法、甚至語義都可能有微妙差異。要讓兩邊的型別定義在整個並存期持續對得上,等於要長期維護一份雙向翻譯,而且每次任一邊改型別都得同步另一邊。對一個有上萬個 module、且元件之間緊耦合的 codebase,這份翻譯的維護面積會大到失控。更糟的是,這個成本不會隨遷移進度遞減——它在「最後一個檔轉完」之前都存在,而緊耦合恰恰會讓「最後一個檔」遲遲到不了。
big-bang 把這個成本換成一次性的:clean break,沒有並存期、沒有互通層、沒有「這個檔到底是哪種語言」的認知開銷。前提當然是——你得先有信心一次轉完且轉對。這個信心不是來自勇氣,而是來自兩個先例:Airtable 與 Zapier 都把各自的遷移用「single commit」一次落地過。這兩個先例的價值不只是「別人做過所以可行」,而是它們證明了「自動轉換器 + 原子提交」這個模式在真實 codebase 上能成立——Stripe 因此敢直接拿 Airtable 開源的 codemod 當底,而不是從零摸索。下面這張表把兩條路的取捨並排——可以點欄位排序。
點欄位標題可排序 · 3 欄 × 5 列
| 維度 | incremental(逐檔遷) | big-bang(一次合) |
|---|---|---|
| 並存期 | 長——可能數月到一年雙語言並存 | 無——一個週末 clean break |
| 互通層 | 必須——Flow / TS 型別定義要持續同步 | 不需要——切完只剩一種語言 |
| 認知負擔 | 高——改一個 feature 常跨兩種語言 | 低——切換後語言唯一 |
| 回滾 | 容易——逐檔可獨立 revert | 難——但用可重跑 codemod 換取「重生 branch」的能力 |
| 最適 codebase | 依賴圖乾淨、低耦合 | 緊耦合、無乾淨依賴圖(正是 Dashboard) |
取捨的重點是最後一列:incremental 的優勢在「依賴圖乾淨」時才成立,而 Dashboard 恰好相反
Dashboard 的緊耦合與缺乏乾淨依賴圖,使 big-bang 一次切換比 incremental 逐檔遷的互通層成本更低。
codemod 怎麼補上語法落差
big-bang 的全部風險都壓在一件事上:那個 codemod 要能可靠地把 Flow 轉成 正確 的 TypeScript。Stripe 沒有從零寫,而是拿 Airtable 開源的 source-to-source 轉換器當底——它 parse Flow、輸出 TypeScript——複製進 monorepo,然後花幾個月反覆加固。source-to-source 的意思是它在 AST 層面工作:把 Flow 程式 parse 成抽象語法樹、把樹上的 Flow-特有節點(型別標註、型別別名、utility type 等)改寫成 TypeScript 對應的節點,再印回原始碼。這比正則替換可靠得多——它理解語法結構,不會把字串裡剛好長得像型別標註的文字也改掉。
在動 application 程式之前,團隊先處理了一個依賴根源:Sail,Stripe 內部的 design system,一套大量被引用的 React component 庫。對 Sail 的處理很有意思——他們沒有把 Sail 本身轉成 TS,而是為它生出一份 TypeScript 型別定義(類似 .d.ts),讓仍在用 Flow 的程式能繼續引用 Sail,同時用測試持續驗證這份定義對得上 Sail 端的 Flow 改動。這一步把「最多人依賴的那塊」先穩住,等於在大遷移之前先拆掉一顆最大的相依地雷。
所謂「加固」具體長什麼樣?舉一個官方點名的 bug:箭頭函式回傳物件字面量時,必須加括號才能跟「語句區塊」消歧(() => ({ a: 1 }) 而非 () => { a: 1 })。Stripe 的 codemod 在函式帶 generic 時——這種語法在純 JS 裡不存在——會錯誤地把這對括號剝掉,產出語義改變的程式。這類 syntactic edge case 一個一個被找出來修,「dozens of similar fixes」,每個 fix 都附一條回歸測試,確保下次轉換不會 regress。下面這條 slider 把同一段 component 的 Flow 原型與 codemod 產出的 TS 並排——拖動分隔線看 codemod 改了哪些 token。
拖動分隔線比對 Flow 原碼與 codemod 產出的 TypeScript
互動圖表
codemod 把物件型別分隔符由逗號改為分號、為函式參數補名稱、在 generic 箭頭函式加尾逗號消歧。
上面那段刻意挑了三個有代表性的差異:物件型別裡的分隔符 Flow 用逗號、TS 慣用分號;函式型別的參數 Flow 容許省略名字((string) => void),TS 要求參數名((a: string) => void);而那個帶 generic 的箭頭函式在 .tsx 檔裡 <T> 會跟 JSX 標籤撞,得寫成 <T,> 加一個逗號消歧——正是 codemod 早期剝錯括號那類問題的鄰居。這些都不是「翻譯」,而是兩套 parser 對同一份語法的不同要求,codemod 必須逐條補上。
這類 edge case 的麻煩之處在於它們是長尾的:頭幾十個 case 涵蓋了 codebase 裡 99% 的寫法,但剩下那 1% 散落在三百多萬行的各個角落,每一個都可能在切換那一刻悄悄產出語義錯誤的程式。這就是為什麼「每個 fix 配一條回歸測試」這件事不是工程潔癖,而是這條路能不能走通的核心:回歸測試把每一個被馴服的 edge case 固定住,確保下次對全新的 main 重跑 codemod 時,這個 case 不會復發。沒有這層測試守護,codemod 就只是個一次性腳本,跑一次的結果沒人敢信;有了它,codemod 才升級成一個可以反覆執行、結果可信的純函式——而這正是後面「不凍結 repo」那一招的前提。
頭幾十個 case 覆蓋 99% 寫法;剩 1% 散在 370 萬行,每個都要一條回歸測試釘住,否則重跑就復發
頭部數十個 edge case 覆蓋約 99% 的寫法;剩餘 1% 散布在 370 萬行中,每個須配一條回歸測試釘住。
語法之外還有 build tool。ESLint、Jest、Webpack、Metro 全都要改。Jest 的 snapshot 是個典型麻煩:snapshot 裡寫死了 test 檔的副檔名引用。Stripe 的解法是把所有產出統一成 .tsx 副檔名,這樣就能用一次 bulk-rewrite 把 snapshot 全部改對,同時維持 100% 測試通過。有些不相容則被判定「現在修不划算」——例如某些 custom ESLint rule 是針對 Babel 的 Flow parser 寫的,TS parser 產出的 AST 結構不同,這些 rule 在遷移後直接關掉。把這幾個 pass 攤開看:
切換分頁看 codemod 的四個 pass · 4 tabs
.tsx 副檔名,讓後面的 snapshot 重寫有一個一致的目標。
@ts-expect-error 註解把問題逐處標記、延後到切換後再處理。這把「語言轉換」與「修型別錯」徹底解耦——轉換本身永遠能跑綠,錯誤變成一份可量化、可逐步消化的待辦清單。
.tsx 做 bulk-rewrite 解決。針對 Babel Flow parser 寫的 custom ESLint rule,因 AST 結構不同、修起來不划算,遷移後直接停用。另外用 TypeScript project references 在不重構應用碼的前提下切出 module 邊界,緩解 compiler 在上萬個 module 上的記憶體壓力。
第四個 pass 裡藏著一個常被低估的問題:記憶體。TypeScript 的 compiler 對上萬個 module 做全量型別檢查時,記憶體壓力很容易爆掉。Stripe 用的是 project references——讓 TS 把 codebase 切成有明確邊界的子專案、各自獨立檢查與快取,而不必把整棵依賴圖一次載進記憶體。關鍵在於他們用這個機制「推斷」出 module 邊界,而不必重構應用程式碼去配合——這是一個很 pragmatic 的取捨:能用工具設定解決的,就不要求工程師改架構。
第三個 pass 是整套設計裡最關鍵的一步:用 @ts-expect-error 做 suppression,把「轉換」與「修錯」拆開。@ts-expect-error 的語義是「我知道下一行有型別錯,先讓它過,但如果哪天這行不再報錯,請反過來提醒我把這個註解刪掉」——它不像 @ts-ignore 那樣無條件閉嘴,而是一個會自我提醒的暫時性抑制。這個語義對「先標記、後消化」的策略剛好:每修好一處型別、底下的 @ts-expect-error 就會變成「多餘」而被工具揪出來,suppression 清單於是能單調收斂、不會有人偷偷留著沒用的抑制。
如果堅持遷移前把所有型別錯誤改完,那等於要在一個移動標靶(持續變動的 main)上做幾萬處手改,永遠追不上——你改完一批,main 又長出新的 Flow 程式,下次重跑又是一批新錯。改用「先標記、後消化」,轉換這件事就永遠能跑綠,型別錯誤退化成一份切換後可以慢慢消化的待辦清單——而這份清單的數字,恰好是這次遷移最反直覺的部分。
97,000 個 suppression 卻換到更高的覆蓋率
第一次對 Dashboard 全量轉換,codemod 產出了 97,000 個 suppression 註解——大約每 1,000 行就有一個。對照之下,原本的 Flow codebase 只有 5,000 個 suppression。乍看像是「轉完反而更糟」:抑制的錯誤多了將近二十倍。
但兩件事讓這個數字不那麼可怕。其一,透過反覆改進 codemod,這 97,000 被壓到了 37,000——也就是說大部分 suppression 不是真的型別問題,而是 codemod 還不夠聰明、轉出來的東西 TS 不認,改好 codemod 就消失了。其二、也是最反直覺的:即便 suppression 數字仍遠高於 Flow,TS 量到的 type coverage 反而更高。
為什麼?因為 suppression 數量和 type coverage 量的是不同東西。suppression 是「這幾處我明確讓型別檢查閉嘴」;coverage 是「整個 codebase 裡有多少比例的值是有型別資訊覆蓋的」。Flow 之所以 suppression 少,部分原因是它對很多第三方套件根本沒有型別定義可檢查——沒有型別,就沒有錯誤可抑制,但那片區域其實是型別真空。TS 那套豐富的 @types/* 生態把這些真空填上,於是整體 coverage 上去了,代價是某些原本「無型別所以無錯」的地方現在有型別、暴露出需要 suppress 的真實落差。下面把這三個數字畫成同一張圖。
suppression 數量的三個節點 · 點長條看數值
suppression 數量(@ts-expect-error / Flow suppress 註解)
首次轉換 97,000 個 suppression 改進 codemod 後降至 37,000,但 TS 的 type coverage 反而高於 Flow。
這張圖的關鍵閱讀方式是:97k 那根長條不是「遷移的品質」,而是「codemod 第一版的粗糙程度」;從 97k 到 37k 的下降幾乎全來自改 codemod,而不是改應用程式碼。把 suppression 當成 codemod 成熟度的代理指標,而非 codebase 健康度的指標——這個區分讓團隊能客觀地問「我們的轉換器夠好了嗎」,而不是被一個嚇人的絕對數字綁架。
這裡有一個普遍適用的度量教訓:當你拿一個新工具量舊系統,第一輪數字往往反映的是「工具還不會用」,而非「系統有多糟」。如果 Stripe 在看到 97,000 這個數字時就退縮——「我們的 codebase 顯然爛到不能遷」——這次遷移會在第一週就被自己嚇死。真正該問的是「這 97,000 裡有多少是 codebase 真的問題,多少是轉換器的問題」,而回答這個問題的方法就是去改 codemod、看數字掉到哪裡為止。掉到 37,000 後不再明顯下降,那剩下的 37,000 才比較接近「真實落差」。把一個嚇人的絕對值分解成「工具雜訊 + 真實訊號」,是任何大規模自動化遷移都會遇到的判讀問題。
還有一個值得記住的對照:切換上線時,這個 codebase 帶著 37,000 個 @ts-expect-error 跑在 production。對某些團隊文化,「帶著三萬七千個已知型別抑制上線」聽起來像不可接受的技術債。但這正是 big-bang 哲學的核心取捨——把「達到完美型別」這個目標從「切換的前置條件」降級成「切換後的持續任務」。完美是收斂的終點,不是切換的閘門。願不願意接受這個降級,往往決定了一個團隊能不能走 big-bang。
不凍結 repo,把巨型 PR 落地
一個 370 萬行的 PR 最大的敵人不是型別錯誤,是時間——只要 main 還在動,這個 branch 就一直在腐爛。幾百名工程師每天還在往 main 推 Flow 程式,任何傳統意義的「長命 migration branch」在合進去之前早就被 merge conflict 淹死了。Stripe 的解法是把 branch 的存活時間壓到趨近於零:codemod 被設計成可重跑,而不是一條一次性的 migration pipeline。
這個性質改變了整個賽局。既然轉換是一個能對任意版本的 main 重新執行、且結果可信(因為每個 edge case 都有回歸測試守著)的純函式,那就沒必要維護一個長命 branch 去跟 main 賽跑。做法變成:在切換前把 codemod 養到足夠成熟,然後在切換那一刻才重生 migration branch——對當下最新的 main 跑一遍 codemod,產出一個幾乎沒有 drift 的新 branch。branch 越短命,conflict 越少。
對比一下傳統的長命遷移 branch:你在某個時間點從 main 切出去、開始改,然後 main 持續前進,你得不斷 rebase / merge 把上游的新改動吸收進來。對一個跨越三百多萬行的 branch,每一次 rebase 都可能是幾百個 conflict,而且這個過程沒有終點——只要 branch 還活著,conflict 就持續生成。Stripe 的可重跑 codemod 把這個方程式整個改寫:不是「維護一個 branch 並持續吸收 main 的變化」,而是「保留一個能在任意時刻重新生成 branch 的函式」。狀態被換成了一個可重複執行的轉換,conflict 從「持續發生的事件」變成「一次性產出的瞬間」。
切換週(cutover week)做的是降低未知數:把 build 在 CI 上跑綠、部署到 QA、找 product team 對 user-facing 功能手測。這一週抓到了像「loader 寫死 .js 副檔名導致缺翻譯」這種只有真人點才會發現的 bug,並開了一個 Slack channel 專門協調 rollout。
然後是那個週末。週六(3 月 5 日)是一次全流程彩排:重生一份 migration branch、跑自動腳本、部署 QA、再驗證、再請 product team 測——確認沒有新問題。週日清晨(3 月 6 日)是正式切換,照一個收得很緊的順序走:鎖住 monorepo(阻止 main drift)→ 跑最後一輪 QA → 提交那個改動 →「merged cleanly and our automated tests passed」→ 起 production 部署 → 解鎖 repo → 通知所有工程師。整個 repo 被鎖住的窗口,只有清晨那段最後驗證到合入的時間。
為什麼要鎖 repo?因為從「重生 branch」到「合入 main」之間,只要有人往 main 推一個新 commit,這個 branch 就立刻有了 drift——它是基於某個 main 快照生成的,那個快照一變,乾淨合入的保證就破了。鎖住 monorepo 等於凍結那個快照,讓 codemod 的輸出與 main 的當前狀態完全一致,merge 才能「cleanly」。而能把鎖的窗口壓到一個清晨,靠的正是前面所有準備:彩排已經證明流程可跑、QA 已經抓掉肉眼可見的 bug、codemod 已經穩定到重生一次只要跑腳本。鎖的時間長短,直接反映了你對整套流程的信心程度。
這就是為什麼前面那張時間軸最後一格是「鎖 → 驗 → 合 → 部署 → 解鎖」一連串緊湊動作:可重跑的 codemod 把「凍結 repo 多久」這個成本,從「整個遷移期」壓縮成「一個週日清晨」。對幾百名工程師而言,他們感受到的全部代價,就是某個週末別往 main 推東西——而換來的是週一醒來時,整個 codebase 已經是 TypeScript。
隔天,幾百名工程師開始寫 TypeScript。轉換的平滑程度從另一段內部回饋看得最清楚——一位面試時還對這次遷移半信半疑的工程師寫道:
「When I was interviewing, I heard the migration from Flow to TypeScript was underway. I was admittedly skeptical, seeing prior teams struggle with the complexity and effort of even small codebases. The fact that I was back to normal in a few minutes [on] Monday was humbling.」
「Monday 早上幾分鐘就回到正常工作」——對一個 370 萬行的語言遷移來說,「幾分鐘」是個荒謬的數字,而這正是 big-bang 的回報:沒有並存期、沒有互通層,工程師醒來只面對一種語言。有人把它稱作「在 Stripe 期間最大的單一開發者生產力提升」。
當然,這條路有清楚的前提,不是誰都能照搬。它要求:(一)codebase 規模大到值得投入幾個月養 codemod;(二)緊耦合到 incremental 的互通層成本足以蓋過 big-bang 的風險;(三)願意接受「先標記、後消化」——切換時帶著 37,000 個 @ts-expect-error 上線,把修錯延後成一份待辦清單,而不是 blocker。少了任何一條,逐檔遷可能仍是更穩的選擇。
但這次遷移真正可遷移(pun intended)的洞見,與 Flow 或 TypeScript 本身無關,而是把一個高風險的一次性事件,拆成三個被馴服的子問題:用回歸測試守護的可重跑 codemod 換掉「長命 branch 跟 main 賽跑」;用 @ts-expect-error 把「轉換」與「修錯」解耦,讓轉換永遠跑綠;用「重生 branch + 鎖一個清晨」把凍結 repo 的成本壓到最小。big-bang 看起來像一次豪賭,實際上是把賭注一個一個拆解成可驗證的工程步驟。
真正落地的不是膽量,是準備:當轉換是一個有回歸測試守護、可對最新 main 重跑的純函式,「一次合 370 萬行」就從一場豪賭變成一個鎖了一個週日清晨的部署。