likes
comments
collection
share

【译】TypeScript 向模块的迁移

作者站长头像
站长
· 阅读数 18

TypeScript 5.0中,我们进行了一项基础架构的改变,这是我们所做的最具影响力的工作之一,它不是一个新功能、修复的错误或数据结构优化。我们重新组织了整个代码库,使用了 ECMAScript 模块,并切换到了一个更新的输出目标。

需要了解的内容

在我们深入讨论之前,我们想要设定一些期望。了解TypeScript 5.0 的这些改变对您意味着什么和不意味着什么是很重要的。

作为一般的 TypeScript 用户,您需要至少运行 Node.js 12。npm 安装速度应该会更快,占用的空间也会减少,因为 typescript 软件包的大小将减少约 46%。运行 TypeScript 的速度会更快,通常可以将构建时间缩短 10% 至 25%。

作为TypeScript的API使用者,您可能不会受到影响。TypeScript不会将其 API 作为 ES 模块进行发布,仍然会提供一个基于CommonJS 的 API。这意味着现有的构建脚本仍然可以工作。如果您依赖于TypeScripttypescriptServices.jstypescriptServices.d.ts文件,您将能够依赖于typescript.js/typescript.d.ts。如果您导入protocol.d.ts,您可以切换到tsserverlibrary.d.ts并利用ts.server.protocol

最后,作为 TypeScript 的贡献者,您的生活可能会变得更容易。构建时间会更快,增量检查时间应该也会更快,如果您已经在我们的编译器之外编写 TypeScript 代码,那么您将拥有一种更熟悉的编写格式。

背景信息

现在这可能听起来很奇怪——modules?像是带有导入和导出的文件?几乎所有现代的 JavaScript 和TypeScript 都使用模块,不是吗?

确实如此!但是当前的 TypeScript 代码库早于 ECMAScript 的模块化——我们上一次重写开始于2014年,而模块化在 2015 年才被标准化。我们不知道它们与其他模块系统(如CommonJS)的兼容性如何,而且坦率地说,当时对我们来说在模块中进行开发并没有太大的好处。

相反,TypeScript 利用了命名空间(以前称为内部模块)。

命名空间具有一些有用的特性。例如,它们的作用域可以跨文件合并,这意味着可以将项目拆分到不同的文件中,并以单个变量的方式进行清晰地公开。

// parser.ts 
namespace ts { 
    export function createSourceFile(/*...*/) { /*...*/ } 
};
// program.ts 
namespace ts { 
    export function createProgram(/*...*/) { /*...*/ } 
}
// user.ts 
// Can easily access both functions from 'ts'. 
const sourceFile = ts.createSourceFile(/*...*/); 
const program = ts.createProgram(/*...*/);

在自动导入不存在的时候,我们也很容易引用跨文件的导出。同一命名空间中的代码无需编写导入语句即可访问彼此的导出。

// parser.ts
namespace ts { 
    export function createSourceFile(/*...*/) { /*...*/ }
} 
// program.ts 
namespace ts { 
export function createProgram(/*...*/) { 
    // We can reference 'createSourceFile' without writing 
    //'ts.createSourceFile' or writing any sort of 'import'
    let file = createSourceFile(/*...*/); 
  } 
}

回想起来,命名空间的这些特性让其他工具很难支持 TypeScript;但是,它们对我们的代码库非常有用。 倒退几年,我们开始感受到命名空间的更多缺点。

命名空间问题

TypeScript 的编写语言是 TypeScript。这有时会让人感到惊讶,但编译器使用自身语言进行编写是一种常见的做法。这样做确实有助于我们理解我们正在为其他 JavaScript 和 TypeScript 开发者提供的体验。用术语来说,我们使用 TypeScript 自举(TypeScript compiler bootstrap)以便能够使用它自身来开发。

大多数现代 JavaScript 和 TypeScript 代码都是使用模块来编写的。然而,通过使用命名空间,我们没有像大多数用户那样使用 TypeScript。因此,我们的很多功能都是围绕着使用模块展开的,但我们自己并没有使用它们。所以我们面临两个问题:我们不仅错过了这些功能,而且错过了使用这些功能的体验。

例如,TypeScript 支持增量模式用于构建。这是加快连续构建的一种很好的方式,但在使用命名空间结构的代码库中,它实际上毫无用处。编译器只能在模块之间有效地进行增量构建,但我们的命名空间只是存在于全局范围内(通常是命名空间所在的位置)。因此,我们损害了在 TypeScript 本身上进行迭代的能力,以及在我们自己的代码库上正确测试增量模式的能力。

这不仅涉及编译器功能,错误消息和编辑器场景等体验也是基于模块构建的。自动导入补全和"整理导入"命令是 TypeScript 支持的两个广泛使用的编辑器功能,但我们根本没有依赖它们。

命名空间的运行时性能问题

一些与命名空间相关的问题更加微妙。到目前为止,命名空间的大部分问题可能听起来只是纯粹的基础设施问题,但命名空间也会对运行时性能产生影响。

首先,让我们看一下前面的例子:

// parser.ts 
namespace ts { 
    export function createSourceFile(/*...*/) { /*...*/ }
}
// program.ts 
namespace ts { 
    export function createProgram(/*...*/) {
        createSourceFile(/*...*/); 
    } 
}

这些文件将被重写为类似于以下 JavaScript 代码的内容:

// parser.js 
var ts; 
(function (ts) { 
    function createSourceFile(/*...*/) { /*...*/ } 
    ts.createSourceFile = createSourceFile; 
})(ts || (ts = {})); 
// program.js 
(function (ts) { 
    function createProgram(/*...*/) { 
        ts.createSourceFile(/*...*/); 
    } 
    ts.createProgram = createProgram; 
})(ts || (ts = {}));

首先要注意的是每个命名空间都被包裹在一个立即调用的函数表达式(IIFE)中。每次出现ts命名空间时,都会重复执行相同的设置/拆除操作,理论上在生成最终输出文件时可以进行优化。

第二个更微妙且更重要的问题是我们对 createSourceFile 的引用必须重写为ts.createSourceFile。回想一下,这实际上是我们喜欢的一点——它使得跨文件引用导出变得容易。

然而,这是有运行时成本的。不幸的是,在 JavaScript 中很少有零成本的抽象概念,通过对象调用方法的成本比直接调用作用域内的函数更高。因此,运行类似 ts.createSourceFile 这样的代码比运行createSourceFile 更耗费资源。

这些操作之间的性能差异通常是可以忽略的。或者至少,在编写编译器时通常可以忽略,因为这些操作在数百万个节点上重复数百万次。几年前,当 Evan Wallace 在我们的问题跟踪器上指出了这个开销时,我们意识到这是一个巨大的改进机会。

但命名空间并不是唯一可能遇到这个问题的结构——大多数打包工具模拟作用域的方式也会面临同样的问题。例如,考虑一下如果 TypeScript 编译器使用以下方式的模块结构:

// parser.ts
export function createSourceFile(/*...*/) {
    /*...*/ 
}
// program.ts 
import { createSourceFile } from "./parser"; 
export function createProgram(/*...*/) { 
    createSourceFile(/*...*/); 
}

一个简单的打包工具可能会为每个模块创建一个函数来建立作用域,并将导出放在一个单独的对象上。它可能类似于以下的形式:

    // Runtime helpers for bundle:
    function register(moduleName, module) { /*...*/ }
    function customRequire(moduleName) { /*...*/ }

    // Bundled code:
    register("parser", function (exports, require) {
        exports.createSourceFile = function createSourceFile(/*...*/) {
            /*...*/
        };
    });

    register("program", function (exports, require) {
        var parser = require("parser");

        exports.createProgram = function createProgram(/*...*/) {
            parser.createSourceFile(/*...*/);
        };
    });

    var parser = customRequire("parser");
    var program = customRequire("program");
    module.exports = {
        createSourceFile: parser.createSourceFile,
        createProgram: program.createProgram,
    };

现在,每个对createSourceFile的引用都必须经过 parser.createSourceFile,与在本地声明createSourceFile相比,这仍然会带来更多的运行时开销。这在一定程度上是为了模拟ECMAScript 模块的"实时绑定"行为——如果某人在parser.ts中修改了createSourceFile,这个修改也会反映在program.ts中。实际上,这里的JavaScript输出甚至可能变得更糟,因为重新导出通常需要以getter的形式定义——每个中间的重新导出也是如此!但出于我们的目的,让我们假设打包工具总是编写属性而不是getter

既然捆绑模块也可能遇到这些问题,为什么我们要提到命名空间的样板代码和间接性的问题呢?

因为模块的生态系统非常丰富,而且打包工具在优化某些间接性方面做得出乎意料地好!越来越多的打包工具不仅能够将多个模块聚合到一个文件中,还能执行一种被称为"作用域提升"的操作。作用域提升试图将尽可能多的代码移入尽可能少的共享作用域中。因此,执行作用域提升的打包工具可以将上述代码重写

    function createSourceFile(/*...*/) {
        /*...*/
    }

    function createProgram(/*...*/) {
        createSourceFile(/*...*/);
    }

    module.exports = {
        createSourceFile,
        createProgram,
    };

为将这些声明放在同一作用域中。通常,将这些声明放在同一作用域中是有益的,因为它避免了在单个文件中添加模拟作用域的样板代码——许多作用域的设置和拆除可以完全消除。但是,由于作用域提升将声明放在一起,它也使得引擎更容易优化我们对不同函数的使用。

因此,转向模块不仅是建立共鸣和更轻松迭代的机会,也是让我们加快速度的机会!

迁移

不幸的是,将使用命名空间的代码库转换为模块并没有一个明确的一对一的翻译方案。

我们对代码库在模块中的外观有一些具体的想法。我们肯定希望尽量减少对代码库风格的干扰,并且不想通过自动导入遇到太多的"坑"。与此同时,我们的代码库存在隐式循环,这也带来了一系列问题。

为了执行迁移,我们开发了一些特定于我们代码库的工具,我们将其称为 "typeformer"。早期版本直接使用 TypeScript API,但最新版本使用了 David Sherret 的出色的ts-morph库。

让这个迁移可行的一部分方法是将每个转换步骤都分解为单独的步骤和提交。这样,在不必担心微小但影响深远的差异(如缩进的更改)的情况下,我们可以更容易地迭代特定的步骤。每当我们在转换中看到一些"错误"时,我们就可以进行迭代。

在这个转换过程中的一个小问题是模块之间的隐式导出是如何解析的。这导致了一些不明显的隐式循环,而我们并不希望立即去思考这些循环的问题。

但我们很幸运—— TypeScript 的API需要通过一个叫做 "barrel" 模块来保留——一个重新导出所有其他模块内容的单个模块。我们利用了这一点,并且在生成导入时采取了一种"如果它没有问题,就暂时不修复"的方法。换句话说,在无法直接从每个模块创建导入时,typeformer 只是从该 barrel模块生成导入。

// program.ts
import { createSourceFile } from "./\_namespaces/ts"; // <- not directly importing from './parser'.

我们最终认为,我们可以(并且感谢 Oleksandr Tarasiuk 提出的更改,我们将会)切换到跨文件直接导入。

选择一个打包工具

在选择打包工具时,我们考虑到市面上出现了一些出色的新型打包工具。我们对我们的需求进行了评估,希望选择一个满足以下条件的工具:

  • 支持不同的模块格式(例如,CommonJS、ESM、以及一些条件性地设置全局变量的IIFE)
  • 提供良好的作用域提升(scope hoisting)和摇树优化(tree shaking)支持
  • 易于配置
  • 快速

这里有几个选项可能同样出色,但最终我们选择了 esbuild,并对其非常满意!我们对其快速迭代的能力和对遇到的任何问题的快速解决感到惊讶。对于 Evan Wallace 的卓越工具和对提升性能的贡献,我们表示赞赏。

打包和编译

不过,采用 esbuild 引出了一个奇怪的问题:打包工具是应该操作 TypeScript 的输出,还是直接操作TypeScript 源代码文件?换句话说,TypeScript 是否应该将其.ts文件转换并生成一系列.js文件,然后由 esbuild 进行打包?还是 esbuild 应该直接编译和打包我们的.ts文件?

大多数人现在使用的是后者的方式。这样做可以避免协调额外的构建步骤、每个步骤的中间产物文件,并且通常更快。

除此之外,esbuild 还支持大多数其他打包工具不支持的一个功能——const enum内联。这种内联在遍历我们的数据结构时提供了关键的性能提升,直到最近,唯一支持此功能的主要工具是 TypeScript 编译器本身。因此,esbuild 使得直接从我们的输入文件构建成为可能,而无需在运行时做出妥协。

但是 TypeScript 本身也是一个编译器,我们需要测试我们自己的行为!TypeScript 编译器需要能够编译 TypeScript 编译器并产生合理的结果,对吗?

因此,虽然添加打包工具帮助我们实际体验我们向用户提供的内容,但我们面临着失去了向下级编译自身并快速查看一切是否正常的风险。

我们最终达成了一个妥协。在持续集成(CI)中运行时,TypeScript 也会作为tsc发出的未打包的 CommonJS 来运行。这确保 TypeScript 仍然可以进行引导,并能够生成一个通过我们的测试套件的有效工作版本的编译器。

对于本地开发,默认情况下,运行测试仍然需要来自 TypeScript 的完整类型检查,并由 esbuild 进行编译。这在某些测试中是必要的。例如,我们存储了 TypeScript 声明文件的"基准"或"快照"。每当我们的公共 API 发生更改时,我们必须将新的.d.ts文件与基准进行比较,以查看发生了什么变化。但是,生成声明文件仍然需要运行 TypeScript。

但这只是默认设置。现在,如果需要,我们可以轻松地在不进行完整类型检查的情况下运行和调试测试。因此,对于我们来说,JavaScript 转换和类型检查已经解耦,如果需要,可以独立运行。

保留我们的 API 和捆绑我们的声明文件

正如前面提到的,使用命名空间的一个优点是,我们可以将输入文件串联起来以创建输出文件。但是,这也适用于我们的输出.d.ts文件。

以前的示例中:

// src/compiler/parser.ts
namespace ts {
    export function createSourceFile(/*...*/) {
        /*...*/
    }
}

// src/compiler/program.ts
namespace ts {
    export function createProgram(/*...*/) {
        createSourceFile(); /*...*/
    }
}

我们最初的构建系统会生成单个输出 .js.d.ts 文件。文件 tsserverlibrary.d.ts 可能如下所示:

namespace ts {
    function createSourceFile(/*...*/): /* ...*/;
}
namespace ts {
    function createProgram(/*...*/): /* ...*/;
}

当同一个作用域中存在多个namespace时,它们会进行称为声明合并的操作,将它们的所有导出合并在一起。因此,这些命名空间形成了一个最终的ts命名空间,一切都正常工作。

TypeScript 的 API 确实有一些"嵌套"命名空间,在迁移过程中我们需要进行维护。创建tsserverlibrary.js所需的一个输入文件如下所示:

// src/server/protocol.ts
namespace ts.server.protocol {
    export type Request = /*...*/;
}

作为一个旁注和复习,这与编写以下内容相同:

// src/server/protocol.ts
namespace ts {
    export namespace server {
        export namespace protocol {
            export type Request = /*...*/;
        }
    }
}

它将被添加到 tsserverlibrary.d.ts的底部:

namespace ts {
    function createSourceFile(/*...*/): /* ...*/;
}
namespace ts {
    function createProgram(/*...*/): /* ...*/;
}
namespace ts.server.protocol {
    type Request = /*...*/;
}

在命名空间之后,我们希望在仅使用模块的情况下保留相同的API,并且我们的声明文件也必须能够对此进行建模。

为了保持一切正常运行,我们的公共API中的每个命名空间都由一个单独的文件来建模,该文件重新导出了来自各个较小文件的所有内容。这些通常被称为"barrel模块",因为它们...嗯...将所有内容重新打包到一个...桶中?

我们不太确定。

总之!我们保持相同的公共API的方法如下:

// COMPILER LAYER

// src/compiler/parser.ts
export function createSourceFile(/*...*/) {
    /*...*/
}

// src/compiler/program.ts
import { createSourceFile } from "./_namespaces/ts";

export function createProgram(/*...*/) {
    createSourceFile(/*...*/);
}

// src/compiler/_namespaces/ts.ts
export * from "./parser";
export * from "./program";

// SERVER LAYER

// src/server/protocol.ts
export type Request = /*...*/;

// src/server/_namespaces/ts.server.protocol.ts
export * from "../protocol";

// src/server/_namespaces/ts.server.ts
export * as protocol from "./protocol";

// src/server/_namespaces/ts.ts
export * from "../../compiler/_namespaces/ts";
export * as server from "./ts.server";

在这里,每个项目中不同的命名空间被一个名为_namespaces的文件夹中的 barrel 模块所取代。

NamespaceModule Path within Project
namespace ts./_namespaces/ts.ts
namespace ts.server./_namespaces/ts.server.ts
namespace ts.server.protocol./_namespaces/ts.server.protocol.ts

有一些"不必要的"间接性,但它为模块转换提供了一个合理的模式。

现在,我们的.d.ts生成当然可以处理这种情况 - 每个.ts文件将产生一个不同的输出.d.ts文件。这是大多数编写 TypeScript 的人使用的方式;然而,我们的情况具有一些独特的特点,使得直接使用它变得具有挑战性:

  • 一些使用者已经依赖于 TypeScript API在单个d.ts文件中的表示。这些使用者包括暴露TypeScript API 内部的项目(例如ts-expose-internals,byots),以及将 TypeScript 进行打包/封装的项目(例如ts-morph)。因此,保持在单个文件中是可取的。
  • 我们在公共 API 中导出了许多类似 SyntaxKind 或 SymbolFlags 的枚举类型,它们实际上是const枚举。暴露const枚举通常是不好的,因为下游 TypeScript 项目可能会错误地假设这些枚举的值永远不会改变并将其内联。为了防止发生这种情况,我们需要对我们的声明文件进行后处理以删除const修饰符。如果需要在每个输出文件上跟踪此操作,这将是具有挑战性的,因此,我们可能希望将所有内容保持在单个文件中。
  • 一些下游用户增强了 TypeScript 的 API,声明了我们的一些内部存在;即使这些增强并非官方支持,最好也避免破坏这些情况,因此无论我们发布什么都需要与我们旧的输出足够相似,不会引起任何意外。
  • 我们跟踪 API 的变化,并在每次完整的测试运行中对比旧 API 和新 API。将这个过程限制在单个文件中是可取的。
  • 鉴于我们的每个 JavaScript 库入口点只是单个文件,最"诚实"的做法似乎是为每个入口点提供单个声明文件。

这些要求都指向一个解决方案:声明文件的打包。

就像有很多选项用于打包 JavaScript 一样,也有很多选项用于打包.d.ts文件:api-extractor、rollup-plugin-dts、tsup、dts-bundle-generator 等等。

这些都满足"生成单个文件"的最终要求,然而,额外的要求是以类似于旧输出的命名空间形式声明我们的API,这意味着如果不进行大量修改,我们无法使用其中任何一个。

最终,我们选择了针对我们特定需求的自定义mini-d.ts打包工具。该脚本大约有400行代码,简单地递 归遍历每个入口点的导出,并按原样输出声明。根据前面的示例,这个打包工具的输出类似于:

namespace ts {
    function createSourceFile(/*...*/): /* ...*/;
    function createProgram(/*...*/): /* ...*/;
    namespace server {
        namespace protocol {
            type Request = /*...*/;
        }
    }
}

此输出在功能上等同于旧的命名空间连接输出,同时也包括了与我们之前的输出相同的 const enum 转换为 enum@internal 去除。去除了命名空间 ts {} 的重复还使声明文件稍微变小(约200 KB)。

需要注意的是,此打包工具不适用于一般用途。它简单地遍历导入并按原样输出声明,并且无法处理以下情况:

未导出的类型 —— 如果导出的函数引用了未导出的类型,TypeScript 的 d.ts 输出仍将在本地声明该类型。

export function doSomething(obj: Options): void;

// Not exported, but used by 'doSomething'!
interface Options {
    // ...
}

这允许 API 提及特定类型,即使 API 使用者实际上不能通过名称引用这些类型。

我们的打包工具无法输出未导出的类型,但可以检测到这种情况,并发出错误,指示该类型必须导出。这是一个很好的权衡,因为完整的 API 往往更易用。

名称冲突 —— 两个文件可以分别声明一个名为 Info 的类型 - 一个被导出,另一个纯粹是局部的。

// foo.ts
export interface Info {
    // ...
}
export function doFoo(info: Info) {
    // ...
}

// bar.ts
interface Info {
    // ...
}
export function doBar(info: Info) {
    // ...
}

对于一个强大的声明打包工具来说,这不应该是一个问题。未导出的 Info 可以使用一个新名称进行声明,并更新使用它的地方。

但是我们的声明打包工具并不强大 - 它不知道如何处理这种情况。它的第一次尝试是简单地删除局部声明的类型,并保留导出的类型。这是非常错误的,而且很微妙,因为它通常不会触发任何错误!

我们让打包工具聪明一些,至少可以检测到这种情况。现在它会发出错误以解决歧义,可以通过重新命名和导出缺失的类型来解决。幸运的是,在 TypeScript API 中没有太多这样的例子,因为命名空间合并已经意味着在不同文件中具有相同名称的声明被合并。

导入限定符 —— 偶尔,TypeScript 会推断出一个本地未导入的类型。在这种情况下,TypeScript 会将该类型写为 import("./types").SomeType 等形式。由于这些 import(...)限定符所指向的路径已不存在,因此不能将它们保留在输出中。我们的打包工具会检测到这些类型,并要求修复代码。通常情况下,只需要对函数进行显式类型注释即可解决此问题。像api-extractor这样的打包工具实际上可以通过重写类型引用来指向正确的类型来处理这种情况。 因此,尽管存在一些限制,但对我们来说,这些都是完全可以的(甚至是可取的)。

转折点的到来!

经过多年的决策和细致的计划,一切都要有所进展了!经过漫长的努力,形成了一个庞大的拉取请求,其中有超过 282,000 行的代码发生了变动。此外,由于我们无法长时间冻结 TypeScript 代码库,这个拉取请求还需要定期刷新。在某种程度上,我们在开车时试图更换桥梁。

幸运的是,我们的类型转换工具能够使用提交来重建迁移的每个步骤,这也有助于代码审查。除此之外,我们的测试套件和所有外部测试基础设施真的让我们对此举充满了信心。

因此,最终,我们要求团队暂停进行任何更改。我们点击了合并按钮,就这样,Jake 让 Git 相信他是 TypeScript 代码库中每一行代码的作者。

TypeScript 开始使用模块了!

【译】TypeScript 向模块的迁移

Git的问题?

好的,我们对于那个 Git 的问题是半开玩笑的。我们通常使用 Git 的责任追溯功能来了解代码改动的来源,不幸的是,默认情况下,Git 认为几乎每一行代码都是来自我们的“将代码库转换为模块”提交。

幸运的是,可以通过配置 blame.ignoreRevsFile 来让 Git 忽略特定的提交,而 GitHub 默认会忽略在顶层的 .git-blame-ignore-revs 文件中列出的提交。

春季大扫除,当我们进行这些改动的时候,我们寻找了简化我们正在发布的一切的机会。我们发现 TypeScript 有一些文件实际上已经不再需要了。lib/typescriptServices.jslib/typescript.js 是相同的,而所有的 lib/protocol.d.ts 基本上是从 ts.server.protocol 命名空间中的lib/tsserverlibrary.d.ts 复制出来的。

在 TypeScript 5.0 中,我们选择删除了这些文件,并建议使用这些向后兼容的替代方案。能够减少几兆字节的代码同时拥有良好的解决方案让我们感到很好。

空格和代码压缩? 我们使用 esbuild 的一个好消息是,磁盘上的文件大小减小的比我们预期的要多。原来其中一个很重要的原因是 esbuild 在输出中使用了两个空格来缩进,而不是 TypeScript 使用的四个空格。在进行 gzip 压缩时,差异非常小;但在磁盘上,我们节省了相当多的空间。

这引发了一个问题,即我们是否应该开始对输出进行任何压缩。虽然这很诱人,但这会使我们的构建过程变得复杂,使堆栈跟踪分析变得更加困难,并迫使我们必须使用源映射(或者找到一个源映射托管服务器,类似于符号服务器用于调试信息)。

我们决定暂时不进行代码压缩。任何在 Web 上发布 TypeScript 的人都可以对我们的输出进行压缩(就像在 TypeScript Playground 上所做的那样),而且 gzip 压缩已经让从 npm 下载的速度非常快。虽然代码压缩对于我们构建系统的根本性变化来说是个“易如反掌”的事情,但它只是带来了更多问题而不是答案。此外,我们还有其他更好的方法来减小包的大小。

性能减慢? 当我们深入挖掘时,我们注意到虽然我们的各项基准测试的编译时间都减少了,但解析过程实际上,我们发现在解析过程中实际上变慢了。是什么原因导致这种情况呢?

虽然我们没有提到太多细节,但当我们切换到模块时,我们也切换到了更现代的输出目标。我们从 ECMAScript 5 切换到了 ECMAScript 2018。使用更多的原生语法意味着我们可以在输出中减少一些字节,并且更容易调试我们的代码。但这也意味着引擎必须执行这些原生结构所规定的确切语义。

也许你会惊讶地发现,letconst - 这两个在现代 JavaScript 中最常用的特性 - 实际上有一些额外的开销。

没错!在 letconst 变量被声明之前,它们不能被引用。

// error! 'x' is referenced in 'f'
// before it's declared!
f();
let x = 10;
function f() { 
    console.log(x); 
} 

为了强制实施这一点,引擎通常会在函数捕获 letconst 变量时插入保护代码。每当函数引用这些变量时,这些保护代码至少会执行一次。

当 TypeScript 的目标是 ECMAScript 5 时,这些 letconst 变量只会被转换成 var 变量。这意味着如果在初始化之前访问了 letconst 声明的变量,我们不会得到错误。相反,它的值将被观察为 undefined。在某些情况下,由于这种差异,TypeScript 的降级输出并不符合规范。当我们切换到更新的输出目标时,我们修复了一些使用前声明的情况,但这种情况很少见。

当我们最终将输出目标切换到更现代化的时候,我们发现引擎在处理 letconst 变量时花费了大量时间进行这些检查。作为一次实验,我们尝试在最终的代码包上运行 Babel,仅将 letconst 转换为 var。我们发现,通过在所有地方都使用 var,我们的解析时间通常可以减少 10% - 15%。这意味着我们整个编译过程的时间中,有多达 5% 的时间仅用于这些 let/const 检查!

目前,esbuild 并没有提供将 letconst 转换为 var 的选项。我们本可以在这里使用 Babel,但我们确实不想在我们的构建过程中引入另一个步骤。郭书育(Shu-yu Guo)已经在研究消除许多这些运行时检查的机会,并取得了一些有希望的结果,但某些检查仍然需要在每个函数上运行,而我们正在寻求立即的解决方案。

相反,我们找到了一个折中方案。我们意识到我们的编译器的大多数主要组件都遵循一种相似的模式,其中顶层作用域包含了其他闭包共享的一大部分状态。

export function createScanner(/ *...* /) { 
let text; 
let pos; 
let end; 
let token; 
let tokenFlags;

// ...

let scanner = {
    getToken: () => token,
    // ...
};

return scanner;

}

我们最初希望在使用letconst之前主要是因为var会有潜在的作用域泄漏问题;但在函数的顶层作用域中,使用var的"缺点"要少得多。因此,我们自问在这些情况下切换到var能够带来多少性能提升。

事实证明,通过这样做,我们能够消除大部分运行时检查!因此,在我们的编译器中的一些特定位置,我们已经切换到了var,在那些区域我们关闭了"no var"的 ESLint 规则。上面的createScanner函数现在看起来像这样:

    export function createScanner(/ *...* /) { 
    // Why var? It avoids TDZ checks in the runtime which can be costly.
    // See:<https://github.com/microsoft/TypeScript/issues/52924> 
    /* eslint-disable no-var */ 
    var text; 
    var pos; 
    var end; 
    var token; 
    var tokenFlags;
    // ...

    let scanner = {
        getToken: () => token,
        // ...
    };
    /* eslint-enable no-var */

    return scanner;
}

我们不建议大多数项目这样做——至少在进行性能分析之前不要这样做。但我们很高兴在这里找到了一个合理的解决方法。

ESM在哪里?

正如我们之前提到的,尽管TypeScript现在使用模块进行编写,但我们实际发布的JS文件的格式并没有改变。当在CommonJS环境中执行时,我们的库仍然以CommonJS形式工作((module.exports is defined),或者声明一个顶层变量var ts否则(用于

长期以来一直有一个请求,希望TypeScript以ECMAScript模块(ESM)的形式发布(#32949)。

发布ECMAScript模块将带来许多好处:

  • 如果运行时可以并行加载多个文件(即使按顺序执行),加载ESM可能比非捆绑CJS更快。
  • 原生ESM不使用导出助手,因此ESM输出可以像捆绑/作用域提升的输出一样快。CJS可能需要导出助手来模拟动态绑定,在我们这样有多个重新导出链的代码库中,这可能会变慢。
  • 软件包大小会更小,因为我们可以在不同的入口点之间共享代码,而不是生成单独的捆绑包。
  • 那些将TypeScript进行捆绑的人可能会摇树(tree shake)掉他们不使用的部分。这甚至可以帮助那些只需要我们解析器的众多用户(尽管我们的代码库仍需要更多改变来使其正常工作)。

听起来都很棒!但我们并没有这样做,那是为什么呢?

主要原因在于当前的生态系统。虽然很多软件包正在添加ESM(甚至只使用ESM),但更大一部分仍在使用CommonJS。在不久的将来,我们不太可能只发布ESM,因此我们必须继续发布一些CommonJS,以免让用户被落下。

话虽如此,还有一个有趣的中间地带...

Shipping ESM Executables (And More?)

之前我们提到,我们的库仍然是按照 CommonJS 规范编写的。但是,TypeScript不仅仅是一个库,它还包括一些可执行文件,包括tsctsserver,以及一些用于自动类型获取(ATA)、文件监视和取消的较小的包。

关键观察结果是,这些可执行文件不需要被导入;它们是可执行文件!因为这些文件不需要被任何人导入(甚至包括使用tsserverlibrary.js和自定义主机实现的 vscode.dev 我们可以自由地将这些可执行文件转换为任何我们想要的模块格式,只要行为对于调用这些可执行文件的用户没有变化。

这意味着,只要我们将最低 Node 版本提升到v12.20,我们就可以将tsctsserver等转换为ESM 格式。

一个需要注意的地方是,在我们的软件包中可执行文件的路径是“众所周知的”;令人惊讶的是,许多工具、package.json脚本、编辑器启动配置等都使用硬编码的路径,比如./node\_modules/typescript/bin/tsc.js``./node\_modules/typescript/lib/tsc.js

由于我们的package.json没有声明"type": "module",Node会认为这些文件是CommonJS格式,因此仅仅发出ESM格式是不够的。我们可以尝试使用"type": "module",但这将带来许多其他挑战。

相反,我们倾向于在 CommonJS 文件中使用动态import()调用来启动一个实际执行工作的ESM文件。换句话说,我们将替换tsc.js,使用类似下面的包装器:

// <https://en.wikipedia.org/wiki/Fundamental_theorem_of_software_engineering> 
(() => import("./esm/tsc.mjs"))().catch((e) => { 
    console.error(e);
    process.exit(1); 
});

这对于调用该工具的任何人来说是不可观察到的,而且我们现在可以发出ESM格式。然后,tsc.jstsserver.jstypingsInstaller.js等之间共享的大部分代码可以共享!这将进一步减少我们软件包的大小,节省了另外7 MB的空间,而这个改变是任何人都看不到的,非常好。

ESM文件的实际样子以及如何发出它是另一个问题。在不久的将来,最兼容的选择可能是使用esbuild的代码拆分功能来发出ESM格式。

更远的未来,我们甚至可以完全将 TypeScript 代码库转换为像Node16/NodeNextES2022/ESNext这样的模块格式,并直接发出 ESM 格式!或者,如果我们仍然希望只发布一些文件,我们可以将我们的 API 公开为 ESM 文件,并将它们作为打包器的一组入口点。无论哪种方式,都有可能使 npm 上的 TypeScript 软件包更加精简,但这将是一个更加困难的改变。

无论如何,我们绝对会考虑将来进行这方面的工作;将代码库从命名空间转换为模块是迈出的第一步,也是向前发展的重要一步。

API Patching

正如我们之前提到的,我们的目标之一是保持与 TypeScript 现有 API 的兼容性;然而,CommonJS 模块使人们可以以我们未曾预料到的方式使用 TypeScript API

在 CommonJS 中,模块是普通对象,没有提供默认的保护机制,无法阻止他人修改库的内部实现!随着时间的推移,我们发现许多项目都在对我们的 API 进行补丁修改!这使得我们陷入了困境,因为即使我们想支持这种补丁修改,在实际操作中也是不可行的。

在许多情况下,我们帮助一些项目转移到更合适的向后兼容的 API 上。在其他情况下,我们仍然面临一些挑战,希望能够帮助项目维护者前进!我们渴望与他们交流并提供帮助。

Accidentally Exported

在这方面,我们的目标是保持对由于使用命名空间而必要的现有API的一些"软兼容性"

使用命名空间时,必须导出内部函数,以便它们可以被不同的文件使用。

// utilities.ts 
namespace ts { 
    /** @internal */ 
    export function doSomething() { 
    } 
}
// parser.ts 
namespace ts { 
    // ...
    scssCopy code
    let val = doSomething();
}

// checker.ts 
namespace ts { 
// ...
let otherVal = doSomething();
} 

在这里,doSomething 必须被导出,以便它可以从其他文件中访问。在构建过程中,我们会在.d.ts 文件中删除被标记为/** @internal */ 的函数,但在运行时它们仍然可以从外部访问。

相比之下,模块的捆绑不会泄露每个文件的导出。如果一个入口点没有重新导出另一个文件中的函数,它将作为本地函数被复制进来。

从技术上讲,使用 TypeScript 5.0,我们可以不重新导出每个被标记为/** @internal */的函数,并将它们作为"硬私有"函数。然而,这似乎对于试验 TypeScript API 的项目不够友好。我们还需要开始明确地导出我们公共 API 中的所有内容。这可能是一个最佳实践,但这超出了我们在5.0版本中想要承诺的范围。

因此,我们选择在 TypeScript 5.0 中保持相同的行为。

How’s the Dog Food?

我们之前提到,模块能帮助我们更好地理解用户的需求。那么,这种说法到底是否成立呢?

首先,考虑到我们必须做出的所有打包选择和构建工具决策!了解这些问题使我们更接近其他库作者目前所经历的情况,并为我们带来了很多思考。

但是,一旦我们转向模块,我们就遇到了一些明显的用户体验问题。例如,自动导入和编辑器中的"整理导入"命令有时感觉不太顺畅,并经常与我们的代码检查偏好发生冲突。在项目引用方面,我们也遇到了一些困难,如果要在"开发"和"生产"构建之间切换标志,就需要完全平行的一组tsconfig.json 文件。我们对外部没有收到更多关于这些问题的反馈感到惊讶,但我们很高兴我们能够发现这些问题。而最好的部分是,许多这些问题,比如对不区分大小写的导入排序的支持和通过--build传递特定于emit的标志,已经在 TypeScript 5.0 中实现了!

那么项目级别的增量编译呢?我们并不清楚我们是否获得了我们期望的改进。从tsc进行的增量检查并不会在一秒钟内完成等等。我们认为这部分问题可能源于项目中文件之间的循环依赖关系。我们还认为,由于我们的大部分工作往往集中在诸如共享类型、扫描器、解析器和检查器之类的大型根文件上,这就需要检查项目中几乎每个其他文件。这是我们希望未来能够调查的问题,并希望能够为每个人带来改进。

结果

在经历了所有这些步骤之后,我们取得了一些非常好的结果!

npm上未压缩软件包大小减少了 46% 速度提升了 10%-25% 许多用户体验改进 更现代化的代码库 这种性能改进与我们在 TypeScript 5.0 中所做的其他性能工作有些交织在一起,但其中令人惊讶的部分来自模块和作用域提升。

我们对我们更快、更现代化的代码库和大大简化的构建过程感到非常兴奋。我们希望这使得使 TypeScript 5.0 和未来的每个版本都成为一种享受。

祝您编程愉快!

– Daniel Rosenwasser、Jake Bailey和TypeScript团队

原文链接:devblogs.microsoft.com/typescript/…