我們用了一個(gè)周末,將 370 萬(wàn)行代碼遷移到了 TypeScript
TypeScript 是增長(zhǎng)最快的語(yǔ)言之一,最近幾年逐漸成為很多大廠的首選工具。最近,Stripe 將最大的 JavaScript 代碼庫(kù)(用于支持 Stripe Dashboard 功能)從 Flow 遷移到了 TypeScript。于是通過(guò)單一 PR 請(qǐng)求,轉(zhuǎn)換了超過(guò) 370 萬(wàn)行代碼。第二天,幾百名工程師快速跟進(jìn),開始為自己的項(xiàng)目編寫 TypeScript。
TypeScript 目前已經(jīng)成為 JavaScript 類型檢查的客觀標(biāo)準(zhǔn),Stripe 已經(jīng)把這次使用的 TypeScript 轉(zhuǎn)換工具分享到 GitHub(https://github.com/stripe-archive/flow-to-typescript-codemod),希望幫助更多朋友能夠輕松完成類似的大規(guī)模遷移。
以下是他們遷移的具體步驟:
1
Stripe 的 JavaScript 類型檢查簡(jiǎn)史
Stripe 是一款誕生于 2012 年的大規(guī)模前端應(yīng)用程序,共包含 stripe.com、Stripe JS 和 Stripe Dashboard 幾大組成部分。隨著業(yè)務(wù)的發(fā)展,我們開始對(duì) JS 代碼進(jìn)行類型檢查以提高產(chǎn)品質(zhì)量和可靠性。2016 年,我們率先采用了 Flow——這是 Facebook 當(dāng)時(shí)專門開發(fā)的 JavaScript 類型檢查系統(tǒng)。之后幾年間,F(xiàn)low 一直為我們大部分前端應(yīng)用程序的類型安全保駕護(hù)航。
為 API 資源和關(guān)聯(lián)端點(diǎn)生成的 Flow 類型示例。
然而,工程師們?cè)趯?shí)際使用中發(fā)現(xiàn) Flow 仍有諸多不足。首先,這款類型檢查器會(huì)輕松耗盡筆記本電腦的內(nèi)存,而編輯器內(nèi)集成也速度緩慢、可靠性低下。與此同時(shí),微軟開發(fā)的另外一種類型系統(tǒng) TypeScript 卻在異軍突起,憑借著完善的工具組合和強(qiáng)大社區(qū)而廣受好評(píng)。于是,越來(lái)越多的 Stripe 工程師呼吁轉(zhuǎn)向 TypeScript。
Stripe 擁有專門的開發(fā)者生產(chǎn)力團(tuán)隊(duì),職責(zé)就是為工程師們提供最高效、最順手的開發(fā)環(huán)境,所以他們的滿意度就是生產(chǎn)力團(tuán)隊(duì)的使命。我們一直在努力確定開發(fā)者們最關(guān)心的緊迫問(wèn)題:例如,我們?cè)谌块_發(fā)工具中都集成了上報(bào)錯(cuò)誤 / 不便的功能,確保將情況快速發(fā)送給相關(guān)團(tuán)隊(duì)以評(píng)判優(yōu)先級(jí)。對(duì) TypeScript 的支持已經(jīng)相當(dāng)緊迫,于是支持團(tuán)隊(duì)決定在整個(gè)公司內(nèi)幫助前端工程師轉(zhuǎn)向 TypeScript。
2
選擇正確的遷移策略
在所有前端代碼庫(kù)中,最大的那個(gè)負(fù)責(zé)為 Stripe Dashboard 和其他一些面向用戶的產(chǎn)品提供支持。Dashboard 代碼庫(kù)中的不同組件保持著緊密耦合,而且沒有清晰拆分的依賴圖表。如果選擇面向 TypeScript 開展增量遷移,就意味著開發(fā)人員在一段時(shí)間內(nèi)必須同時(shí)使用兩種語(yǔ)言來(lái)完成常見任務(wù)。此外,我們還需要一個(gè)互操作層來(lái)同步兩種語(yǔ)言之間的類型定義,并在整個(gè)開發(fā)過(guò)程中始終保持二者一致。
2020 年末,我們組建了一支新的橫向 JavaScript 基礎(chǔ)設(shè)施團(tuán)隊(duì):在這里,工程師們只關(guān)注一項(xiàng)工作——提升 Stripe 編寫 JS 代碼時(shí)的體驗(yàn)。而團(tuán)隊(duì)的首要挑戰(zhàn)之一,就是用 TypeScript 替換掉 Flow,同時(shí)回避掉漫長(zhǎng)且充滿不確定性的遷移過(guò)程。
我們首先與其他開展過(guò)類似遷移的企業(yè)進(jìn)行交談,并參考了 Airtable 和 Zapier 的經(jīng)歷回顧文章。這兩家企業(yè)都開發(fā)出自動(dòng)化腳本,用于將一種語(yǔ)言轉(zhuǎn)換成另一種語(yǔ)言、貫穿整個(gè)代碼庫(kù)運(yùn)行,再把輸出結(jié)果合并成單一提交。Airtable 已經(jīng)把自己的轉(zhuǎn)換腳本以“codemod”源到源轉(zhuǎn)換工具的形式上傳至 GitHub,它就完全能夠解析 Flow 代碼并生成相應(yīng)的 TypeScript。
這種遷移方式大大降低了工程師們的工作負(fù)擔(dān),也不需要為相同的產(chǎn)品維護(hù)兩套類型系統(tǒng)。這么一看,從 Flow 到 TypeScript 的道路頓時(shí)平坦了起來(lái)。
3
規(guī)劃、籌備和迭代
Airtable 工具那出色的代碼轉(zhuǎn)換質(zhì)量給我們留下了深刻印象,于是 Stripe 決定把它作為遷移工作的基礎(chǔ)。這里要感謝 Airtable 團(tuán)隊(duì)開發(fā)并分享的這份工作成果——開源社區(qū)正是在無(wú)數(shù)這類案例的支持下,才變得如此興盛蓬勃。
我們首先將 Airtable 的 codemod 復(fù)制到 Stripe 的 monorepo 當(dāng)中,從而指向內(nèi)部代碼來(lái)運(yùn)行。我們的 JS 項(xiàng)目中大量用到了 Sail——一個(gè)由嚴(yán)格類型化 React 組件構(gòu)成的共享設(shè)計(jì)系統(tǒng),所以我們決定在遷移之初先從 Sail 入手。
我們?yōu)?Sail 生成了 TypeScript 定義,而非直接把代碼轉(zhuǎn)換成 TypeScript,這樣就能保證它同時(shí)支持用 Flow 和 TypeScript 編寫的應(yīng)用程序。為了安全支持這兩種類型系統(tǒng),我們編寫了測(cè)試來(lái)驗(yàn)證 TypeScript 定義對(duì)于底層 Flow 代碼做出的具體更改。這種方法對(duì)于大規(guī)模代碼庫(kù)來(lái)說(shuō)可能太過(guò)麻煩,好在 Sail 組件擁有明確且嚴(yán)格的接口,所以我們的測(cè)試倒是相當(dāng)順?biāo)臁?br>
還有個(gè)問(wèn)題,codemod 的底子很好、但功能并不全面:對(duì)于很多文件,它在轉(zhuǎn)換中可能發(fā)生崩潰,輸出結(jié)果也不夠完善。所以在幾個(gè)月時(shí)間里,我們通過(guò)一次次迭代解決了這些較為極端的句法和語(yǔ)義案例。
舉個(gè)簡(jiǎn)單的例子,JS 箭頭函數(shù)可以在沒有 return 語(yǔ)句時(shí)直接返回單一表達(dá)式,如下所示:
const linesOfCode = () => 7;
JS 對(duì)象字面量會(huì)使用大括號(hào)來(lái)體現(xiàn)屬性定義。但因?yàn)榇罄ㄌ?hào)也被用于描述語(yǔ)句塊,所以要從箭頭函數(shù)返回對(duì)象字面量,還需要引入一組額外的括號(hào)來(lái)消除歧義:
const currencyMap = () => ({ca:'CAD',us:'USD'});
我們注意到,codemod 會(huì)錯(cuò)誤刪除掉箭頭函數(shù)中這些額外的括號(hào),但這個(gè)問(wèn)題只發(fā)生在泛型函數(shù)(接受類型參數(shù)的函數(shù))當(dāng)中。可一旦刪除,結(jié)果語(yǔ)法在標(biāo)準(zhǔn) JS 中將不再可用:
// bad!
const wrapper = (arg:T) => {wrapped: T};
于是我們修復(fù)了這個(gè)問(wèn)題,并添加測(cè)試以防止其再次發(fā)生。整個(gè)遷移過(guò)程中,我們進(jìn)行了大量類似的語(yǔ)法修復(fù),才最終讓之前龐大的代碼庫(kù)“舊月的換新顏”。
在確保 Sail 能夠在 TypeScript 中正常起效之后,我們又開發(fā)了幾個(gè)包含數(shù)百個(gè) JS 模塊的內(nèi)部應(yīng)用程序。我們還向 codemod 中添加二次檢查,希望進(jìn)一步減少生成代碼中的錯(cuò)誤,同時(shí)使用 TypeScript 的 @ts-expect-error 注釋來(lái)標(biāo)記這些錯(cuò)誤??梢钥吹?,我們的基本思路并不是提前解決掉每個(gè)錯(cuò)誤,而是盡快替換掉 Flow,并在過(guò)程當(dāng)中跟蹤實(shí)際發(fā)生的 TypeScript 錯(cuò)誤抑制并加以解決。
Dashboard 代碼庫(kù)的初始階段共引發(fā)超過(guò) 97000 個(gè)錯(cuò)誤抑制。在更新了 codemod 的迭代方法之后,這個(gè)數(shù)字被控制到了 37000 個(gè),相當(dāng)于每千行代碼有 1 個(gè)錯(cuò)誤抑制。相比之下,F(xiàn)low 代碼這邊的錯(cuò)誤抑制大概是 5000 個(gè)。
Flow 和 TypeScript 都支持對(duì)類型覆蓋率進(jìn)行測(cè)量,而我們驚喜地發(fā)現(xiàn)雖然 TypeScript 這邊的抑制數(shù)字更大,但這主要是因?yàn)槠鋱?bào)告覆蓋率要比 Flow 更高。這應(yīng)該是因?yàn)?TypeScript 中的可用第三方類型定義在數(shù)量和質(zhì)量上都優(yōu)于 Flow,而后者則因?yàn)槿鄙龠@些定義而導(dǎo)致類型覆蓋率不足。
不過(guò)面對(duì)包含數(shù)萬(wàn)個(gè)模塊的 Dashboard 時(shí),我們的方法對(duì) TypeScript 編譯器產(chǎn)生了巨大的內(nèi)存壓力。而解決這個(gè)問(wèn)題的主要工具,就是 TypeScript 項(xiàng)目引用:盡管 Dashboard 并不進(jìn)行模塊區(qū)分,但我們還是正確推斷出了它的模塊結(jié)構(gòu),并據(jù)此建立起項(xiàng)目引用。通過(guò)這種方式,我們得以直接在代碼庫(kù)之上運(yùn)行 TypeScript,且無(wú)需重構(gòu)大量應(yīng)用程序代碼。
4
正式上線
每周,都有數(shù)百名工程師在奮力推進(jìn) Dashboard 項(xiàng)目的遷移工作。但如此徹底的變動(dòng)不容小覷,我們也不想在周內(nèi)工作量合并這些更新。因此,團(tuán)隊(duì)決定選擇 3 月 6 日星期天鎖定 Stripe monorepo,同時(shí)上線我們的新分支。
在合并前一周,我們開始通過(guò) CI 系統(tǒng)將 build 傳遞并部署到 QA 環(huán)境當(dāng)中。畢竟除了 TypeScript 對(duì)項(xiàng)目本體的檢查之外,我們還得更新 ESLint、Jest、Webpack、Metro 等負(fù)責(zé)處理源代碼的其他工具。
這里出現(xiàn)了一個(gè)特別的痛點(diǎn):Jest 快照測(cè)試。Jest 生成的快照文件中,會(huì)包含一條對(duì)快照生成文件的硬編碼引用。由于 codemod 會(huì)給 TypeScript 文件生成.ts 或者.tsx 的擴(kuò)展名,所以快照文件所引用的測(cè)試源將直接失效。為此,我們決定把生成文件的擴(kuò)展名統(tǒng)一成.tsx,這樣就可以批量重寫快照并保證測(cè)試 100% 通過(guò)。
此外,我們還發(fā)現(xiàn)對(duì)某些 TypeScript 兼容代碼的修復(fù)會(huì)帶來(lái)不少工作量,甚至把日程安排推遲數(shù)周。其中的典型案例就是我們自定義的 ESLint 規(guī)則:其中一項(xiàng)規(guī)則會(huì)重新排序?qū)胍詮?qiáng)制保證各文件間的一致性,但該規(guī)則是針對(duì) Babel 的 Flow 解析器編寫的,所以生成的抽象語(yǔ)法樹與 TypeScript 解析器會(huì)略有不同。在這種情況下,我們決定先禁用某些檢查,并在轉(zhuǎn)換完成后再行恢復(fù)。
通過(guò)手動(dòng)上傳 build,我們?cè)?Dashboard 中與面向用戶功能的產(chǎn)品團(tuán)隊(duì)成功會(huì)合。盡管 Dashboard 擁有廣泛的單元和功能測(cè)試,但端到端測(cè)試覆蓋率卻比較有限。因此,各產(chǎn)品相關(guān)方就必須有能力開展手動(dòng)測(cè)試。測(cè)試中同樣暴露出不少小 bug,我們搶在最后一周成功將其解決:例如,由于翻譯加載代碼中存在一個(gè)硬編碼.js 擴(kuò)展名,因此我們無(wú)法為非英文版 Dashboard 用戶正確加載翻譯內(nèi)容。
整個(gè)過(guò)程給了我們很大信心,但這種顛覆性的變更還是讓大家有點(diǎn)忐忑:雖然我們牢牢掌握著開發(fā)工具和構(gòu)建過(guò)程,但畢竟代碼庫(kù)中的每個(gè)文件都發(fā)生了變化。轉(zhuǎn)換腳本中的任何一點(diǎn)細(xì)微錯(cuò)誤(例如從多個(gè)組件間共享的對(duì)象中刪除一個(gè)空字段)都有可能引發(fā)面向用戶的錯(cuò)誤,而任何現(xiàn)有自動(dòng)化測(cè)試都發(fā)現(xiàn)不了這樣的錯(cuò)誤。
另外,這類故障可能會(huì)有多種表現(xiàn)方式,例如引發(fā)下游開發(fā)工具報(bào)錯(cuò)、或者導(dǎo)致構(gòu)建失敗等。為了及時(shí)發(fā)現(xiàn)這些意外狀況,我們只能依靠自動(dòng)化與環(huán)境監(jiān)控工具,同時(shí)建立了專門的協(xié)調(diào)部署 Slack 頻道,保證面向用戶的團(tuán)隊(duì)能夠及時(shí)收到報(bào)告并快速著手修復(fù)。
3 月 5 日星期六,團(tuán)隊(duì)生成了新的遷移分支并運(yùn)行了我們的自動(dòng)化腳本。之后,我們將該分支部署到 QA 環(huán)境并重復(fù)驗(yàn)證過(guò)程,包括產(chǎn)品團(tuán)隊(duì)提議的手動(dòng)測(cè)試。期間沒有發(fā)現(xiàn)任何新問(wèn)題,看起來(lái)一切合并準(zhǔn)備均已就緒。
3 月 6 號(hào)星期天一大早,我們就鎖定了 Stripe monorepo,又對(duì)遷移分支進(jìn)行衛(wèi)次 QA 測(cè)試,之后果斷提交了變更。整個(gè)合并過(guò)程干凈利落,我們的自動(dòng)化測(cè)試也全部通過(guò)。就這樣,TypeScript 順順當(dāng)當(dāng)進(jìn)入了生產(chǎn)部署。
憑借這一年來(lái)的細(xì)心調(diào)整與嚴(yán)謹(jǐn)測(cè)試,新代碼在接收生產(chǎn)流量后沒有發(fā)生任何意外。我們隨后解鎖了 repo,讓開發(fā)者們看到現(xiàn)在的 Dashboard 已經(jīng)運(yùn)行在 TypeScript 當(dāng)中了。
有一天我正在面新員工,碰巧聽說(shuō)公司打算從 Flow 遷移到 TypeScript。
其實(shí)我是有點(diǎn)懷疑的,畢竟之前不少團(tuán)隊(duì)在小型代碼庫(kù)上都身陷泥潭、糾纏不清,這么大規(guī)模的遷移能順利完成嗎?但禮拜一的現(xiàn)實(shí)證明我想多了——一切如常。
Eric Clemmons, Stripe 軟件工程師
遷移一結(jié)束,公司內(nèi)可以說(shuō)是好評(píng)如潮。完善順暢的遷移給工程師們留下了深刻印象,甚至有人認(rèn)為這是 Stripe 多年以來(lái)最成功的一次開發(fā)者生產(chǎn)力提升。我們很高興這一年的付出沒有白費(fèi),Stripe 的代碼庫(kù)終于獲得了顯著、甚至可以說(shuō)是顛覆性的改進(jìn)。
5
TypeScript……兩個(gè)月之后
轉(zhuǎn)換當(dāng)然不可能毫無(wú)瑕疵。在接下來(lái)的幾周內(nèi),我們的 JS 基礎(chǔ)設(shè)施團(tuán)隊(duì)又先后解決了幾個(gè)意外問(wèn)題。但最讓人吃驚的,是有工程師報(bào)告 CI 和本地 TypeScript 運(yùn)行間存在不一致。在 TypeScript 中,我們直接使用由 npm 安裝的各種第三方類型定義,而如果定義被更新,工程師們就得安裝新版本。而這明顯跟我們的 Flow 配置不同,其中的依賴更新很少會(huì)改變具體類型,因此我們只能提醒工程師們運(yùn)行 yarn install 進(jìn)行調(diào)試。
此外還有其他工作要做:我們知道更細(xì)粒度的項(xiàng)目引用可以進(jìn)一步提高性能,更好的緩存設(shè)計(jì)則能加快 CI 運(yùn)行速度。然而,這么點(diǎn)好處并不值得大費(fèi)周章。工程師們喜歡使用自動(dòng)依賴導(dǎo)入和代碼補(bǔ)齊之類的功能,也離不開 TypeScript 社區(qū)中廣泛的第三方類型定義和集成語(yǔ)料庫(kù)。這也保證了當(dāng)有新工程師加入 Stripe 編寫前端代碼時(shí),他們能第一時(shí)間使用自己最熟悉的語(yǔ)言、把全部精力都投入到功能設(shè)計(jì)上。
隨著 Dashboard 遷移工作的完成,JS 基礎(chǔ)設(shè)施團(tuán)隊(duì)開始進(jìn)一步提高 TypeScript 在整個(gè)公司內(nèi)的采用率。我們使用相同的工具又先后轉(zhuǎn)移了不少其他代碼庫(kù),包括我們的全部支付 UI Stripe Checkout。Stripe 的前端工程師們很快就適應(yīng)了這一切,開始用 TypeScript 編寫所有開發(fā)項(xiàng)目。
而且我們從遷移計(jì)劃立項(xiàng)之初就在發(fā)布更新,相當(dāng)于搞了個(gè)全程直播,反響同樣熱烈。來(lái)自整個(gè)行業(yè)的開發(fā)者紛紛給予關(guān)注,并在自己的代碼庫(kù)中嘗試應(yīng)用相同的改進(jìn)。為了支持大家,我們決定在 GitHub 上分享 Stripe 的 TypeScript 轉(zhuǎn)換代碼(https://github.com/stripe-archive/flow-to-typescript-codemod),希望能起到些許積極作用。
除了關(guān)于 JavaScript、Flow 和 TypeScript 的種種細(xì)節(jié)之外,我們還從此次遷移中總結(jié)出另一條重要經(jīng)驗(yàn):只要勤奮、專注、樂觀,對(duì)大規(guī)模代碼庫(kù)做出顯著改進(jìn)并非不可能。我們將保持住這份熱情,為 Stripe 乃至整個(gè)行業(yè)內(nèi)的工程師帶來(lái)更高的生產(chǎn)效率與更絲滑的工作體驗(yàn)。
原文鏈接:https://stripe.com/blog/migrating-to-typescript
作者:Andrew Lunny
來(lái)源:InfoQ
作者:Andrew Lunny
歡迎關(guān)注微信公眾號(hào) :前端Q