ts-migrate:大規(guī)模遷移到 TypeScript 的工具
文章信息
原文地址:https://medium.com/airbnb-engineering/ts-migrate-a-tool-for-migrating-to-typescript-at-scale-cd23bfeb5cc
原文作者:Sergii Rudenko
本文譯者:一川
寫在前面
TypeScript 是 Airbnb 前端 Web 開發(fā)的官方語言。然而,采用 TypeScript 和遷移包含數(shù)千個 JavaScript 文件的成熟代碼庫的過程并非一日之功。TypeScript 的采用經(jīng)歷了初始提議、多個團(tuán)隊采用、測試階段以及最終成為 Airbnb 前端開發(fā)的官方語言的過程。您可以在 Brie Bunge 的這次演講中了解更多關(guān)于我們?nèi)绾未笠?guī)模采用 TypeScript 的信息。
遷移策略
大規(guī)模遷移是一項復(fù)雜的任務(wù),我們探索了從 JavaScript 遷移到 TypeScript 的幾個選項:
混合遷移策略
逐個文件部分遷移,修復(fù)類型錯誤,然后重復(fù),直到遷移完整項目。allowJS 配置選項允許我們讓 TypeScript 和 JavaScript 文件并排共存于項目中,這使得這種方法成為可能!
在混合遷移策略中,我們不必暫停開發(fā),可以逐個文件逐步遷移。雖然,在大規(guī)模上,這個過程可能需要很長時間。此外,還需要對來自組織不同部門的工程師進(jìn)行培訓(xùn)和入職培訓(xùn)。
全面遷移
獲取 JavaScript 或部分 TypeScript 項目并將其完全轉(zhuǎn)換。我們需要添加一些 any 類型和 @ts-ignore 注釋,以便項目編譯時不會出錯,但隨著時間的推移,我們可以用更具描述性的類型替換它們。
選擇 all-in 遷移策略有幾個顯著優(yōu)勢:
- 跨項目的一致性:整體遷移將保證每個文件的狀態(tài)相同,工程師無需記住他們可以在哪里使用 TypeScript 功能以及編譯器將在何處防止基本錯誤。
- 只修復(fù)一種類型比修復(fù)文件容易得多:修復(fù)整個文件可能非常復(fù)雜,因為文件可能有多個依賴項。使用混合遷移,很難跟蹤遷移的實(shí)際進(jìn)度和文件的狀態(tài)。
看起來整體遷移是這里的明顯贏家!
但是,對大型成熟代碼庫執(zhí)行全面遷移的過程是一個沉重而復(fù)雜的問題。為了解決這個問題,我們決定使用代碼修改腳本——codemods!通過我們最初手動遷移到 TypeScript 的過程,我們認(rèn)識到可以自動化的重復(fù)操作。我們?yōu)檫@些步驟中的每一個都制作了代碼模塊,并將它們組合到總體遷移管道中
根據(jù)我們的經(jīng)驗,并不能 100% 保證自動遷移會產(chǎn)生完全沒有錯誤的項目,但我們發(fā)現(xiàn)下面概述的步驟組合為我們提供了最終遷移到無錯誤 TypeScript 的最佳結(jié)果項目。使用 codemods,我們能夠在一天內(nèi)將包含 50,000 多行代碼和 1,000 多個文件的項目從 JavaScript 轉(zhuǎn)換為 TypeScript!
基于這個管道,我們創(chuàng)建了一個名為“ts-migrate”的工具:
在 Airbnb,我們將 React 用于我們前端代碼庫的重要部分。這就是為什么 codemods 的某些部分與基于 React 的概念相關(guān)。ts-migrate 可以通過額外的配置和測試與其他框架或庫一起使用。
遷移過程的步驟
讓我們來看看將項目從 JavaScript 遷移到 TypeScript 所需的主要步驟以及這些步驟是如何實(shí)現(xiàn)的:
1、每個 TypeScript 項目的第一部分是創(chuàng)建一個 tsconfig.json 文件,如果需要可以通過ts-migrate執(zhí)行此操作。有一個默認(rèn)的配置文件模板和一個驗證檢查,可以幫助我們確保所有項目的配置一致。
這是基本級別配置的示例:
{
"extends": "../typescript/tsconfig.base.json",
"include": [".", "../typescript/types"]
}
2、tsconfig.json 文件就位后,下一步是將源代碼文件的文件擴(kuò)展名從 .js/.jsx 更改為 .ts/.tsx 。此步驟的自動化非常簡單,而且還消除了大量手動工作。
3、下一步是運(yùn)行 codemods!我們稱它們?yōu)椤安寮?。ts-migrate 的插件是代碼模塊,可以通過 TypeScript 語言服務(wù)器訪問其他信息。插件將字符串作為輸入并生成更新的字符串作為輸出。jscodeshift、TypeScript API、字符串替換或其他 AST 修改工具可用于為代碼轉(zhuǎn)換提供動力。
在每個步驟之后,我們檢查 Git 歷史記錄中是否有任何待處理的更改并提交它們。這有助于將遷移拉取請求拆分為更易于理解的提交,還可以跟蹤文件重命名。
我們將 ts-migrate 分成 3 個包:
- ts-migrate[https://github.com/airbnb/ts-migrate]
- ts-migrate-server[ter/packages/ts-migrate-server]
- ts-migrate-plugins[https://github.com/airbnb/ts-migrate/tree/master/packages/ts-migrate-plugins]
通過這樣做,我們能夠?qū)⑥D(zhuǎn)換邏輯與核心運(yùn)行器分開,并為不同的目的創(chuàng)建多個配置。目前,我們有兩個主要配置:migration 和 reignore 。
雖然遷移配置的目標(biāo)是從 JavaScript 遷移到 TypeScript,但 reignore 的目的是通過簡單地忽略所有錯誤使項目可編譯。當(dāng)代碼庫很大并且正在執(zhí)行以下任務(wù)時,Reignore 很有用:
- 升級 TypeScript 版本
- 對代碼庫進(jìn)行重大更改或重構(gòu)
- 改進(jìn)一些常用庫的類型
這樣,即使有一些我們不想立即處理的錯誤,我們也可以遷移項目。它使 TypeScript 或庫的更新變得更加容易。
這兩個配置都在 ts-migrate-server 上運(yùn)行,它由兩部分組成:
- TSServer :這部分與 VSCode 編輯器為編輯器和語言服務(wù)器之間的通信所做的非常相似。TypeScript 語言服務(wù)器的新實(shí)例作為單獨(dú)的進(jìn)程運(yùn)行,開發(fā)工具使用語言協(xié)議與服務(wù)器通信。
- 遷移運(yùn)行器:這部分運(yùn)行并協(xié)調(diào)遷移過程。
它需要以下參數(shù):
interface MigrateParams {
rootDir: string; // path to the root directory
config: MigrateConfig; // migration config, including list of
// plugins it contains
server: TSServer; // an instance of the TSServer fork
}
它執(zhí)行以下操作:
- 解析 tsconfig.json 。
- 創(chuàng)建 .ts 源文件。
- 將每個文件發(fā)送到 TypeScript 語言服務(wù)器進(jìn)行診斷。編譯器為我們提供了三種類型的診斷:semanticDiagnostics 、 syntacticDiagnostics 和 suggestionDiagnostics 。我們使用這些診斷來查找源代碼中有問題的地方。基于唯一的診斷代碼和行號,我們可以識別問題的潛在類型并應(yīng)用必要的代碼修改。
- 在每個文件上運(yùn)行所有插件。如果文本因插件執(zhí)行而發(fā)生變化,我們會更新原始文件的內(nèi)容并通知 TypeScript 語言服務(wù)器文件已更改。
您可以在示例包或主包中找到 ts-migrate-server 用法示例。ts-migrate-example 還包含插件的基本示例。它們分為 3 個主要類別:
- 基于js代碼轉(zhuǎn)化
- 基于 TypeScript 抽象語法樹 (AST)
- 基于文本
存儲庫中有一組示例來演示如何構(gòu)建各種簡單的插件并將它們與 ts-migrate-server 結(jié)合使用。以下是轉(zhuǎn)換以下代碼的示例遷移管道:
function mult(first, second) {
return first * second;
}
into:
function tlum(tsrif: number, dnoces: number): number {
console.log(`args: ${arguments}`);
return tsrif * dnoces;
}
ts-migrate 在上面的例子中做了 3 次轉(zhuǎn)換:
- 反轉(zhuǎn)所有標(biāo)識符 first -> tsrif
- 添加類型到函數(shù)聲明 function tlum(tsrif, dnoces) -> function tlum(tsrif: number, dnoces: number): number
- 插入 console.log(‘a(chǎn)rgs:${arguments}’);
生成插件
實(shí)際的插件位于一個單獨(dú)的包中 — ts-migrate-plugins。讓我們來看看其中的一些。我們有兩個基于 jscodeshift 的插件:explicitAnyPlugin 和 declareMissingClassPropertiesPlugin。jscodeshift 是一個可以使用 recast 包將 AST 轉(zhuǎn)換回字符串的工具通過使用函數(shù) toSource() ,我們可以直接更新文件的源代碼。
explicitAnyPlugin 背后的主要思想在于從 TypeScript 語言服務(wù)器中提取所有語義診斷錯誤以及行號。然后,我們需要在診斷中指定的行上添加 any 類型。這種方法允許我們解決錯誤,因為添加 any 類型可以修復(fù)編譯錯誤。
使用前:
const fn2 = function(p3, p4) {}
const var1 = [];
使用后:
const fn2 = function(p3: any, p4: any) {}
const var1: any = [];
declareMissingClassPropertiesPlugin 使用代碼 2339 進(jìn)行所有診斷(你能猜出這段代碼的含義嗎?),如果它可以找到缺少標(biāo)識符的類聲明,插件將使用 any 類型注釋將它們添加到類主體中。顧名思義,這個 codemod 只適用于 ES6 類。
下一類插件是基于 TypeScript AST 的插件。通過解析 AST,我們可以在源文件中生成一個包含以下類型的更新數(shù)組:
type Insert = { kind: 'insert'; index: number; text: string };
type Replace = { kind: 'replace'; index: number; length: number; text: string };
type Delete = { kind: 'delete'; index: number; length: number };
生成更新后,唯一剩下的就是以相反的順序應(yīng)用更改。如果通過這些操作的結(jié)果,我們收到新文本,我們將更新源文件。讓我們來看看其中幾個基于 AST 的插件:stripTSIgnorePlugin 和 hoistClassStaticsPlugin。
stripTSIgnorePlugin 是遷移管道中的第一個插件。它從文件中刪除所有 @ts-ignore 1 實(shí)例。如果我們將一個 JavaScript 項目轉(zhuǎn)換為 TypeScript,這個插件將不會做任何事情。但是,如果它是部分 TypeScript 項目(在 Airbnb,我們有幾個項目處于這種狀態(tài)),這是必不可少的第一步。只有在刪除 @ts-ignore 注釋后,TypeScript 編譯器才會發(fā)出所有需要解決的診斷錯誤。
const str3 = foo
? // @ts-ignore
// @ts-ignore comment
bar
: baz;
轉(zhuǎn)換:
const str3 = foo
? bar
: baz;
刪除 @ts-ignore 評論后,我們運(yùn)行 hoistClassStaticsPlugin 。該插件遍歷文件中的所有類聲明。它決定了我們是否可以提升標(biāo)識符或表達(dá)式,并決定一個賦值是否已經(jīng)被提升到一個類。
為了能夠快速迭代并防止回歸,我們?yōu)槊總€插件和 ts-migrate 添加了一系列單元測試。
React相關(guān)插件
reactPropsPlugin 將類型信息從 PropTypes 轉(zhuǎn)換為 TypeScript 道具類型定義。它基于 Mohsen Azimi 編寫的出色工具。我們只需要在包含至少一個 React 組件的 .tsx 文件上運(yùn)行這個插件。reactPropsPlugin 查找所有 PropTypes 聲明并嘗試使用 AST 和簡單的正則表達(dá)式(如 /number/ 或更復(fù)雜的情況如 /objectOf$/ )來解析它們。當(dāng)檢測到 React 組件(功能組件或類組件)時,它會轉(zhuǎn)換為具有新屬性類型的組件:type Props = {…}; 。
reactDefaultPropsPlugin 涵蓋了 React 組件的 defaultProps 模式。我們使用一種特殊類型來表示具有默認(rèn)值的props:
type Defined<T> = T extends undefined ? never : T;
type WithDefaultProps<P, DP extends Partial<P>> = Omit<P, keyof DP> & {
[K in Extract<keyof DP, keyof P>]:
DP[K] extends Defined<P[K]>
? Defined<P[K]>
: Defined<P[K]> | DP[K];
};
我們試圖找到默認(rèn)的 props 聲明并將它們與上一步生成的組件 props 類型合并。
狀態(tài)和生命周期的概念在 React 生態(tài)系統(tǒng)中非常普遍。我們在兩個插件中解決了這些問題。如果組件是有狀態(tài)的,則 reactClassStatePlugin 會生成一個新的 type State = any; 并且 reactClassLifecycleMethodsPlugin 會使用適當(dāng)?shù)念愋妥⑨尳M件生命周期方法。這些插件的功能可以擴(kuò)展,包括用更具描述性的類型替換 any 的能力。
對于狀態(tài)和道具,還有更多改進(jìn)和更好類型支持的空間。然而,作為一個起點(diǎn),這個功能被證明是足夠的。我們也不涉及 hooks,因為在遷移開始時我們的代碼庫使用的是舊版本的 React。
確保項目編譯成功
我們的目標(biāo)是獲得一個具有基本類型覆蓋率的編譯 TypeScript 項目,不會導(dǎo)致應(yīng)用程序運(yùn)行時行為發(fā)生變化。
在所有轉(zhuǎn)換和代碼修改之后,我們的代碼可能具有不一致的格式并且一些 lint 檢查可能會失敗。我們的前端代碼庫依賴于 prettier-eslint 設(shè)置——Prettier 用于自動格式化代碼,ESLint 確保代碼遵循最佳實(shí)踐。因此,我們可以通過從我們的插件運(yùn)行 eslint-prettier 來快速修復(fù)前面步驟可能引入的任何格式問題。
遷移管道的最后一部分確保解決所有 TypeScript 編譯違規(guī)問題。為了檢測和修復(fù)潛在的錯誤,tsIgnorePlugin 使用行號進(jìn)行語義診斷,并插入帶有有用解釋的 @ts-ignore 注釋,例如:
// @ts-ignore ts-migrate(7053) FIXME: No index signature with a parameter of type 'string...
const { field1, field2, field3 } = DATA[prop];
// @ts-ignore ts-migrate(2532) FIXME: Object is possibly 'undefined'.
const field2 = object.some_property;
我們還添加了對 JSX 語法的支持:
{*
// @ts-ignore ts-migrate(2339) FIXME: Property 'NORMAL' does not exist on type 'typeof W... */}
<Text weight={WEIGHT.NORMAL}>
some text
</Text>
<input
id="input"
// @ts-ignore ts-migrate(2322) FIXME: Type 'Element' is not assignable to type 'string'.
name={getName()}
/>
在注釋中包含有意義的錯誤消息可以更輕松地解決問題并重新訪問需要注意的代碼。這些注釋與 $TSFixMe 2 相結(jié)合,使我們能夠收集有關(guān)代碼質(zhì)量的有用數(shù)據(jù)并識別代碼中可能存在問題的區(qū)域。
最后但同樣重要的是,我們需要運(yùn)行 eslint-fix 插件兩次。在 tsIgnorePlugin 之前給定的格式可能會影響我們將在何處獲得編譯器錯誤。在 tsIgnorePlugin 之后再次出現(xiàn),因為插入 @ts-ignore 注釋可能會引入新的格式錯誤。
總結(jié)
我們的遷移故事正在進(jìn)行中:我們有一些遺留項目仍在使用 JavaScript,我們的代碼庫中仍然有大量的 $TSFixMe 和 @ts-ignore 評論。
然而,使用 ts-migrate 極大地加快了我們的遷移過程和生產(chǎn)力。工程師們能夠?qū)W⒂诰幋a優(yōu)化,而不是手動逐個文件遷移。目前,我們的 600 萬行前端 monorepo 中約有 86% 已轉(zhuǎn)換為 TypeScript,我們有望在年底前實(shí)現(xiàn) 95% 的轉(zhuǎn)換。
您可以查看 ts-migrate 并在 Github 存儲庫的主包中找到有關(guān)如何安裝和運(yùn)行 ts-migrate 的說明。如果您發(fā)現(xiàn)任何問題或有改進(jìn)的想法,我們歡迎您的貢獻(xiàn)!
非常感謝 Brie Bunge,他是 Airbnb TypeScript 的推動者,也是 ts-migrate 的創(chuàng)建者。感謝 Joe Lencioni 幫助我們在 Airbnb 采用 TypeScript 并改進(jìn)我們的 TypeScript 基礎(chǔ)設(shè)施和工具。特別感謝 Elliot Sachs 和 John Haytko 為 ts-migrate 做出的貢獻(xiàn)。感謝所有提供反饋和幫助的人!
Footnote
我們想記下我們在此過程中發(fā)現(xiàn)的一些關(guān)于遷移的事情,它們可能會有用:
- TypeScript 3.7 版本引入了 @ts-nocheck 注釋,可以將其添加到 TypeScript 文件的頂部以禁用語義檢查。我們沒有使用這個注釋,因為它之前不支持 .ts/.tsx 文件,但它在遷移過程中也可以成為一個很好的中間階段幫手。
- TypeScript 3.9 版本引入了 @ts-expect-error 注釋。當(dāng)一行以 @ts-expect-error 注釋為前綴時,TypeScript 將禁止報告該錯誤。如果沒有錯誤,TypeScript 將報告不需要 @ts-expect-error 。在 Airbnb 代碼庫中,我們改用 @ts-expect-error 注釋而不是 @ts-ignore 。
- @ts-ignore 注釋允許我們告訴編譯器忽略下一行的錯誤。
- 我們?yōu)?any 類型引入了自定義別名 — 和函數(shù)類型TSFixMeFunction = (…args: any[]) => any; 。盡管最佳實(shí)踐是避免使用 any 類型,但使用它可以幫助我們簡化遷移過程并明確應(yīng)該重新訪問哪些類型。
寫在最后
在ts大量應(yīng)用在新項目,而對于js老項目維護(hù)卻日益艱難,在前端行業(yè)有很多人做過很多嘗試將js項目遷移到ts代碼。ts-migrate的出現(xiàn)在一定程度上,能夠幫助我們漸進(jìn)式遷移到ts,減少使用和開發(fā)成本。
學(xué)而知不足,水平有限,還望諸君多多指教。覺得文章不錯的讀者,不妨點(diǎn)個關(guān)注,收藏起來上班摸魚的時候品嘗。