Deno 为什么把核心模块从 ts 改回了 js

先说原因:根本原因是 ts 无法为 Deno runtime 生成高性能的 js 代码


关于更换 js 的讨论已经很久了,这是 ry 写的设计文档 docs.google.com/documen

首先需要澄清一个误区:Deno 并没有放弃 TypeScript,Deno 依然是一个安全的 TS/JS runtime。

上一次 Deno 调整架构之后,增加了 rusty_v8 和 deno_typescript 两个 rust 包(crates),目前 Deno 彻底去除了 C++/C 代码。各语言比例大概是:

  • TypeScript:64.7%

  • Rust:31.9%

  • JavaScript:1.4%

可以看到,Deno 目前就已经包含了一部分 JavaScript 代码。

我们看一下 Deno 的几个核心模块(目录):

  • deno/core:代码主要为 rust 和 js

  • deno/cli:全部为 rust

  • deno/cli/ops:全部为 rust

  • deno/cli/js:全部为 typescript

  • deno/std/*:几乎全部为 typescript,部分高性能模块是 wasm(rust+js)

这次把 ts 改回 js 的只有 deno/cli/js/* 目录下的文件。我凭直觉估算一下,这部分的 ts 所占比例应该不到 1/3。

至于原因,这篇中文翻译以及英文原文都有些断章取义+猜测了。编译速度慢是一个方面,但不是根本原因。rustc 的编译速度也很慢,但是生成的代码质量很高。tsc 确实也慢,如果 tsc 能够生成高性能的 js 代码,那么这点构建的时间成本是可以接受的。但是目前来看 ts 无法为 Deno runtime 生成高性能的 js 代码。或者说,Deno 团队没有找到 ts 生成高性能 js 代码的方式。

Deno 使用最新版 ts 和 v8,而 ts 和 v8 的实现目标都是 stage3,理论上 ts 只要简单的做类型擦除就可以直接运行在 v8 上。最初 Deno 团队也是这么想的。

但是实际使用 ts 的情况却不是这么理想。

我从 2018 年底开始给 Deno 修 bug,2019 年开始维护了几个 deno/cli/js/web 下的模块,这时 ts 的问题就暴露出来了。

比如我为 URLSearchParams API 加了这几行代码:

Deno 为什么把核心模块从 ts 改回了 js_第1张图片

初看这行代码是不是有点画蛇添足啊。因此在 PR 里我反复向 ry 解释 WHATWG 规范里面对于 URLSearchParams 的描述,并且私下里和他讨论了很久。

根据 whatwg 的 url 规范,参数必须转换成字符串。当使用 ts 编写代码时,我们已经默认了参数肯定是字符串,而且参数的个数为 1。但是用户依然可以传入其它参数,比如用户使用 Deno 运行 js 代码时,用户可以写 url.get(1)此时 Deno 的行为就不符合规范了。即使用户使用的是 ts,用户依然可以写 url.get(1 as any)代码。

我当时给 ry 的建议是,使用 js 写所有的测试用例,这样能更容易覆盖规范的所有路径。但是 ry 还是希望使用 ts,于是我在单元测试里面加了很多 as unknown as string代码。

去年(2019)底我在北京为 ry 展示了我为 Deno 做的 wpt(Web 兼容性测试)报告。

我:目前 web 兼容性还有很多工作要做,我把 nodejs 的 wpt 移植到了 deno,可以作为开发 web api 的指导。

ry:目前 deno web api 的进度如何?

我:大概 60% 多一些。

ry:挺少的。有什么困难吗?

我:和上次咱俩网上讨论的一样,ts 写 web api 有些 quirks。

目前 Deno 魔改了两套 ts 编译器,一套用于构建 deno 时使用,使得 deno 的构建完全脱离了 Node.js;另一套用于 Deno runtime,也就是运行 deno run 命令的时候使用。此外 Deno 还有第三套 ts 编译器,用于 deno doc命令,这个 ts 编译器是使用 rust 语言开发的 swc。swc 只做类型擦除,不做静态类型检查,而且是 rust 开发的,因此性能非常高。

说到性能,我们再来谈谈 ts 为什么不能编译为高性能的 js 代码。

前面谈到的 ts 那些关于 weg 规范的问题,我们都可以通过某些 hack 技巧绕过去,比如我这个 PR(Sorting non-existent params removes ? from URL #2495) 就通过将类的某个属性强制转换为 any 来访问属性的私有字段,以此来实现规范定义的步骤。

但是性能问题目前还没有非常好的方式来解决。

我们继续看 URLSearchParams,这个类是这么定义的:

export class URLSearchParamsImpl implements URLSearchParams {
  #params: Array<[string, string]> = [];

  constructor(init: string | string[][] | Record = "") {
    if (typeof init === "string") {
      this.#handleStringInitialization(init);
      return;
    }

    if (Array.isArray(init) || isIterable(init)) {
      this.#handleArrayInitialization(init);
      return;
    }

    if (Object(init) !== init) {
      return;
    }
...
...

定义类 URLSearchParamsImpl,实现接口 URLSearchParams

存在的问题就是:当访问URLSearchParams 的 name 属性时就会输出错误的结果: "URLSearchParamsImpl", 这是不符合规范的。为了使 URL 符合规范,我们必须使用如下代码:

Object.defineProperty(URLSearchParamsImpl, "name", { value: "URLSearchParams" });

对于 V8 来说,这种代码比直接写 class URLSearchParams 的性能要低,因为会破坏 V8 的最优优化路径。

那 Deno 为什么不能直接写 class URLSearchParams 呢?这又是另一个历史遗留问题了,而且是 ts 的历史问题。

Deno 使用 es2019(es10) 编写,相比使用 es3 编写的 Node.js,Deno 已经非常现代化了。(目前 Node.js 已经部分升级到了 es6,并保留了一部分 es5;而 Deno 基本上全量升级到了 es2020)。虽然解决了 js 的遗留问题,比如不兼容 common js 等,但是却遇到了 ts 的历史遗留问题导致无法直接定义 class URLSearchParams

最初 Deno 的 lib.deno.d.ts 文件是从 .ts 源码自动生成的,然而随着越来越多的 web api 添加进来,这种自动生成 .d.ts 的方式出现了问题。首先就是生成的 .d.ts 文件杂乱不够紧凑,但是也能使用。最严重的问题就是,生成的 .d.ts 文件和 TypeScript 内置的 lib.*.d.ts 不同,而且很多 ts 源码无法生产和 lib.dom.d.ts 相同的声明文件。

还用上面的 URLSearchParams 举例,在 TypeScript 内置的 lib.dom.d.ts 中,这个类型是这么定义的:

interface URLSearchParams {
   ...
}

declare var URLSearchParams: {
    prototype: URLSearchParams;
    new(init?: string[][] | Record | string | URLSearchParams): URLSearchParams;
    toString(): string;
};

而 ts 只能生成如下的声明文件:

declare class URLSearchParams {
    constructor(init?: string[][] | Record | string | URLSearchParams);
    toString(): string;
}

两者应该是等价的(我对这个不是特别熟,如果不等价,请告诉我)。

因为 .d.ts 文件的产生要比 es6 还要早,因此 lib.*.d.ts 里面都没有使用 class。

最终 Deno 也手动维护了 5 个 lib.*.d.ts 文件,上面的 URLSearchParams类型定义在 lib.deno.shared_globals.d.ts 中。此时如果我们再写 class URLSearchParams 则会覆盖掉 .d.ts 文件里面的类型定义。

目前 Deno 的大多数 web api 都使用的这种模式,定义 XxxxImpl实现 Xxxx接口,然后再重新设置 XxxxImpl 的 name 属性。

如果某个技术带来了 xxx 问题,要么我们在这个技术上解决 xxx 问题,要么换用一个没有 xxx 的问题的其它技术。这和当年 rust 从 go 换到 rust 一样,使用 go 会带来 double gc 问题(go gc 和 v8 gc),Deno 当时看似有 4 条路:解决 go gc,解决 v8 gc,换掉 go,换掉 v8。其实只有一条路,换掉 go。

现在 deno 又面临了类似的问题,ts 生产了性能问题的 js 代码,我们明明知道性能问题在哪儿,但是无法从 ts 层面解决它,于是我们选择了“手写 js 代码”。

而 deno/std 里面的 ts 没有这方面的性能问题,因此就没有必要把 ts 换成 js。但是 deno/std/hash 有其它方面的性能问题,Deno 的解决方式是使用 wasm 重写了此模块。

虽然 deno 的一些内部模块从 ts 改回了 js,这并不意味着 ts 不行了,只是说明 ts 在某些特定场景不太适合,也不要因此而全面否定 ts,对于大部分项目来说使用 ts 的收益还是很大的。

我觉得还有一些场景不太适合 ts:经常修改原型链的,需要运行时动态添加属性的,等等。

Deno 的性能问题是由于需要实现特定的规范,以及对 TypeScript 内置的 lib.dom.d.ts 的兼容需求,才导致的 ts 无法生成高性能代码。对于大多数项目而言,ts 生产的 js 代码还是非常优秀的。

你可能感兴趣的:(Deno 为什么把核心模块从 ts 改回了 js)