TypeScript 是一門基於JavaScript 拓展的語言,它是JavaScript 的超集,並且給JavaScript 添加了靜態類型檢查系統。 TypeScript 能讓我們在開發時發現程序中類型定義不一致的地方,及時消除隱藏的風險,大大增強了代碼的可讀性以及可維護性。相信大家對於如何在項目中使用TypeScript 已經輕車熟路,本文就來探討簡單探討一下TypeScript 是如何工作的,以及有哪些工具幫助它實現了這個目標。
一、TypeScript 工作原理
![]()![]()peScript 的大致工作原理如上圖所示:
- TypeScript 源碼經過掃描器掃描之後變成一系列Token;
- 解析器解析token,得到一棵AST 語法樹;
- 綁定器遍歷AST 語法樹,生成一系列Symbol,並將這些Symbol 連接到對應的節點上;
- 檢查器再次掃描AST,檢查類型,並將錯誤收集起來;
- 發射器根據AST 生成JavaScript 代碼。
可見,AST 是整個類型驗證的核心。如對於下面的代碼
var a = 1; ```text
var a = 1; function func(p: number): number { return p * p; } a = 's' export { func }
`` function func(p: number): number {
``text
var a = 1; function func(p: number): number { return p * p; } a = 's' export { func }
`` return p * p;
``text
var a = 1; function func(p: number): number { return p * p; } a = 's' export { func }
`` }
``text
var a = 1; function func(p: number): number { return p * p; } a = 's' export { func }
`` a = 's'
``text
var a = 1; function func(p: number): number { return p * p; } a = 's' export { func }
`` export {
``text
var a = 1; function func(p: number): number { return p * p; } a = 's' export { func }
`` func
``text
var a = 1; function func(p: number): number { return p * p; } a = 's' export { func }
```
生成AST 的結構為
AST 中的節點稱為Node,Node 中記錄了這個節點的類型、在源碼中的位置等訊息。不同類型的Node 會記錄不同的訊息。如對於FunctionDeclaration 類型的Node,會記錄name(函數名)、parameters(參數)、body(函數體)等訊息,而對於VariableDeclaration 類型的Node,會記錄name(變量名)、initializer(初始化)等訊息。一個源文件也是一個Node —— SourceFile,它是AST 的根節點。
關於如何從源碼生成AST,以及從AST 生成最終代碼,相關理論很多,本文也不再贅述。本節主要說明一下綁定器的作用和檢查器如何檢查類型。
簡而言之,綁定器的終極目標是協助檢查器進行類型檢查,它遍歷AST,給每個Node 生成一個Symbol,並將源碼中有關聯的部分(在AST 節點的層面)關聯起來。這句話可能不是很直觀,下面來說明一下。
Symbol 是語義系統的基本構造塊,它有兩個基本屬性:members 和exports。 members 記錄了類、接口或字面量實例成員,exports 記錄了模塊導出的對象。 Symbols 是一個對象的標識,或者說是一個對像對外的身份特徵。如對於一個類實例對象,我們在使用這個對象時,只關心這個對象提供了哪些變量/方法;對於一個模塊,我們在使用這個模塊時,只關心這個模塊導出了哪些對象。通過讀取Symbol,我們就可以獲取這些訊息。
然後再看看綁定器如何將源碼中有關聯的部分(在AST 節點的層面)關聯起來。這需要再了解兩個屬性:Node 的locals 屬性以及Symbol 的declarations 屬性。對於容器類型的Node,會有一個locals 屬性,其中記錄了在這個節點中聲明的變量/類/類型/函數等。如對於上面代碼中的func 函數,對應FunctionDeclaration 節點中的locals 中有一個屬性p。而對於SourceFile 節點,則含有a 和func 兩個屬性。
Symbol 的declarations 屬性記錄了這個Symbol 對應的變量的聲明節點。如對於上文代碼中第1 行和第7 行中的a 變量,各自創建了一個Symbol,但是這兩個Symbol 的declarations 的內容是一致的,都是第一行代碼var a = 1;所對應的VariableDeclaration 節點。
Symbol 的declarations 屬性是個數組,一般來說,這個數組中只有一個對象。一個違反了這種情況的例子是interface 聲明,TypeScript 中的interface 聲明可以合併。如對於下面的例子
interface T { ```text
interface T { a: string } interface T { b: number }
`` a: string
``text
interface T { a: string } interface T { b: number }
`` }
``text
interface T { a: string } interface T { b: number }
`` interface T {
``text
interface T { a: string } interface T { b: number }
`` b: number
``text
interface T { a: string } interface T { b: number }
```
生成的AST 樹為
包含兩個InterfaceDeclaration 節點,這個是符合預期的。但是對於這兩個InterfaceDeclaration 節點,關聯的Symbol 為
兩個聲明之中的成員發生了合併,declarations 中也含有兩條記錄。
理解了綁定器的作用之後,相信檢查器如何工作的也非常明了了。 Node 和Symbol 是關聯的,Node 上含有這個Node 相關的類型訊息,Symbol 含有這個Node 對外暴露的變量,以及Symbol 對應的聲明節點。對於賦值操作,檢查給這個Node 賦的值是否匹配這個Node 的類型。對於導入操作,檢查Symbol 是否導出了這個變量。對於對象調用操作,先從Symbol 的members 屬性找到調用方法的Symbol,根據這個Symbol 找到對應的declaration 節點,然後循環檢查。具體實現這裡就不再研究。
檢查結果被記錄到SourceFile 節點的diagnostics 屬性中。
二、TypeScript 與VSCode
當我們在VSCode 中新建一個TypeScript 文件並輸入TS 代碼時,可以發現VSCode 自動對代碼做了高亮,甚至在類型不一致的地方,VSCode 還會進行標紅,提示類型錯誤。
這是因為VSCode 內置了對TypeScript 語言的支持,類型檢查主要通過TypeScript 插件(extension)進行。插件背後就是Language Service Protocal。
Language Service Protocal
LSP 是由微軟提出的的一個協議,目的是為了解決插件在不同的編輯器之間進行複用的問題。 LSP 協議在語言插件和編輯器之間做了一層隔離,插件不再直接和編輯器通信,而是通過LSP 協議進行轉發。這樣在遵循了LSP 的編譯器中,相同功能的插件,可以一次編寫,多處運行。
從圖中可以看出,遵循了LSP 協議的插件存在兩個部分
- LSP 客戶端,它用來和VSCode 環境交互。通常用JS/TS 寫成,可以獲取到VSCode API,因此可以監聽VSCode 傳過來的事件,或者向VSCode 發送通知。
- 語言服務器。它是語言特性的核心實現,用來對文本進行詞法分析、語法分析、語義診斷等。它在一個單獨的進程中運行。
TypeScript 插件
VSCode 內置了對TypeScript 的支持,其實就是VSCode 內置了TypeScript 插件。
這一點可以從在Preference 中搜typescript,能在Extensions 下面找到TypeScript 看出。更改這裡面的配置,能控制插件的各種行為。
TypeScript 插件也遵循了LSP 協議。前面提到LSP 協議是為了讓插件一次編寫多處運行,這其實更多針對語言服務器部分。這是因為程序分析功能都由語言服務器實現,這一部分的工作量是最大的。本節內容也先從語言服務器說起。
tsserver
TypeScript 插件的語言服務器其實就是一個在獨立進程中運行的tsserver.js 文件。我們可以在typescript 源碼的src 文件下面找到tsserver 文件夾,這個文件夾編譯之後,就是我們項目中的node_modules/typescript/lib/tsserver.js 文件。 tsserver 接收插件客戶端傳過來的各種消息,將文件交給typescript-core 分析處理,處理結果回傳給客戶端后,再由插件客戶端交給VSCode,進行展示/執行動作等。
由於TypeScript 插件不需要將TS 文件編譯成JS 文件,所以typescript-core 只會運行到檢查器這一步。
private semanticCheck(file: NormalizedPath, project: Project) { ```text
private semanticCheck(file: NormalizedPath, project: Project) { // 简化了const diags = project.getLanguageService().getSemanticDiagnostics(file).filter(d => !!d.file); this.sendDiagnosticsEvent(file, project, diags, semanticDiag); }
`` // 簡化了const diags = project.getLanguageService().getSemanticDiagnostics(file).filter(d => !!d.file);
``text
private semanticCheck(file: NormalizedPath, project: Project) { // 简化了const diags = project.getLanguageService().getSemanticDiagnostics(file).filter(d => !!d.file); this.sendDiagnosticsEvent(file, project, diags, semanticDiag); }
`` this.sendDiagnosticsEvent(file, project, diags, semanticDiag);
``text
private semanticCheck(file: NormalizedPath, project: Project) { // 简化了const diags = project.getLanguageService().getSemanticDiagnostics(file).filter(d => !!d.file); this.sendDiagnosticsEvent(file, project, diags, semanticDiag); }
```
基本上看名字就知道這個函數做了什麼。
TypeScript 插件創建tsserver 的語句為
this._factory.fork(version.tsServerPath, args, kind, configuration, this._versionManager)
很明顯可以看出是fork 了一個進程。 fork 函數里值得一提的參數是version.tsServerPath,它是tsserver.js 文件的路徑。當我們將鼠標移到狀態欄右下角TypeScript 的版本上,會提示當前插件使用的tsserver.js 文件所在路徑。
VSCode 內置了最新穩定版本的typescript,並使用這個版本的tsserver.js 文件創建語言服務器。對應的是工作區版本——package.json 中依賴的typescript 的版本。點擊狀態欄右下角TypeScript 版本,會彈窗提示切換tsserver 的版本。如果tsserver 版本變更,會重新創建語言服務器進程。
LSP 客戶端
LSP 客戶端的主要作用:
- 創建語言服務器;
- 作為VSCode 和語言服務器之間溝通的橋樑。
創建語言服務器主要是fork 一個進程,與語言服務器溝通通過進程間通信,與VSCode 溝通通過調用VSCode 命名空間api。
像高亮、懸浮彈窗等功能是很多語言都需要的功能,因此VSCode 預先準備好了UI 和動作,LSP 客戶端只需要提供相應的數據就可以。如對於語法診斷,VSCode 提供了createDiagnosticCollection 方法,需要語法診斷功能的插件只需要調用這個方法創建一個DiagnosticCollection 對象,然後將診斷結果按文件添加到這個對像中即可。 TypeScript 插件在創建LSP 客戶端時,順帶給這個客戶端關聯了一個DiagnosticsManager 對象。
class DiagnosticsManager { constructor(owner: string, onCaseInsenitiveFileSystem: boolean) { super(); // 創建了三個對象,_diagnostics和_pendingUpdate主要用作緩存,進行性能優化// _currentDiagnostics是診斷結果核心對象,調用了createDiagnosticCollection this._diagnostics = new ResourceMap(undefined, { onCaseInsenitiveFileSystem }); this._pendingUpdates = new ResourceMap(undefined, { onCaseInsenitiveFileSystem }); this._currentDiagnostics = this._register(vscode.languages.createDiagnosticCollection(owner)); } public updateDiagnostics( file: vscode.Uri, language: DiagnosticLanguage, kind: DiagnosticKind, diagnostics: ReadonlyArray ): void { // 有簡化,給每個文件創建一個fileDiagnostics對象,將診斷結果記錄到fileDiagnostics對像中// 將file和fileDiagnostics關聯到_diagnostics對像中後,觸發一個更新事件const fileDiagnostics = new FileDiagnostics(file, language); fileDiagnostics.updateDiagnostics(language, kind, diagnostics); this._diagnostics.set(file, fileDiagnostics); this.scheduleDiagnosticsUpdate(file); } private scheduleDiagnosticsU pdate(file: vscode.Uri) { if (!this._pendingUpdates.has(file)) { // 延時更新this._pendingUpdates.set(file, setTimeout(() => this.updateCurrentDiagnostics(file), this._updateDelay)); } } private updateCurrentDiagnostics(file: vscode.Uri): void { if (this._pendingUpdates.has(file)) { clearTimeout(this._pendingUpdates.get(file)); this._pendingUpdates.delete(file); } // 真正觸發了更新的代碼,從_diagnostics中取出文件關聯的診斷結果,並設置到_currentDiagnostics對像中// 觸發更新const fileDiagnostics = this._diagnostics.get(file);this._currentDiagnostics.set(file, fileDiagnostics ? fileDiagnostics.getDiagnostics(this._settings) : []); } }
LSP 客戶端在收到語言服務器的診斷結果後,調用DiagnosticsManager 對象的updateDiagnostics 方法,診斷結果就能在VSCode 上顯示出來了。
三、TypeScript 與babel
在開發過程中,錯誤提示功能由VSCode 提供。但是我們的代碼需要經過編譯之後才能在瀏覽器中運行,這個過程中是什麼東西處理了TypeScript 呢?答案是Babel。 Babel 最初是設計用來將ECMAScript 2015+的代碼轉換成後向兼容的代碼,主要工作就是語法轉換和polyfill。只要Babel 能識別TypeScript 語法,就能對TypeScript 語法進行轉換。因此,Babel 和TypeScript 團隊進行了長達一年的合作,推出了@babel/preset-typescript 這個插件。使用這個插件,就能將TypeScript 轉換成JavaScript。
Babel 有兩種常見使用場景,一種是直接在CLI 中調用babel 命令,另一種是將Babel 和打包工具(如webpack)結合使用。由於babel 自身並不具備打包功能,所以直接在命令行中調用babel 命令的用處不大,本節主要討論如何在webpack 中使用babel 處理typescript。在webpack 中使用@babel/preset-typescript 插件非常簡單,只需要兩步。首先是配置babel,讓它加載@babel/preset-typescript 插件
{ ```text
{ presets: [@babel/preset-typescript] }
`` presets: [@babel/preset-typescript]
``text
{ presets: [@babel/preset-typescript] }
```
然後配置webpack,讓babel 能處理ts 文件
{ ```text
{ rules [ { test: /.ts$/, use: label-loader } ] }
`` rules [
``text
{ rules [ { test: /.ts$/, use: label-loader } ] }
`` {
``text
{ rules [ { test: /.ts$/, use: label-loader } ] }
`` test: /.ts$/,
``text
{ rules [ { test: /.ts$/, use: label-loader } ] }
`` use: label-loader
``text
{ rules [ { test: /.ts$/, use: label-loader } ] }
`` }
``text
{ rules [ { test: /.ts$/, use: label-loader } ] }
`` ]
``text
{ rules [ { test: /.ts$/, use: label-loader } ] }
```
這樣的話,webpack 在遇到.ts 文件時,會調用label-loader 處理這個文件。 label-loader 將這個文件轉換成標準JavaScript 文件後,將處理結果交還webpack,webpack 繼續後面的流程。 label-loader 是怎麼將TypeScript 文件轉換成標準JavaScript 文件的呢?答案是直接刪除掉類型註解。先看一下babel 的工作流程,babel 主要有三個處理步驟:解析、轉換和生成。
- 解析:將原代碼處理為AST。對應babel-parse
- 轉換:對AST 進行遍歷,在此過程中對節點進行添加、更新、移除等操作。對應babel-tranverse。
- 生成:把轉換後的AST 轉換成字符串形式的代碼,同時創建源碼映射。對應babel-generator。
在加入@babel/preset-typescript 之後,babel 這三個步驟是如何運行呢
- 解析:調用babel-parser 的typescript 插件,將源代碼處理成AST。
- 轉換:babel-tranverse 的過程中會調用babel-plugin-transform-typescript 插件,遇到類型註解節點,直接移除。
- 生成:遇到類型註解類型節點,調用對應輸出方法。其它如常。
使用babel,不僅能處理typescript,之前babel 就已經存在的polyfill 功能也能一併享受。並且由於babel 只是移除類型註解節點,所以速度相當快。那麼問題來了,既然babel 把類型註解移除了,我們寫TypeScript 還有什麼意義呢?我認為主要有以下幾點考慮:
- 性能方面,移除類型註解速度最快。收集類型並且驗證類型是否正確,是一個相當耗時的操作。
- babel 本身的限制。本文第一節分析過,進行類型驗證之前,需要解析項目中所有文件,收集類型訊息。而babel 只是一個單文件處理工具。 Webpack 在調用loader 處理文件時,也是一個文件一個文件調用的。所以babel 想驗證類型也做不到。並且babel 的三個工作步驟中,並沒有輸出錯誤的功能。
- 沒有必要。類型驗證錯誤提示可以交給編輯器。
當然,由於babel 的單文件特性,@babel/preset-typescript 對於一些需要收集完整類型系統訊息才能正確運行的TypeScript 語言特性,支持不是很好,如const enums 等。完整訊息可以查看
四、TSC
VSCode 只提示類型錯誤,babel 完全不校驗類型,如果我們想保證提交到代碼倉庫的代碼是類型正確的,應該怎麼做呢?這時可以使用tsc 命令。
tsc --noEmit --skipLibCheck
只需要在項目中運行這個命令,就可以對項目代碼進行類型校驗。如果再配合husky,在gitcommit 之前先執行一下這個命令,檢查一下類型。如果類型驗證不通過就不執行git commit,這樣整個開發體驗就很完美了。
tsc 命令對應的TypeScript 版本,就是node_modules 下安裝的TypeScript 的版本,這個版本可能跟VSCode 的TypeScript 插件使用的tsserver 的版本不一致。這在大多數情況下沒有問題,VSCode 內置的TypeScript 版本一般都比項目中依賴的TypeScript 版本高,TypeScript 是後向兼容的。如果遇到VSCode 類型檢查正常,但是tsc 命令檢查出錯,或相反的情況,可以從版本方面排查一下。
五、總結
本文探討了TypeScript 的工作原理,以及幫助TypeScript 在項目開發中發揮作用的工具。希望能給大家一些啟發。
附錄
- TypeScript AST Viewer要確保開啟了Option 中的Binding 選項。