TypeScript 是如何工作的

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;
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 {
    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 協議的插件存在兩個部分

  1. LSP 客戶端,它用來和 VSCode 環境交互。通常用 JS/TS 寫成,可以獲取到 VSCode API,因此可以監聽 VSCode 傳過來的事件,或者向 VSCode 發送通知。

  2. 語言服務器。它是語言特性的核心實現,用來對文本進行詞法分析、語法分析、語義診斷等。它在一個單獨的進程中運行。

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) {
    // 簡化了
    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 客戶端的主要作用:

  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<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 主要有三個處理步驟:解析、轉換和生成。

  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 等。完整信息可以查看文檔 [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 的工作原理,以及幫助 TypeScript 在項目開發中發揮作用的工具。希望能給大家一些啓發。

附錄

參考資料

[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