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的型別檢查。
原則1:主流的瀏覽器的主流版本只認識js程式碼
原則2:ts的程式碼一定會經過編譯為js程式碼,才能執行在主流瀏覽器上
首先,想要編譯ts程式碼,至少具備以下3個要素:
上述過程為:ts編譯器讀取ts原始碼,並通過指定的編譯設定,將ts原始碼編譯為指定形式的js程式碼。
目前主流的ts編譯方案有2種,分別是:
接下來將詳細介紹上述兩種方案以及它們之間的差異。
官方編譯方案,按照TypeScript官方的指南,你需要使用tsc(TypeScript Compiler)完成,該tsc來源於你本地或是專案安裝的typescript包中。
按照上面的ts程式碼編譯3要素,我們可以完成一一對應:
讓我們通過一個simple-tsc-demo,實踐這一過程。
首先,建立一個名為simple-tsc-demo的空資料夾,並進行yarn init
(npm 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設定:
專案根目錄/src/index.ts
;當然,為了方便執行命令,我們在package.json中新增名為build
的指令碼:
{
...
+ "scripts": {
+ "build": "tsc"
+ },
...
}
完成搭建以後,專案整體如下:
執行build指令碼,能夠看到在專案根目錄產生dist/index.js
:
對於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模組規範的程式碼:
對於tsc編譯方案,按照TypeScript編譯三要素模型簡單總結一下:我們準備了ts原始碼、tsc編譯器以及tsconfig.json設定。通過tsc編譯器讀取tsconfig.json編譯設定,將ts原始碼編譯為了js程式碼。此外,在tsconfig.json中,我們設定了生成的js程式碼的兩種模組規範:"module": "commonjs"
與"module": "es6"
,並驗證了其結果符合對應的模組規範。
對於編譯器這部分來說,除了上面我們嘗試過的tsc編譯器,是否還存在其他的編譯器呢?答案是肯定的: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編譯三要素模型,來一一對應:
同樣的,讓我們通過一個simple-babel-demo,實踐這一過程。
首先,建立一個名為simple-babel-demo的空資料夾,並進行yarn init
(npm 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
)。
完成專案搭建以後,整體如下:
執行build指令碼,能夠看到在專案根目錄產生dist/index.js
:
這段程式碼,與上面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規範:
對於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編譯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編譯錯誤程式碼的時候,tsc型別檢查幫助我們找到了程式碼的錯誤點,符合我們的預期。
從結果來看,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需要我們設定noEmit
為true
,表明tsc讀取到了ts原始碼以後,不會生成任何的檔案,僅僅會進行型別檢查。
於是,在babel編譯方案中,整個體系如下:
不知道有沒有細心的讀者在使用IDEA的時候,會發現如果是IDE當前開啟的TS檔案,IDEA右下角會展示一個typescript:
VSCode同樣也會有:
在同一臺電腦上,甚至發現IDEA和VSCode的typescript版本都還不一樣(5.0.3和4.9.5)。這是怎麼一回事呢?實際上,IDE檢測到你所在的專案是一個ts專案的時候(或當前正在編輯ts檔案),就會自動的啟動一個ts的檢測服務,專門用於當前ts程式碼的型別檢測。這個ts型別檢測服務,同樣使用tsc來完成,但這個tsc來源於兩個途徑:
例如,上圖本人機器上的IDEA,因為檢測到了專案安裝了"typescript": "^5.0.3"
,所以自動切換為了專案安裝的TypeScript;而VSCode似乎沒有檢測到,所以使用VSCode自帶的。
當然,你也可以在IDE中手動切換:
最後,我們簡單梳理下IDE是如何在對應的程式碼位置展示程式碼的型別錯誤,流程如下:
但是,同樣是IDE中的ts型別檢查也要有一定的依據。譬如,外部庫的型別定義的檔案從哪裡查詢,是否允許較新的語法等,這些設定依然是由tsconfig.json來提供的,但若未提供,則IDE會使用一份預設的設定。如果要進行型別檢測的自定義設定,則需要提供tsconfig.json。
綜合前面的tsc編譯與babel編譯的過程,再整理上述的IDE對TS專案的型別檢查,我們可以分別總結出tsc編譯與babel編譯兩種場景的程式碼編譯流程和IDE型別檢查流程。
首先是tsc編譯方案:
在這套方案中,ts專案的程式碼本身的編譯,會走專案安裝的typescript,並載入專案本身的tsconfig.json設定。同時,IDE也會利用專案本身的typescript以及讀取相同設定的tsconfig.json來完成專案程式碼的型別檢查。
於是,無論是程式碼編譯還是IDE呈現的型別檢查,都是走的一套邏輯,當IDE提示了某些ts程式碼的編譯問題,那麼ts程式碼編譯一定會出現相同的問題。不會存在這樣的情況:程式碼有編譯問題,但是IDE不會紅色顯示型別檢查問題。
再來看babel編譯方案:
很顯然,babel編譯方案,程式碼編譯與IDE的型別檢查是兩條路線。也就是說,有可能你的IDE提示了錯誤,但是babel編譯是沒有問題。這也是很多小夥伴拿到基於babel編譯的TS專案容易出現IDE有程式碼異常問題的UI顯示,但是編譯程式碼有沒有問題的原因所在。
本文著重介紹了TypeScript程式碼的兩種編譯方案,以及IDE是如何進行TypeScript的型別檢查的。作為三部曲的第一部,內容比較多,比較細,感謝讀者的耐心閱讀。接下來的剩餘兩部分,將分別介紹webpack如何編譯打包基於TypeScript的專案以及TSX是如何進行型別檢查。