阅读 807

TypeScript是如何工作的

TypeScript 是一门基于 JavaScript 拓展的语言,它是 JavaScript 的超集,并且给 JavaScript 添加了静态类型检查系统。TypeScript 能让我们在开发时发现程序中类型定义不一致的地方,及时消除隐藏的风险,大大增强了代码的可读性以及可维护性。相信大家对于如何在项目中使用 TypeScript 已经轻车熟路,本文就来探讨简单探讨一下 TypeScript 是如何工作的,以及有哪些工具帮助它实现了这个目标。

一、TypeScript 工作原理

1111.png

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 等。完整信息可以查看文档

四、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 命令检查出错,或相反的情况,可以从版本方面排查一下。

五、总结

999.png 本文探讨了 TypeScript 的工作原理,以及帮助 TypeScript 在项目开发中发挥作用的工具。希望能给大家一些启发。

附录

文章分类
前端
文章标签