TypeScript 漸進(jìn)遷移指南

英文 |?https://nextfe.com/
英文作者 | Nathaniel?
我之前寫了一篇《如何把 Node.js 項目從 JavaScript 遷移到 TypeScript 的指南》。指南的閱讀量超過了七千,不過其實當(dāng)時我對 JavaScript 和 TypeScript 的了解并不深入,把重心更多地放到特定工具上,而沒怎么從全局著手。最大的問題是我沒有提供遷移大型項目的解決方案。
顯然,大型項目不可能在短時間內(nèi)重寫一切。因此,我很想分享下我最近學(xué)到的遷移項目到 TypeScript 的主要經(jīng)驗。
遷移一個包含成千上百個文件的大型項目可能比你想象得要容易。整個過程主要分 3 步。
注意:本文假定你已經(jīng)有一定的 TypeScript 基礎(chǔ),同時會使用 Visual Studio Code,否則,一些地方可能不一定直接適用。
相關(guān)代碼:https://github.com/
開始引入類型
花了 10 個小時使用 console.log 排查問題后,你終于修復(fù)了 Cannot read property 'x' of undefined 問題,出現(xiàn)這個問題的原因是調(diào)用了可能為 undefined 的某個方法,給了你一個「驚喜」!你暗暗發(fā)誓,一定要把整個項目遷移到 TypeScript。但是看了看 lib、util、components 文件夾里上萬個 JavaScript 文件,你對自己說:「等以后吧,等我有空的時候。」當(dāng)然那一天永遠(yuǎn)也不會到來,因為總有各種酷炫的新特性等著加到應(yīng)用,客戶也不會因為項目是用 TypeScript 寫的就出大價錢。
如果我告訴你,你可以增量遷移到 TypeScript 并立刻從中受益呢?
添加神奇的 d.ts
d.ts 是 TypeScript 的類型聲明文件,其中聲明了代碼中用到的對象和函數(shù)的各種類型,不包含任何具體的實現(xiàn)。
假定你在寫一個即時通訊應(yīng)用,在 user.js 文件里有一個 user 變量和一些數(shù)組:
const user = {id: 1234,firstname: 'Bruce',lastname: 'Wayne',status: 'online',};const users = [user];const onlineUsers = users.filter((u) => u.status === 'online');console.log(onlineUsers.map((ou) => `${ou.firstname} ${ou.lastname} is ${ou.status}`));
那么對應(yīng)的 user.d.ts 會是:
export interface User {id: number;firstname: string;lastname: string;status: 'online' | 'offline';}
然后 message.js 里定義了一個函數(shù) sendMessage:
function sendMessage(from, to, message)
那么 message.d.ts 中相應(yīng)的類型會是:
type sendMessage = (from: string, to: string, message: string) => boolean
不過,sendMessage 也許沒那么簡單,參數(shù)的類型可能更復(fù)雜,也可能是一個異步函數(shù)。
你可以使用 import 引入其他文件中定義的復(fù)雜類型,保持類型文件簡單明了,避免重復(fù)。
import { User } from './models/user';type Message = {content: string;createAt: Date;likes: number;}interface MessageResult {ok: boolean;statusCode: number;json: () => Promise; text: () => Promise; }type sendMessage = (from: User, to: User, message: Message) => Promise
注意:我這里同時使用了 type 和 interface,這是為了展示如何使用它們。你在項目中應(yīng)該主要使用其中一種。
連接類型
現(xiàn)在已經(jīng)有類型了,如何搭配 js 文件使用呢?
大體上有兩種方式:
Jsdoc typedef import
假設(shè)同一文件夾下有 user.d.ts,可以在 user.js 文件中加入以下注釋:
/*** @typedef {import('./user').User} User*//*** @type {User}*/const user = {id: 1234,firstname: 'Bruce',lastname: 'Wayne',status: 'online',};/*** @type {User[]}*/const users = [];// onlineUser 的類型會被自動推斷為 User[]const onlineUsers = users.filter((u) => u.status === 'online');console.log(onlineUsers.map((ou) => `${ou.firstname} ${ou.lastname} is ${ou.status}`));
確保 d.ts 文件中有相應(yīng)的 import 和 export 語句,這一方式才能正確工作。否則,最終會得到 any 類型,顯然 any 類型不會是你想要的。
三斜杠指令
在無法使用 import 的場景下,三斜杠指令是導(dǎo)入類型的經(jīng)典方式。
注意,你可能需要在 eslint 配置文件中加入以下內(nèi)容以免 eslint 把三斜杠指令視為錯誤:
{"rules": {"spaced-comment": ["error","always",{"line": {"markers": ["/"]}}]}}
假設(shè) message.js 和 message.d.ts 在同一文件夾下,可以在 message.js 文件中加入以下三斜杠指令:
///(僅當(dāng)使用 user 類型時才加這一行) ///
然后給 sendMessage 函數(shù)加上以下注釋:
/*** @type {sendMessage}*/function sendMessage(from, to, message)
接著你會發(fā)現(xiàn) sendMessage 有了正確的類型,IDE 能自動補全 from、to、message 和函數(shù)的返回類型。
或者你也可以這么寫:
/*** @param {User} from* @param {User} to* @param {Message} message* @returns {MessageResult}*/function sendMessage(from, to, message)
這是 jsDoc 書寫函數(shù)簽名的風(fēng)格,肯定沒有上一種寫法那么簡短。
使用三斜杠指令時,應(yīng)該在 d.ts 文件中移除 import 和 export 語句,否則無法工作。如果你需要從其他文件中引入類型,可以這么寫:
type sendMessage = (from: import("./models/user").User,to: import("./models/user").User,message: Message) => Promise;
這一差別背后的原因是 TypeScript 把不含 import 和 export 語句的 d.ts 文件視作環(huán)境(ambient)模塊聲明,包含 import 和 export 語句的則視為普通模塊文件,而不是全局聲明,所以無法用于三斜杠指令。
注意,在實際項目中,選擇以上兩種方式中的一種,不要混用。
自動生成 d.ts
如果項目的 JavaScript 代碼中已經(jīng)有大量 jsDoc 注釋,那么你有福了,只需以下一行命令就能自動生成類型聲明文件:
npx typescript src/**/*.js --declaration --allowJs --emitDeclarationOnly --outDir types
以上命令中,所有 js 文件在 src 文件夾下,輸出的 d.ts 文件位于 types 文件夾下。
babel 配置(可選)
如果項目使用 babel,那么需要在 babelrc 里加上:
{"exclude": ["**/*.d.ts"]}
否則 *.d.ts 文件會被編譯為 *.d.js 文件,這毫無意義。
現(xiàn)在你應(yīng)該就能享受到 TypeScript 的益處了(自動補全),無需額外配置 IDE,也不用修改 js 代碼的邏輯。
類型檢查
如果項目中 70% 以上的代碼都經(jīng)過以上步驟遷移后,你可以考慮開啟類型檢查,進(jìn)一步幫助檢測代碼中的小錯誤和問題。別擔(dān)心,你仍將繼續(xù)使用 JavaScript,也就是說不用改動構(gòu)建過程,也不用換庫。
開啟類型檢查的主要步驟是在項目中加上 jsconfig.json。例如:
{"compilerOptions": {"module": "commonjs","target": "es5","checkJs": true,"lib": ["es2015", "dom"]},"baseUrl": ".","include": ["src/**/*"],"exclude": ["node_modules"]}
關(guān)鍵在于 checkJs 需要為真,這就為所有項目開啟了類型檢查。
開啟后可能會碰到一大堆報錯,可以逐一修正。
漸進(jìn)類型檢查
// @ts-nocheck
如果你希望以后再修復(fù)一些文件的類型問題,可以在文件頭部加上 // @ts-nocheck,TypeScript 編譯器會忽略這些文件。
// @ts-ignore
如果只想忽略某行而不是整個文件的話,可以使用 // @ts-ignore。加上這個注釋后,類型檢查會忽略下一行。
使用這兩個標(biāo)記可以讓你慢慢修正類型檢查錯誤。
第三方庫
維護(hù)良好的庫
如果用的是流行的庫,那 DefinitelyTyped 上多半已經(jīng)有類型定義了,只需運行以下命令:
yarn add @types/your_lib_name --dev
或
npm i @types/your_lib_name --save-dev
注意:如果庫屬于某組織,庫名中包含 @ 和 /,那么在安裝相應(yīng)的類型定義文件時需要移除 @ 和 /,并在組織名后加上 __,例如 @babel/core 改為 babel__core。
純 JS 庫
如果用了一個作者 10 年前就已經(jīng)停止更新的 js 庫怎么辦?大多數(shù) npm 模塊仍然使用 JavaScript,沒有類型信息。添加 @ts-ignore 看起來不是一個好主意,因為你希望盡可能地確保類型安全。
那你就需要通過創(chuàng)建 d.ts 文件增補模塊定義,建議創(chuàng)建一個 types 文件夾,加入自己的類型定義。然后就可以享受類型安全檢查了。
declare module 'some-js-lib' {export const sendMessage: (from: number,to: number,message: string) => Promise; }
完成這些步驟后,類型檢查應(yīng)該能很好地工作,可以避免代碼出現(xiàn)很多小錯誤。
類型檢查升級
修復(fù) 95% 以上類型檢查錯誤并確保每個庫都有相應(yīng)的類型定義后,你可以進(jìn)行最后一步:正式把整個項目的代碼遷移到 TypeScript。
注意:我上一篇指南中提到的一些細(xì)節(jié)這里就不講了。
把所有文件改為 .ts 文件
現(xiàn)在是時候把 d.ts 文件和 js 文件合并了。由于幾乎所有的類型檢查錯誤都已修正,類型檢查已經(jīng)覆蓋所有模塊,基本上只需要把 require 改成 import 然后把代碼和類型定義都放到 ts 文件中。完成之前的工作后,這一步相當(dāng)簡單。
把 jsconfig 改為 tsconfig
現(xiàn)在我們需要的是 tsconfig.json 而不是 jsconfig.json。
tsconfig.json 的例子:
前端項目
{"compilerOptions": {"target": "es2015","allowJs": false,"esModuleInterop": true,"allowSyntheticDefaultImports": true,"noImplicitThis": true,"strict": true,"forceConsistentCasingInFileNames": true,"module": "esnext","moduleResolution": "node","resolveJsonModule": true,"isolatedModules": true,"noEmit": true,"jsx": "preserve","lib": ["es2020", "dom"],"skipLibCheck": true,"typeRoots": ["node_modules/@types", "src/types"],"baseUrl": ".",},"include": ["src"],"exclude": ["node_modules"]}
后端項目
{"compilerOptions": {"sourceMap": false,"esModuleInterop": true,"allowJs": false,"noImplicitAny": true,"skipLibCheck": true,"allowSyntheticDefaultImports": true,"preserveConstEnums": true,"strictNullChecks": true,"resolveJsonModule": true,"moduleResolution": "node","lib": ["es2018"],"module": "commonjs","target": "es2018","baseUrl": ".","paths": {"*": ["node_modules/*", "src/types/*"]},"typeRoots": ["node_modules/@types", "src/types"],"outDir": "./built",},"include": ["src/**/*"],"exclude": ["node_modules"]}
因為這樣修改后類型檢查會變得更嚴(yán)格,所以可能需要修復(fù)一些額外的類型錯誤。
修改 CI/CD 和構(gòu)建流程
改到 TypeScript 后需要在構(gòu)建流程中生成可運行的代碼,通常在 package.json 中加上這一行就行:
{"scripts":{"build": "tsc"}}
不過,前端項目通常用了 babel,你需要這樣設(shè)置項目:
{"scripts": {"build": "rimraf dist && tsc --emitDeclarationOnly && babel src --out-dir dist --extensions .ts,.tsx && copyfiles package.json LICENSE.md README.md ./dist"}}
別忘了改入口文件,比如:
{"main": "dist/index.js","module": "dist/index.js","types": "dist/index.d.ts",}
好了,萬事俱備。
注意,dist 需要改成你實際使用的目錄。
結(jié)語
恭喜,代碼現(xiàn)在遷移到了 TypeScript,有嚴(yán)格的類型檢查保證。現(xiàn)在可以享受 TypeScript 帶來的所有好處,比如自動補全、靜態(tài)類型、esnext 語法、對大型項目友好。開發(fā)體驗大大提升,維護(hù)成本大大降低。編寫項目代碼不再是痛苦的過程,再也不會碰到 Cannot read property 'x' of undefined 報錯。
替代方案:如果你希望一下子遷移整個項目到 TypeScript,可以參考 airbnb 團隊的指南。
本文完?
最后
歡迎加我微信(winty230),拉你進(jìn)技術(shù)群,長期交流學(xué)習(xí)...
歡迎關(guān)注「前端Q」,認(rèn)真學(xué)前端,做個專業(yè)的技術(shù)人...


