TypeScript必知三部曲(一)TypeScript編譯方案以及IDE對TS的型別檢查

w4ngzhen發表於2023-04-08

TypeScript程式碼的編譯過程一直以來會給很多小夥伴造成困擾,typescript官方提供tsc對ts程式碼進行編譯,babel也表示能夠編譯ts程式碼,它們二者的區別是什麼?我們應該選擇哪種方案?為什麼IDE開啟ts專案的時候,就能有這些ts程式碼的型別定義?為什麼明明IDE對程式碼標紅報錯,但程式碼有能夠編譯出來?

帶著這些問題,我們由淺入深介紹TypeScript程式碼編譯的兩種方案以及我們日常使用IDE進行ts檔案型別檢查的關係,讓你今後面對基於ts的工程能夠做到遊刃有餘。

寫在前面

其實這篇文章並非是全新的文章,早在22年的8月份,我就寫了一篇名為《TypeScript與Babel、webpack的關係以及IDE對TS的型別檢查》的文章,裡面的內容就包含了本文的內容,但迫於當時編寫的匆忙,整個文章的結構安排的不好,脈絡不清晰,東一塊西一塊想到哪裡寫到哪裡,同時還想把webpack相關的也介紹了,所以最終內容比較多比較亂。有強迫症的我一直以來對當時的文章都不是很滿意。

恰好剛好最近又在寫有關TSX(基於TypeScript程式碼的JSX程式碼)的型別檢查相關的介紹,故重新將當時的文章翻了出來,重新編排整理了內容,增加了更多的示意圖,移除了有關webpack的部分,著重介紹現階段TypeScript程式碼的編譯方案,讓文章內容更加聚焦。而在三部曲的第二部分,則會著重介紹本文移除了的對於webpack工程如何編譯TypeScript專案的內容(考慮到該部分內容需要有本文的基礎,故放在了第二部分)。在最後一部分將會介紹TSX的型別檢查。

TypeScript基本原則

原則1:主流的瀏覽器的主流版本只認識js程式碼

原則2:ts的程式碼一定會經過編譯為js程式碼,才能執行在主流瀏覽器上

TypeScript編譯方式

首先,想要編譯ts程式碼,至少具備以下3個要素:

  1. ts原始碼
  2. ts編譯器
  3. ts編譯配置

010-ts-code-compile-flow

上述過程為:ts編譯器讀取ts原始碼,並透過指定的編譯配置,將ts原始碼編譯為指定形式的js程式碼。

目前主流的ts編譯方案有2種,分別是:

  1. tsc編譯
  2. babel編譯

接下來將詳細介紹上述兩種方案以及它們之間的差異。

tsc編譯

官方編譯方案,按照TypeScript官方的指南,你需要使用tsc(TypeScript Compiler)完成,該tsc來源於你本地或是專案安裝的typescript包中。

按照上面的ts程式碼編譯3要素,我們可以完成一一對應:

  1. ts原始碼
  2. ts編譯器:tsc
  3. ts編譯配置:tsconfig.json

020-ts-code-compile-by-tsc

讓我們透過一個simple-tsc-demo,實踐這一過程。

首先,建立一個名為simple-tsc-demo的空資料夾,並進行yarn initnpm init亦可)。然後,我們按照上述的三要素模型,準備:

(1)ts原始碼:編寫專案根目錄/src/index.ts

interface User {
    id: string;
    name: string;
}

export const userToString = (u: User) => `${u.id}/${u.name}`

(2)編譯器tsc:安裝typescript獲得

yarn add typescript

(3)編譯配置tsconfig.json:專案根目錄/tsconfig.json

{
  "compilerOptions": {
    "module": "commonjs",
    "rootDir": "./src",
    "outDir": "./dist"
  }
}

簡單介紹上述tsconfig.json配置:

  1. module:指定ts程式碼編譯生成何種模組方案的js程式碼,這裡暫時寫的commonjs,後續會介紹其它值的差異;
  2. rootDir:指定ts程式碼存放的根目錄,這裡就是當前目錄(專案根目錄)下的src資料夾,能夠匹配到我們編寫的專案根目錄/src/index.ts
  3. outDir:指定ts程式碼經過編譯後,生成的js程式碼的存放目錄。

當然,為了方便執行命令,我們在package.json中新增名為build的指令碼:

{
  ... 
+ "scripts": {
+  "build": "tsc"
+ },
  ...
}

完成搭建以後,專案整體如下:

030-simple-tsc-example-full

執行build指令碼,能夠看到在專案根目錄產生dist/index.js

040-simple-tsc-compile-result-commonjs

對於index.js的內容,熟悉js模組化規範的小夥伴應該很容易看出這是commonjs的規範:給exports物件上新增屬性欄位,exports物件會作為模組匯出,被其他模組使用。

之所以產生的js程式碼是符合commonjs模組規範的程式碼,源於我們在tsconfig.json中配置的module值為commonjs。倘若我們將module欄位改為es6

{
  "compilerOptions": {
- 	"module": "commonjs",
+   "module": "es6",
    "rootDir": "./src",
    "outDir": "./dist"
  }
}

再一次編譯以後,會看到編譯後的js程式碼則是符合es6模組規範的程式碼:

050-simple-tsc-compile-result-es6

對於tsc編譯方案,按照TypeScript編譯三要素模型簡單總結一下:我們準備了ts原始碼、tsc編譯器以及tsconfig.json配置。透過tsc編譯器讀取tsconfig.json編譯配置,將ts原始碼編譯為了js程式碼。此外,在tsconfig.json中,我們配置了生成的js程式碼的兩種模組規範:"module": "commonjs""module": "es6",並驗證了其結果符合對應的模組規範。

對於編譯器這部分來說,除了上面我們嘗試過的tsc編譯器,是否還存在其他的編譯器呢?答案是肯定的:babel。

babel編譯

本文並不是一篇專門講babel的文章,但是為了讓相關知識能夠比較好的銜接,還是需要介紹這塊內容的。當然如果讀者有時間,我推薦這篇深入瞭解babel的文章:一口(很長的)氣了解 babel - 知乎 (zhihu.com)

babel 總共分為三個階段:解析,轉換,生成。

babel 本身不具有任何轉化功能,它把轉化的功能都分解到一個個 plugin 裡面。因此當我們不配置任何外掛時,經過 babel 的程式碼和輸入是相同的。

外掛總共分為兩種:

  • 當我們新增 語法外掛 之後,在解析這一步就使得 babel 能夠解析更多的語法。(順帶一提,babel 內部使用的解析類庫叫做 babylon,並非 babel 自行開發)

舉個簡單的例子,當我們定義或者呼叫方法時,最後一個引數之後是不允許增加逗號的,如 callFoo(param1, param2,) 就是非法的。如果原始碼是這種寫法,經過 babel 之後就會提示語法錯誤。但最近的 JS 提案中已經允許了這種新的寫法(讓程式碼 diff 更加清晰)。為了避免 babel 報錯,就需要增加語法外掛 babel-plugin-syntax-trailing-function-commas

  • 當我們新增 轉譯外掛 之後,在轉換這一步把原始碼轉換並輸出。這也是我們使用 babel 最本質的需求。

比起語法外掛,轉譯外掛其實更好理解,比如箭頭函式 (a) => a 就會轉化為 function (a) {return a}。完成這個工作的外掛叫做 babel-plugin-transform-es2015-arrow-functions

同一類語法可能同時存在語法外掛版本和轉譯外掛版本。如果我們使用了轉譯外掛,就不用再使用語法外掛了。

總結來說,babel轉換程式碼就像如下流程:

原始碼 -(babel)-> 目的碼

如果沒有使用任何外掛,原始碼和目的碼就沒有任何差異。當我們引入各種外掛的時候,就像如下流程一樣:

原始碼
|
進入babel
|
babel外掛1處理程式碼,例如移除某些符號
|
babel外掛2處理程式碼,例如將形如() => {}的箭頭函式,轉換成function xxx() {}
|
目的碼

babel提倡一個外掛專注做一個事情,比如某個外掛只進行箭頭函式轉換工作,某個外掛只處理將const轉var程式碼,這樣設計的好處是可以靈活的組合各種外掛完成程式碼轉換。

但又因為babel的外掛處理的力度很細,JS程式碼的語法規範有很多,為了處理這些語法,可能需要配置一大堆的外掛。為了解決這個問題,babel設計preset(預置集)概念,preset組合了一堆外掛。於是,我們只需要引入一個外掛組合包preset,就能處理程式碼的各種語法。

PS:官方收編的外掛包通常以 “@babel/plugin-” 開頭的,而預置集包通常以 “@babel/preset-” 開頭。

回到TypeScript編譯,對於babel編譯TS的體系,我們同樣按照TypeScript編譯三要素模型,來一一對應:

  1. ts原始碼
  2. ts編譯器:babel+相關preset、plugin
  3. ts編譯配置:.babelrc

060-ts-code-compile-by-babel

同樣的,讓我們透過一個simple-babel-demo,實踐這一過程。

首先,建立一個名為simple-babel-demo的空資料夾,並進行yarn initnpm init亦可)。然後,我們按照上述的三要素模型,準備:

(1)原始碼:編寫專案根目錄/src/index.ts

interface User {
    id: string;
    name: string;
}

export const userToString = (u: User) => `${u.id}/${u.name}`

(2)ts編譯器babel+相關preset、plugin:專案安裝如下依賴包

yarn add -D @babel/cli @babel/core
yarn add -D @babel/preset-env @babel/preset-typescript
yarn add -D @babel/plugin-proposal-object-rest-spread

讀者看到需要安裝這麼多的依賴包不要感到恐懼,讓我們一個一個分析:

  • @babel/core:babel的核心模組,控制了整體程式碼編譯的運轉以及程式碼語法、語義分析的功能;

  • @babel/cli:支援我們可以在控制檯使用babel命令;

  • @babel/preset-開頭的就是預置元件包合集,其中@babel/preset-env表示使用了可以根據實際的瀏覽器執行環境,會選擇相關的轉義外掛包,透過配置得知目標環境的特點只做必要的轉換。如果不寫任何配置項,env 等價於 latest,也等價於 es2015 + es2016 + es2017 三個相加(不包含 stage-x 中的外掛);@babel/preset-typescript會處理所有ts的程式碼的語法和語義規則,並轉換為js程式碼。

  • plugin開頭的就是外掛,這裡我們引入:@babel/plugin-proposal-object-rest-spread物件展開),它會處理我們在程式碼中使用的...運算子轉換為普通的js呼叫。

介紹完以後,是不是有了一些清晰的認識了呢。讓我們繼續三要素的最後一個:編譯配置。

(3)編譯配置.babelrc專案根目錄/.babelrc檔案

{
  "presets": [
    "@babel/preset-env",
    "@babel/preset-typescript"
  ],
  "plugins": [
    "@babel/plugin-proposal-object-rest-spread"
  ]
}

上面的配置並不複雜,對應了我們安裝依賴包中關於preset與plugin的部分。這部分配置,也是在告訴babel,處理程式碼的時候,需要載入哪些preset、plugin好讓它們處理程式碼。

最後,我們在package.json新增編譯指令碼:

{
	...
+ "scripts": {
+ 	"build": "babel src --config-file ./.babelrc -x .ts -d dist"
+ },
	...
}

編譯指令指定了babel要讀取的原始碼所在目錄(src)、babel配置檔案地址(--config-file ./.babelrc)、babel需要處理的檔案擴充套件(-x .ts)、編譯程式碼生成目錄(-d dist)。

完成專案搭建以後,整體如下:

070-simple-babel-example-full

執行build指令碼,能夠看到在專案根目錄產生dist/index.js

080-simple-babel-compile-result-commonjs

這段程式碼,與上面tsc基於commonjs編譯的js程式碼差別不大。也就是說,babel基於@babel/preset-env+@babel/preset-typescript就能將TS程式碼編譯為commonjs程式碼。那麼我們如何使用babel將ts程式碼編譯器es6的程式碼呢?從babel配置下手,實際上,我們只需要將babelrc的@babel/preset-env移除即可:

{
  "presets": [
-  	"@babel/preset-env",
    "@babel/preset-typescript"
  ],
  "plugins": [
    "@babel/plugin-proposal-object-rest-spread"
  ]
}

再次編譯後,可以看到生成的index.js符合es6規範:

090-simple-babel-compile-result-es6

對於babel編譯,同樣簡單總結一下,對應TypeScript編譯三要素模型,我們準備了ts原始碼、babel與相關preset和plugin作為編譯器,以及babelrc作為編譯配置。babel處理程式碼的流程啟動以後,根據編譯配置知道需要載入哪些plugin、preset,將程式碼以及相關資訊交給plugin、preset處理,最終編譯為js程式碼。此外,在babelrc中,我們透過是否配置@babel/preset-env控制生成滿足commonjs或es6模組規範的js程式碼。

編譯總結

不難看出,ts無論有多麼龐大的語法體系,多麼強大的型別檢查,最終的產物都是js

此外還要注意的一點是,ts中的模組化不能和js中的模組化混為一談。js中的模組化方案很多(es6、commonjs、umd等等),所以ts本身在編譯過程中,需要指定一種js的模組化表達,才能編譯為對應的程式碼。在ts中的import/export,不能認為和es6的import/export是一樣的,他們是完全不同的兩個體系!只是語法上相似而已。

tsc編譯與babel編譯的差異

前面,我們介紹了tsc編譯與babel編譯TS程式碼,那他們二者有什麼差異呢?讓我們先來看這樣一個場景:下面是一段ts原始碼:

interface User {
    id: string;
    name: string;
}

export const userToString = (u: User) => `${u.id}/${u.name}`

我們故意將u.name錯寫為u.myName

- export const userToString = (u: User) => `${u.id}/${u.name}`
+ export const userToString = (u: User) => `${u.id}/${u.myName}`

預期上講,型別檢查肯定不透過,因為User介面根本沒有name欄位。讓我們分別在tsc編譯和babel編譯中看一下編譯的結果是否滿足我們的預期。

tsc編譯錯誤程式碼

100-tsc-compile-error-code

可以從結果很清楚的看到,使用tsc編譯錯誤程式碼的時候,tsc型別檢查幫助我們找到了程式碼的錯誤點,符合我們的預期。

babel編譯錯誤程式碼

110-babel-compile-error-code

從結果來看,babel編譯居然可以直接成功!檢視生成的index.js程式碼:

export const userToString = u => `${u.id}/${u.myName}`;

從js程式碼角度來看,這段程式碼沒有任何的問題,此時的u引數變數在js層面,並沒有明確的型別定義,js作為動態語言,執行的時候,myName也可能就存在,這誰也無法確定。

為什麼babel編譯會這樣處理程式碼?這不得不提到babel中的@babel/preset-typescript是如何編譯TS程式碼的:

警告!有一個震驚的訊息,你可能想坐下來好好聽下。

Babel 如何處理 TypeScript 程式碼?它刪除它

是的,它刪除了所有 TypeScript,將其轉換為“常規的” JavaScript,並繼續以它自己的方式愉快處理。

這聽起來很荒謬,但這種方法有兩個很大的優勢。

第一個優勢:️⚡️閃電般快速⚡️。

大多數 Typescript 開發人員在開發/監視模式下經歷過編譯時間長的問題。你正在編寫程式碼,儲存一個檔案,然後...它來了...再然後...最後,你看到了你的變更。哎呀,錯了一個字,修復,儲存,然後...啊。它只是慢得令人煩惱並打消你的勢頭。

很難去指責 TypeScript 編譯器,它在做很多工作。它在掃描那些包括 node_modules 在內的型別定義檔案(*.d.ts),並確保你的程式碼正確使用。這就是為什麼許多人將 Typescript 型別檢查分到一個單獨的程式。然而,Babel + TypeScript 組合仍然提供更快的編譯,這要歸功於 Babel 的高階快取和單檔案發射架構。

具體的內容小夥伴可以檢視: TypeScript 和 Babel:美麗的結合 - 知乎 (zhihu.com)

也就是說,babel處理TypeScript程式碼的時候,並不進行任何的型別檢查!那小夥伴可能會說,那如果我使用babel編譯方案,怎麼進行型別檢查以確保ts程式碼的正確性呢?答案則是:引入tsc,但僅僅進行型別檢查

回到我們之前的simple-babel-example。在之前的基礎上,我們依舊安裝typescript從而獲得tsc:

{
	...
	"devDependencies": {
    "@babel/cli": "^7.21.0",
    "@babel/core": "^7.21.4",
    "@babel/plugin-proposal-object-rest-spread": "^7.20.7",
    "@babel/preset-env": "^7.21.4",
    "@babel/preset-typescript": "^7.21.4",
+   "typescript": "^5.0.4"
  }
}

然後,在專案中新增tsconfig.json檔案,配置如下

{
  "compilerOptions": {
    "noEmit": true,
    "rootDir": "src"
  }
}

比起tsc編譯方案裡面的配置有所不同,在babel編譯方案中的型別檢查的tsconfig.json需要我們配置noEmittrue,表明tsc讀取到了ts原始碼以後,不會生成任何的檔案,僅僅會進行型別檢查

於是,在babel編譯方案中,整個體系如下:

120-babel-compile-flow-with-type-check

主流IDE對TS專案如何進行型別檢查

不知道有沒有細心的讀者在使用IDEA的時候,會發現如果是IDE當前開啟的TS檔案,IDEA右下角會展示一個typescript:

130-idea-typescript-service

VSCode同樣也會有:

140-vscode-typescript-service

在同一臺電腦上,甚至發現IDEA和VSCode的typescript版本都還不一樣(5.0.3和4.9.5)。這是怎麼一回事呢?實際上,IDE檢測到你所在的專案是一個ts專案的時候(或當前正在編輯ts檔案),就會自動的啟動一個ts的檢測服務,專門用於當前ts程式碼的型別檢測。這個ts型別檢測服務,同樣使用tsc來完成,但這個tsc來源於兩個途徑:

  1. 每個IDE預設情況下自帶的typescript中的tsc
  2. 當前專案安裝的typescript的tsc

例如,上圖本人機器上的IDEA,因為檢測到了專案安裝了"typescript": "^5.0.3",所以自動切換為了專案安裝的TypeScript;而VSCode似乎沒有檢測到,所以使用VSCode自帶的。

當然,你也可以在IDE中手動切換:

150-IDEA-and-VSCode-switch-typescript

最後,我們簡單梳理下IDE是如何在對應的程式碼位置展示程式碼的型別錯誤,流程如下:

160-IDE-ts-check-flow

但是,同樣是IDE中的ts型別檢查也要有一定的依據。譬如,外部庫的型別定義的檔案從哪裡查詢,是否允許較新的語法等,這些配置依然是由tsconfig.json來提供的,但若未提供,則IDE會使用一份預設的配置。如果要進行型別檢測的自定義配置,則需要提供tsconfig.json。

編譯方案與IDE型別檢查整合

綜合前面的tsc編譯與babel編譯的過程,再整理上述的IDE對TS專案的型別檢查,我們可以分別總結出tsc編譯與babel編譯兩種場景的程式碼編譯流程和IDE型別檢查流程。

首先是tsc編譯方案:

170-tsc-compile-and-type-check

在這套方案中,ts專案的程式碼本身的編譯,會走專案安裝的typescript,並載入專案本身的tsconfig.json配置。同時,IDE也會利用專案本身的typescript以及讀取相同配置的tsconfig.json來完成專案程式碼的型別檢查。

於是,無論是程式碼編譯還是IDE呈現的型別檢查,都是走的一套邏輯,當IDE提示了某些ts程式碼的編譯問題,那麼ts程式碼編譯一定會出現相同的問題。不會存在這樣的情況:程式碼有編譯問題,但是IDE不會紅色顯示型別檢查問題。

再來看babel編譯方案:

180-babel-compile-and-type-check

很顯然,babel編譯方案,程式碼編譯與IDE的型別檢查是兩條路線。也就是說,有可能你的IDE提示了錯誤,但是babel編譯是沒有問題。這也是很多小夥伴拿到基於babel編譯的TS專案容易出現IDE有程式碼異常問題的UI顯示,但是編譯程式碼有沒有問題的原因所在。

寫在最後

本文著重介紹了TypeScript程式碼的兩種編譯方案,以及IDE是如何進行TypeScript的型別檢查的。作為三部曲的第一部,內容比較多,比較細,感謝讀者的耐心閱讀。接下來的剩餘兩部分,將分別介紹webpack如何編譯打包基於TypeScript的專案以及TSX是如何進行型別檢查。

相關文章