TypeScript 漸進遷移指南

Nathaniel 原做,翻譯轉載自 New Frontendhtml

我在大概一年前寫了一篇如何把 Node.js 項目從 JavaScript 遷移到 TypeScript 的指南。指南的閱讀量超過了七千,不過其實當時我對 JavaScript 和 TypeScript 的瞭解並不深刻,把重心更多地放到特定工具上,而沒怎麼從全局着手。最大的問題是我沒有提供遷移大型項目的解決方案。顯然,大型項目不可能在短期內重寫一切。所以,我很想分享下我最近學到的遷移項目到 TypeScript 的主要經驗。前端

遷移一個包含成千上百個文件的大型項目可能比你想象得要容易。整個過程主要分 3 步。node

注意:本文假定你已經有必定的 TypeScript 基礎,同時使用 Visual Studio Code,不然,一些地方可能不必定直接適用。git

相關代碼:https://github.com/llldar/mig...github

開始引入類型

花了 10 個小時使用 console.log 排查問題後,你終於修復了 Cannot read property 'x' of undefined 問題,出現這個問題的緣由是調用了可能爲 undefined 的某個方法,給了你一個「驚喜」!你暗暗發誓,必定要把整個項目遷移到 TypeScript。可是看了看 libutilcomponents 文件夾裏上萬個 JavaScript 文件,你對本身說:「等之後吧,等我有空的時候。」固然那一天永遠也不會到來,由於總有各類酷炫的新特性等着加到應用,客戶也不會由於項目是用 TypeScript 寫的就出大價錢。typescript

若是我告訴你,你能夠增量遷移到 TypeScript 並馬上從中受益呢?shell

添加神奇的 d.ts

d.ts 是 TypeScript 的類型聲明文件,其中聲明瞭代碼中用到的對象和函數的各類類型,不包含任何具體的實現。npm

假定你在寫一個即時通信應用,在 user.js 文件裏有一個 user 變量和一些數組:json

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>

注意:我這裏同時使用了 typeinterface,這是爲了展現如何使用它們。你在項目中應該主要使用其中一種。

鏈接類型

如今已經有類型了,如何搭配 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 文件中有相應的 importexport 語句,這一方式才能正確工做。不然,最終會獲得 any 類型,顯然 any 類型不會是你想要的。

三斜槓指令

在沒法使用 import 的場景下,三斜槓指令是導入類型的經典方式。

注意,你可能須要在 eslint 配置文件中加入如下內容以避免 eslint 把三斜槓指令視爲錯誤:

{
  "rules": {
    "spaced-comment": [
      "error",
      "always",
      {
        "line": {
          "markers": ["/"]
        }
      }
    ]
  }
}

假設 message.jsmessage.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 能自動補全 fromtomessage 和函數的返回類型。

或者你也能夠這麼寫:

/**
* @param {User} from
* @param {User} to
* @param {Message} message
* @returns {MessageResult}
*/
function sendMessage(from, to, message)

這是 jsDoc 書寫函數簽名的風格,確定沒有上一種寫法那麼簡短。

使用三斜槓指令時,應該在 d.ts 文件中移除 importexport 語句,不然沒法工做。若是你須要從其餘文件中引入類型,能夠這麼寫:

type sendMessage = (
  from: import("./models/user").User,
  to: import("./models/user").User,
  message: Message
) => Promise<MessageResult>;

這一差異背後的緣由是 TypeScript 把不含 importexport 語句的 d.ts 文件視做環境(ambient)模塊聲明,包含 importexport 語句的則視爲普通模塊文件,而不是全局聲明,因此沒法用於三斜槓指令。

注意,在實際項目中,選擇以上兩種方式中的一種,不要混用。

自動生成 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。

注意:我上一篇指南中提到的一些細節這裏就不講了。

把全部文件改成 .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 團隊的指南

相關文章
相關標籤/搜索