TypeScript 是如何工作的
TypeScript 是一門基於 JavaScript 拓展的語言,它是 JavaScript 的超集,並且給 JavaScript 添加了靜態類型檢查系統。TypeScript 能讓我們在開發時發現程序中類型定義不一致的地方,及時消除隱藏的風險,大大增強了代碼的可讀性以及可維護性。相信大家對於如何在項目中使用 TypeScript 已經輕車熟路,本文就來探討簡單探討一下 TypeScript 是如何工作的,以及有哪些工具幫助它實現了這個目標。
一、TypeScript 工作原理
-
TypeScript 源碼經過掃描器掃描之後變成一系列 Token;
-
解析器解析 token,得到一棵 AST 語法樹;
-
綁定器遍歷 AST 語法樹,生成一系列 Symbol,並將這些 Symbol 連接到對應的節點上;
-
檢查器再次掃描 AST,檢查類型,並將錯誤收集起來;
-
發射器根據 AST 生成 JavaScript 代碼。
可見,AST 是整個類型驗證的核心。如對於下面的代碼
var a = 1;
function func(p: number): number {
return p * p;
}
a = 's'
export {
func
}
生成 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 {
a: string
}
interface T {
b: number
}
生成的 AST 樹爲
理解了綁定器的作用之後,相信檢查器如何工作的也非常明瞭了。Node 和 Symbol 是關聯的,Node 上含有這個 Node 相關的類型信息,Symbol 含有這個 Node 對外暴露的變量,以及 Symbol 對應的聲明節點。對於賦值操作,檢查給這個 Node 賦的值是否匹配這個 Node 的類型。對於導入操作,檢查 Symbol 是否導出了這個變量。對於對象調用操作,先從 Symbol 的 members 屬性找到調用方法的 Symbol,根據這個 Symbol 找到對應的 declaration 節點,然後循環檢查。具體實現這裏就不再研究。
檢查結果被記錄到 SourceFile 節點的 diagnostics 屬性中。
二、TypeScript 與 VSCode
當我們在 VSCode 中新建一個 TypeScript 文件並輸入 TS 代碼時,可以發現 VSCode 自動對代碼做了高亮,甚至在類型不一致的地方,VSCode 還會進行標紅,提示類型錯誤。
Language Service Protocal
LSP 是由微軟提出的的一個協議,目的是爲了解決插件在不同的編輯器之間進行復用的問題。LSP 協議在語言插件和編輯器之間做了一層隔離,插件不再直接和編輯器通信,而是通過 LSP 協議進行轉發。這樣在遵循了 LSP 的編譯器中,相同功能的插件,可以一次編寫,多處運行。
-
LSP 客戶端,它用來和 VSCode 環境交互。通常用 JS/TS 寫成,可以獲取到 VSCode API,因此可以監聽 VSCode 傳過來的事件,或者向 VSCode 發送通知。
-
語言服務器。它是語言特性的核心實現,用來對文本進行詞法分析、語法分析、語義診斷等。它在一個單獨的進程中運行。
TypeScript 插件
VSCode 內置了對 TypeScript 的支持,其實就是 VSCode 內置了 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) {
// 簡化了
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 文件所在路徑。
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<FileDiagnostics>(undefined, { onCaseInsenitiveFileSystem });
this._pendingUpdates = new ResourceMap<any>(undefined, { onCaseInsenitiveFileSystem });
this._currentDiagnostics = this._register(vscode.languages.createDiagnosticCollection(owner));
}
public updateDiagnostics(
file: vscode.Uri,
language: DiagnosticLanguage,
kind: DiagnosticKind,
diagnostics: ReadonlyArray<vscode.Diagnostic>
): 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 scheduleDiagnosticsUpdate(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 插件
{
"presets": ["@babel/preset-typescript"]
}
然後配置 webpack,讓 babel 能處理 ts 文件
{
"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 等。完整信息可以查看文檔 [1]。
四、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 AST Viewer[2]。要確保開啓了 Option 中的 Binding 選項。
參考資料
[1]
文檔: https://babeljs.io/docs/en/babel-plugin-transform-typescript#docsNav
[2]
TypeScript AST Viewer: https://ts-ast-viewer.com/#
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/XF8tWJlwZC04WTdmltmXNQ