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