加速 JavaScript 生态系统 - 隔离声明


TypeScript 的新隔离声明功能是开发者之间共享代码的变革性工具。它大大简化了打包代码以供使用的过程,同时将创建类型定义文件的时间从几分钟甚至几小时缩短到不到一秒。

许多人可能并不知情,但在 TypeScript 5.5 中推出的新隔离声明功能比你想象的要重要得多。它彻底改变了我们打包和分发 JavaScript 代码的方式。你不再需要通过调用tsc编译器手动创建*.d.ts文件。”跳转到源代码”(即在 macOS 上按住 ctrl+点击cmd+点击)现在实际上可以直接带你到TypeScript源代码,而不是*.d.ts文件或一些编译后的 JavaScript 代码。此外,它使发布包的速度比以往任何时候都要快。

它是如何做到的呢?

2024 年 npm 的打包混乱现状

坦白说,这是一团糟,没有必要粉饰这一点。你需要考虑 CommonJSESM 之间的区别,处理一堆设置以使*.d.ts文件正常工作,等等。相比于处理所有打包问题,选择在项目之间复制和粘贴文件,这要省事得多。

打包代码应该像上传文件一样简单。打包和共享代码应该如此简单,以至于每个开发者,无论其经验水平如何,都能做到。

即使对于有经验的开发者来说,创建可以在每个环境中使用的npm包也是一个挑战。Redux 团队的 Mark Erikson 写了一篇跨越多页的博客文章,讲述了他创建npm包的经验。问题在于,打包和在开发者之间共享代码应该是简单的。开发者不应该需要阅读十几篇博客文章并成为所有这些工具内部工作原理的专家,才能共享代码。它应该是即插即用的。

为了应对这种情况,出现了各种 CLI 工具,但这只增加了另一个冲突点。你有工具 A 可以解决部分问题,然后你有工具 B 只处理另一个问题,等等。不久之后,你就需要跟上使用哪个工具是正确的,直到它被另一个工具取代。这很糟糕。

那么,我们如何解决所有这些问题呢?

为什么生成 .d.ts 文件需要这么长时间

问题的核心在于架构。我们只会在包中发布构建产物,即编译后的 JS 和相关的 .d.ts 文件。发布原始的 TypeScript 源文件会好得多,但几乎没有任何工具在 JS 生态系统中与之配合使用。工具中普遍存在的假设是 node_modules 文件夹中的内容不需要编译。发布TypeScript源代码而不是 .d.ts 文件在使用 TypeScript 时也会带来性能上的倒退。

解析.d.ts文件要快得多,因为它们只包含类型检查所需的部分,而不像普通的 .ts 文件那样包含函数体或其他内容。一个.d.ts文件只包含使用模块时所需的类型定义。

1
2
3
4
5
6
7
8
// 输入:add.ts
const SOME_NUMBER = 10;
export function quickMath(a: number, b: number) {
return a + b + SOME_NUMBER;
}

// 输出:add.d.ts,只包含类型检查所需的部分
export function quickMath(a: number, b: number): number;

通常情况下,函数的返回类型是不确定的。TypeScript 编译器必须首先遍历和检查整个函数体才能推断出返回类型。推断在性能方面是非常昂贵的,尤其是对于复杂的函数。因此,创建.d.ts文件的目标是去掉所有推断,这样TypeScript编译器只需要读取这些文件而不需要额外的工作。类型推断是创建这些.d.ts文件所需时间长的主要原因。而且由于这个过程需要很长时间,我们尝试在创建 npm 包时尽可能提前进行。

隔离声明带来的变革

隔离声明的理念是让 TypeScript 编译器之外的工具生成这些.d.ts文件变得非常简单。它通过要求导出的函数和其他内容具有显式返回类型来实现这一点。但不用担心,如果一个类型很复杂,TypeScript 语言服务器有一个方便的功能可以为你推断出缺失的类型。

通过将过程转变为仅仅是语法剥离,创建 .d.ts 文件的时间非常接近于 0 秒。使用一个专门的基于Rust的解析器,即使对于大型项目,这也可以在眨眼之间完成,因为这项工作现在可以并行化。它快到你几乎察觉不到。没有隔离声明,你总是需要调用 tsc 编译器并运行一个完整的类型检查过程。

由于生成定义文件的成本几乎为零,因此在需要时即时生成这些文件的速度已经足够快。我们可以彻底颠覆发布过程:不再需要提前处理构建 .d.ts 文件并将它们发布到 npm,你可以直接上传你的 TypeScript 源代码,并且在你安装包时会自动生成定义文件。生成 .d.ts 文件的过程不在发布包时进行,而是在你安装包时进行。

这也改变了你声明包的可用条目的方式。以前,在npm中,你必须引用几个构建产物才能使其正常工作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// package.json
{
"name": "@my-scope/my-package",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"import": "./dist/index.js"
},
"./foo": {
"types": "./dist/types/foo.d.ts",
"import": "./dist/foo.js"
}
}
}

基于隔离声明的系统更简单:只需直接引用你的源文件。

1
2
3
4
5
6
7
8
9
// jsr.json
{
"name": "@my-scope/my-package",
"version": "1.0.0",
"exports": {
".": "./src/index.ts",
"./foo": "./src/foo.ts"
}
}

将包的条目指向实际的源文件感觉更自然,因为它免去了你对打包产物的担忧。

在生产环境中的使用

当然,从理论上谈论这些事情是很好,但在生产环境中使用这样的功能感觉如何呢?隔离声明的核心理念在 TypeScript 问题跟踪器中多次浮现,但最终实现需要一些时间和许多聪明的头脑。

在你的编辑器中,“转到源代码”之类的功能开箱即用,实际上会将你带到源代码,而不是某个随机的定义文件。

我们意识到拥有这样一个系统的实用性,并使其不仅对 Deno 用户有效,对所有人都有效。你可以与 npmyarnpnpm 甚至bun一起使用它。JSR 注册表通过实现 npm 安装包的协议,与这些包管理器通信时,只是充当另一个 npm 注册表。从包管理器的角度来看,它与任何你可能在公司中使用的私有 `npm 注册表没有什么不同。

由于 npm 客户端没有即时生成定义文件的系统,我们在发布过程中为 npm 压缩包幕后生成这些文件。因此,npm 压缩包附带标准的编译后.js文件,就像你在其他项目中习惯的那样。整个 npm 生态系统都基于发布 .js 文件,改变这一点会破坏生态系统。这里的要点是不要只向 npm 发布TypeScript文件。

但如果你使用可以原生运行TypeScript的运行时,并使用可以分发原始 TypeScript 源代码的注册表,那么隔离声明将是一个变革性的工具。

结论

隔离声明彻底改变了发布的规则。它使生成定义文件的过程几乎零成本,这为即时生成定义文件打开了大门。这大大简化了发布过程,只需上传源文件,速度快得多。目前,JSR 是第一个也是唯一一个利用这一点的注册表,它可以与任何包管理器和任何运行时一起使用。简而言之,JSR 旨在为那些觉得生命太短,不想处理所有打包问题,只想分享代码的人服务。试试看,你会发现其中的妙处。