TypeScript 漸進遷移指南

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 通訊」

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章