TypeScript是如何工作的

blank

TypeScript 是一門基於JavaScript 拓展的語言,它是JavaScript 的超集,並且給JavaScript 添加了靜態類型檢查系統。 TypeScript 能讓我們在開發時發現程序中類型定義不一致的地方,及時消除隱藏的風險,大大增強了代碼的可讀性以及可維護性。相信大家對於如何在項目中使用TypeScript 已經輕車熟路,本文就來探討簡單探討一下TypeScript 是如何工作的,以及有哪些工具幫助它實現了這個目標。

一、TypeScript 工作原理

![]()![]()peScript 的大致工作原理如上圖所示:

  1. TypeScript 源碼經過掃描器掃描之後變成一系列Token;
  2. 解析器解析token,得到一棵AST 語法樹;
  3. 綁定器遍歷AST 語法樹,生成一系列Symbol,並將這些Symbol 連接到對應的節點上;
  4. 檢查器再次掃描AST,檢查類型,並將錯誤收集起來;
  5. 發射器根據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 的結構為

![](https://hypergrowths.com/wp-content/uploads/2021/09/v2-d4d957dff33929c11817f72e8d168157_r.jpg "v2-d4d957dff33929c11817f72e8d168157_r")![](https://hypergrowths.com/wp-content/uploads/2021/09/v2-d4d957dff33929c11817f72e8d168157_r.jpg "v2-d4d957dff33929c11817f72e8d168157_r")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 樹為

![](https://hypergrowths.com/wp-content/uploads/2021/09/v2-f1840cc8f276024aa007139eb5ebd124_r.jpg "v2-f1840cc8f276024aa007139eb5ebd124_r")![](https://hypergrowths.com/wp-content/uploads/2021/09/v2-f1840cc8f276024aa007139eb5ebd124_r.jpg "v2-f1840cc8f276024aa007139eb5ebd124_r")包含兩個InterfaceDeclaration 節點,這個是符合預期的。但是對於這兩個InterfaceDeclaration 節點,關聯的Symbol 為

![](https://hypergrowths.com/wp-content/uploads/2021/09/v2-8d01b18e3f97b1c8fba7cbd5f8c34de2_r.jpg "v2-8d01b18e3f97b1c8fba7cbd5f8c34de2_r")![](https://hypergrowths.com/wp-content/uploads/2021/09/v2-8d01b18e3f97b1c8fba7cbd5f8c34de2_r.jpg "v2-8d01b18e3f97b1c8fba7cbd5f8c34de2_r")兩個聲明之中的成員發生了合併,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 還會進行標紅,提示類型錯誤。

![](https://hypergrowths.com/wp-content/uploads/2021/09/v2-1ba97493c56ecf2219da6a0d0e5073b9_r.png "v2-1ba97493c56ecf2219da6a0d0e5073b9_r")![](https://hypergrowths.com/wp-content/uploads/2021/09/v2-1ba97493c56ecf2219da6a0d0e5073b9_r.png "v2-1ba97493c56ecf2219da6a0d0e5073b9_r")這是因為VSCode 內置了對TypeScript 語言的支持,類型檢查主要通過TypeScript 插件(extension)進行。插件背後就是Language Service Protocal。

Language Service Protocal

LSP 是由微軟提出的的一個協議,目的是為了解決插件在不同的編輯器之間進行複用的問題。 LSP 協議在語言插件和編輯器之間做了一層隔離,插件不再直接和編輯器通信,而是通過LSP 協議進行轉發。這樣在遵循了LSP 的編譯器中,相同功能的插件,可以一次編寫,多處運行。

![](https://hypergrowths.com/wp-content/uploads/2021/09/v2-7bbe9aa7d5b7f4fbcb331d84aa57a7be_r.jpg "v2-7bbe9aa7d5b7f4fbcb331d84aa57a7be_r")![](https://hypergrowths.com/wp-content/uploads/2021/09/v2-7bbe9aa7d5b7f4fbcb331d84aa57a7be_r.jpg "v2-7bbe9aa7d5b7f4fbcb331d84aa57a7be_r")從圖中可以看出,遵循了LSP 協議的插件存在兩個部分

  1. LSP 客戶端,它用來和VSCode 環境交互。通常用JS/TS 寫成,可以獲取到VSCode API,因此可以監聽VSCode 傳過來的事件,或者向VSCode 發送通知。
  2. 語言服務器。它是語言特性的核心實現,用來對文本進行詞法分析、語法分析、語義診斷等。它在一個單獨的進程中運行。

TypeScript 插件

VSCode 內置了對TypeScript 的支持,其實就是VSCode 內置了TypeScript 插件。

![](https://hypergrowths.com/wp-content/uploads/2021/09/v2-9ab52e89da54fa4826307c0602b5a379_r.png "v2-9ab52e89da54fa4826307c0602b5a379_r")![](https://hypergrowths.com/wp-content/uploads/2021/09/v2-9ab52e89da54fa4826307c0602b5a379_r.png "v2-9ab52e89da54fa4826307c0602b5a379_r")這一點可以從在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 文件所在路徑。

![](https://hypergrowths.com/wp-content/uploads/2021/09/v2-20b8004a2bcb440dfd26ab6b05da70ba_r.png "v2-20b8004a2bcb440dfd26ab6b05da70ba_r")![](https://hypergrowths.com/wp-content/uploads/2021/09/v2-20b8004a2bcb440dfd26ab6b05da70ba_r.png "v2-20b8004a2bcb440dfd26ab6b05da70ba_r")VSCode 內置了最新穩定版本的typescript,並使用這個版本的tsserver.js 文件創建語言服務器。對應的是工作區版本——package.json 中依賴的typescript 的版本。點擊狀態欄右下角TypeScript 版本,會彈窗提示切換tsserver 的版本。如果tsserver 版本變更,會重新創建語言服務器進程。

LSP 客戶端

LSP 客戶端的主要作用:

  1. 創建語言服務器;
  2. 作為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 主要有三個處理步驟:解析、轉換和生成。

  1. 解析:將原代碼處理為AST。對應babel-parse
  2. 轉換:對AST 進行遍歷,在此過程中對節點進行添加、更新、移除等操作。對應babel-tranverse。
  3. 生成:把轉換後的AST 轉換成字符串形式的代碼,同時創建源碼映射。對應babel-generator。

在加入@babel/preset-typescript 之後,babel 這三個步驟是如何運行呢

  1. 解析:調用babel-parser 的typescript 插件,將源代碼處理成AST。
  2. 轉換:babel-tranverse 的過程中會調用babel-plugin-transform-typescript 插件,遇到類型註解節點,直接移除。
  3. 生成:遇到類型註解類型節點,調用對應輸出方法。其它如常。

使用babel,不僅能處理typescript,之前babel 就已經存在的polyfill 功能也能一併享受。並且由於babel 只是移除類型註解節點,所以速度相當快。那麼問題來了,既然babel 把類型註解移除了,我們寫TypeScript 還有什麼意義呢?我認為主要有以下幾點考慮:

  1. 性能方面,移除類型註解速度最快。收集類型並且驗證類型是否正確,是一個相當耗時的操作。
  2. babel 本身的限制。本文第一節分析過,進行類型驗證之前,需要解析項目中所有文件,收集類型訊息。而babel 只是一個單文件處理工具。 Webpack 在調用loader 處理文件時,也是一個文件一個文件調用的。所以babel 想驗證類型也做不到。並且babel 的三個工作步驟中,並沒有輸出錯誤的功能。
  3. 沒有必要。類型驗證錯誤提示可以交給編輯器。

當然,由於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 命令檢查出錯,或相反的情況,可以從版本方面排查一下。

五、總結

blankblank本文探討了TypeScript 的工作原理,以及幫助TypeScript 在項目開發中發揮作用的工具。希望能給大家一些啟發。

附錄

  • TypeScript AST Viewer要確保開啟了Option 中的Binding 選項。

What do you think?

Written by marketer

看了100個全球品牌經典案,品牌的流量增長秘訣分析總整理!

blank

中紅傳媒:字節50億收購pico 單挑Facebook