Nathaniel 原作 授權
New Frontend 翻譯
我在大概一年前寫了一篇如何把 Node.js 項目從 JavaScript 遷移到 TypeScript [1]的指南。指南的閱讀量超過了七千,不過其實當時我對 JavaScript 和 TypeScript 的瞭解並不深入,把重心更多地放到特定工具上,而沒怎麼從全局着手。最大的問題是我沒有提供遷移大型項目的解決方案。顯然,大型項目不可能在短時間內重寫一切。因此,我很想分享下我最近學到的遷移項目到 TypeScript 的主要經驗。
遷移一個包含成千上百個文件的大型項目可能比你想象得要容易。整個過程主要分 3 步。
注意:本文假定你已經有一定的 TypeScript 基礎,同時使用 Visual Studio Code,否則,一些地方可能不一定直接適用。
相關代碼:https://github.com/llldar/migrate-to-typescript-the-advance-guide [2]
開始引入類型
花了 10 個小時使用 console.log 排查問題後,你終於修復了Cannot read property 'x' of undefined
問題,出現這個問題的原因是調用了可能爲 undefined 的某個方法,給了你一個「驚喜」!你暗暗發誓,一定要把整個項目遷移到 TypeScript。但是看了看 lib、util、components 文件夾裏上萬個 JavaScript 文件,你對自己說:「等以後吧,等我有空的時候。」當然那一天永遠也不會到來,因爲總有各種酷炫的新特性等着加到應用,客戶也不會因爲項目是用 TypeScript 寫的就出大價錢。
如果我告訴你,你可以增量遷移到 TypeScript 並立刻從中受益呢?
添加神奇的 d.ts
d.ts
是 TypeScript 的類型聲明 [3] 文件,其中聲明瞭代碼中用到的對象和函數的各種類型,不包含任何具體的實現。
假定你在寫一個即時通訊應用,在 user.js 文件裏有一個 user 變量和一些數組:
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}`)
);
那麼對應的 user.d.ts 會是:
export interface User {
id: number;
firstname: string;
lastname: string;
status: 'online' | 'offline';
}
然後 message.js 裏定義了一個函數 sendMessage:
function sendMessage(from, to, message)
那麼 message.d.ts 中相應的類型會是:
type sendMessage = (from: string, to: string, message: string) => boolean
不過, sendMessage 也許沒那麼簡單,參數的類型可能更復雜,也可能是一個異步函數。
你可以使用 import 引入其他文件中定義的複雜類型,保持類型文件簡單明瞭,避免重複。
import { User } from './models/user';
type Message = {
content: string;
createAt: Date;
likes: number;
}
interface MessageResult {
ok: boolean;
statusCode: number;
json: () => Promise<any>;
text: () => Promise<string>;
}
type sendMessage = (from: User, to: User, message: Message) => Promise<MessageResult>
注意:我這裏同時使用了 type 和 interface,這是爲了展示如何使用它們。你在項目中應該主要使用其中一種。
連接類型
現在已經有類型了,如何搭配 js 文件使用呢?
大體上有兩種方式:
Jsdoc typedef import
假設同一文件夾下有 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 文件中有相應的 import 和 export 語句,這一方式才能正確工作。否則,最終會得到 any 類型,顯然 any 類型不會是你想要的。
三斜槓指令
在無法使用 import 的場景下,三斜槓指令是導入類型的經典方式。
注意,你可能需要在 eslint 配置文件中加入以下內容以免 eslint 把三斜槓指令視爲錯誤:
{
"rules": {
"spaced-comment": [
"error",
"always",
{
"line": {
"markers": ["/"]
}
}
]
}
}
假設 message.js 和 message.d.ts 在同一文件夾下,可以在 message.js 文件中加入以下三斜槓指令:
/// <reference path="./models/user.d.ts" /> (僅當使用 user 類型時才加這一行)
/// <reference path="./message.d.ts" />
然後給 sendMessage 函數加上以下注釋:
/**
* @type {sendMessage}
*/
function sendMessage(from, to, message)
接着你會發現 sendMessage 有了正確的類型,IDE 能自動補全 from、to、message
和函數的返回類型。
或者你也可以這麼寫:
/**
* @param {User} from
* @param {User} to
* @param {Message} message
* @returns {MessageResult}
*/
function sendMessage(from, to, message)
這是 jsDoc 書寫函數簽名的風格,肯定沒有上一種寫法那麼簡短。
使用三斜槓指令時,應該在 d.ts 文件中移除 import 和 export 語句,否則無法工作。如果你需要從其他文件中引入類型,可以這麼寫:
type sendMessage = (
from: import("./models/user").User,
to: import("./models/user").User,
message: Message
) => Promise<MessageResult>;
這一差別背後的原因是 TypeScript 把不含 import 和 export 語句的 d.ts 文件視作環境(ambient)模塊聲明,包含 import 和 export 語句的則視爲普通模塊文件,而不是全局聲明,所以無法用於三斜槓指令。
注意,在實際項目中,選擇以上兩種方式中的一種,不要混用。
自動生成 d.ts
如果項目的 JavaScript 代碼中已經有大量 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 文件,這毫無意義。
現在你應該就能享受到 TypeScript 的益處了(自動補全),無需額外配置 IDE,也不用修改 js 代碼的邏輯。
類型檢查
如果項目中 70% 以上的代碼都經過以上步驟遷移後,你可以考慮開啓類型檢查,進一步幫助檢測代碼中的小錯誤和問題。別擔心,你仍將繼續使用 JavaScript,也就是說不用改動構建過程,也不用換庫。
開啓類型檢查的主要步驟是在項目中加上 jsconfig.json。例如:
{
"compilerOptions": {
"module": "commonjs",
"target": "es5",
"checkJs": true,
"lib": ["es2015", "dom"]
},
"baseUrl": ".",
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
關鍵在於 checkJs 需要爲真,這就爲所有項目開啓了類型檢查。
開啓後可能會碰到一大堆報錯,可以逐一修正。
漸進類型檢查
// @ts-nocheck
如果你希望以後再修復一些文件的類型問題,可以在文件頭部加上 //@ts-nocheck
,TypeScript 編譯器會忽略這些文件。
// @ts-ignore
如果只想忽略某行而不是整個文件的話,可以使用 // @ts-ignore。加上這個註釋後,類型檢查會忽略下一行。
使用這兩個標記可以讓你慢慢修正類型檢查錯誤。
第三方庫
維護良好的庫
如果用的是流行的庫,那 DefinitelyTyped 上多半已經有類型定義了,只需運行以下命令:
yarn add @types/your_lib_name --dev
或
npm i @types/your_lib_name --save-dev
注意:如果庫屬於某組織,庫名中包含@
和 / ,那麼在安裝相應的類型定義文件時需要移除 @
和 / ,並在組織名後加上 __,例如 @babel/core 改爲 babel__core。
純 JS 庫
如果用了一個作者 10 年前就已經停止更新的 js 庫怎麼辦?大多數 npm 模塊仍然使用 JavaScript,沒有類型信息。添加 @ts-ignore 看起來不是一個好主意,因爲你希望儘可能地確保類型安全。
那你就需要通過創建 d.ts 文件增補模塊定義,建議創建一個 types 文件夾,加入自己的類型定義。然後就可以享受類型安全檢查了。
declare module 'some-js-lib' {
export const sendMessage: (
from: number,
to: number,
message: string
) => Promise<MessageResult>;
}
完成這些步驟後,類型檢查應該能很好地工作,可以避免代碼出現很多小錯誤。
類型檢查升級
修復 95% 以上類型檢查錯誤並確保每個庫都有相應的類型定義後,你可以進行最後一步:正式把整個項目的代碼遷移到 TypeScript。
注意:我上一篇指南 [4] 中提到的一些細節這裏就不講了。
把所有文件改爲 .ts 文件
現在是時候把 d.ts 文件和 js 文件合併了。由於幾乎所有的類型檢查錯誤都已修正,類型檢查已經覆蓋所有模塊,基本上只需要把 require 改成 import 然後把代碼和類型定義都放到 ts 文件中。完成之前的工作後,這一步相當簡單。
把 jsconfig 改爲 tsconfig
現在我們需要的是 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"]
}
因爲這樣修改後類型檢查會變得更嚴格,所以可能需要修復一些額外的類型錯誤。
修改 CI/CD 和構建流程
改到 TypeScript 後需要在構建流程中生成可運行的代碼,通常在package.json
中加上這一行就行:
{
"scripts":{
"build": "tsc"
}
}
不過,前端項目通常用了 babel,你需要這樣設置項目:
{
"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 需要改成你實際使用的目錄。
結語
恭喜,代碼現在遷移到了 TypeScript,有嚴格的類型檢查保證。現在可以享受 TypeScript 帶來的所有好處,比如自動補全、靜態類型、esnext 語法、對大型項目友好。開發體驗大大提升,維護成本大大降低。編寫項目代碼不再是痛苦的過程,再也不會碰到 Cannot read property 'x' of undefined 報錯。
替代方案:
如果你希望一下子遷移整個項目到 TypeScript,可以參考 airbnb 團隊的指南 [5]。
[1] 如何把 Node.js 項目從 JavaScript 遷移到 TypeScript:
https://url.leanapp.cn/VgSFjSZ
[2] 相關代碼:https://url.leanapp.cn/BQcq21j
[3] 類型聲明:https://url.leanapp.cn/WJhBZKY
[4] 上一篇指南:https://url.leanapp.cn/VgSFjSZ
[5] airbnb 團隊的指南:
https://url.leanapp.cn/ZaDpPM7
end
LeanCloud,領先的 BaaS 提供商,爲移動開發提供強有力的後端支持。更多內容請關注「LeanCloud 通訊」