tsimp 😈
Node.js 的 TypeScript 导入加载器
简介
这是一个使用微软官方 TypeScript 实现来运行用 TypeScript 编写的 Node.js 程序的导入器。
它旨在提供完整的类型检查支持,并在重复使用时(例如,在生成多个 TS 进程的测试套件中)具有可接受的性能。
为什么需要它
市面上有很多 TypeScript 加载器和编译器!你应该选择哪一个,为什么我需要创建这个呢?
- swc 是用 Rust 实现的 TypeScript 编译器
- tsx 是一个零配置的 TypeScript 执行器,旨在成为 node 的直接替代品,由 esbuild 提供支持。
- ts-node 可能是其中最成熟的,具有庞大的功能集,支持你可能需要的所有 node 和 TypeScript 版本。
tsimp 的不同之处:
- 它使用微软的 TypeScript 实现作为编译器。对 swc 和 esbuild 没有不敬之意,它们快速且功能强大,但
tsimp
的目标是与"官方"tsc
程序严格一致,而直接使用它是最简单的方法。 - 它支持 node v20.6 中添加的
--import
和Module.register()
行为,只在不可用时才退回到带有警告的实验性 API。 - 默认启用类型检查,因此无需在运行测试后再执行额外的
tsc --noEmit
步骤,使用持久的 sock daemon 和大量缓存来提高性能。 - 它只是一个模块加载器,而不是其他东西的集合。所以没有 repl,没有打包器等。它基本上只做一件事:让 TypeScript 模块在 Node 中工作。
使用方法
使用 npm 安装 tsimp
:
npm install tsimp
在 node v20.6 及更高版本中这样运行 TypeScript 程序:
node --import=tsimp/import my-typescript-program.ts
或在 v20.6 之前的 Node 版本中这样运行:
node --loader=tsimp/loader my-typescript-program.ts
你也可以使用 tsimp
作为可执行文件来运行你的程序(但 import/loader 方式快约 100ms,因为它不会产生额外的 spawn
调用):
tsimp my-typescript-program.ts
注意,虽然不带参数运行 tsimp
将启动 Node repl,并且在该上下文中它能够导入/require TypeScript 模块,但它并不包含可以直接运行 TypeScript 的 repl。这只是一个导入加载器。
在 Node v20.6 及更高版本中,你还可以在程序中加载 tsimp
,之后 TypeScript 模块就可以直接使用了。
注意,import
声明在代码执行之前并行发生,所以你需要像这样拆分:
import 'tsimp'
// 必须作为异步 import() 完成,以便在 tsimp 导入完成后进行。
// 但 TypeScript 程序中的任何导入都可以是"正常"的顶级导入。
const { SomeThing } = await import('./some-thing.ts')
相比之下,这样是不行的,因为导入是并行发生的:
import 'tsimp'
import { SomeThing } from './some-thing.ts'
CommonJS 的 require()
也被修补了。要在 CommonJS 程序中使用 tsimp
,你可以按上述方式运行它,或在程序中 require()
它。
//commonjs
require('tsimp')
// 现在可以加载 TypeScript
require('./blah.ts')
在 Node 20.6 及更高版本中,这也会附加 ESM 导入支持所需的加载器。在早期 Node 版本中,你必须使用 --loader=tsimp/loader
来支持 ESM。
配置
大多数配置是通过查找模块入口点所在或其上级文件夹树中最近的 tsconfig.json
文件来完成的。
你可以通过在环境中设置 TSIMP_PROJECT=<filename>
来使用不同的文件名。
如果 tsconfig json 文件中有 tsimp
字段,那么它将覆盖文件中的其他任何内容。例如:
{
"compilerOptions": {
"rootDir": "./src",
"declaration": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"inlineSources": true,
"jsx": "react",
"module": "nodenext",
"moduleResolution": "nodenext",
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"skipLibCheck": false,
"sourceMap": false,
"strict": true,
"target": "es2022"
}
"tsimp": {
"compilerOptions": {
"skipLibCheck": true,
"strict": false
}
}
}
使用 tsimp
时始终启用源映射,以便错误引用 TypeScript 代码中的适当调用位置。
配置文件更改和 extends
选项
如果 tsimp 使用的 tsconfig.json
文件发生更改,它将自动使其内存和磁盘缓存过期,因为新选项可能会导致非常不同的结果。
然而,虽然完全支持 extends
(如果 tsc
可以加载它,tsimp 也可以,因为这就是它加载配置的方式),但任何扩展的配置文件都不会被跟踪变化或导致缓存过期。
如有疑问,tsimp --restart
将根据需要重新加载所有内容。
"module"
、"moduleResolution"
和其他必需项
tsimp 的最终模块样式必须是 Node 可理解的,无需任何额外的打包或转译。
为此,无论 tsconfig.json
中的设置如何,module
和 moduleResolution
设置都在 tsimp 中硬编码为 NodeNext
。
此外,tsimp 总是硬编码以下字段:
outDir
因为 tsimp 不是构建工具,而是模块导入器,它实际上不会将生成的 JavaScript 写入磁盘。(好吧,从技术上讲它确实写入了,但只是作为缓存。)所以,outDir
被硬编码为.tsimp-compiled
,但这从未被使用。sourceMap
总是设置为undefined
,因为:inlineSourceMap
总是设置为true
。将源映射内联到生成的 JavaScript 输出中要简单和快速得多。inlineSources
总是设置为false
。当输入肯定存在于磁盘上时,没有必要增加输出的大小。declarationMap
和declaration
总是设置为false
,因为类型声明是无关紧要的。noEmit
总是设置为false
,因为整个目的是获取 Node 运行的 JavaScript 代码。话虽如此,"emit" 是完全虚拟的,不会向磁盘写入任何内容(除非为了避免多次编译相同的代码)。
文件扩展名、模块解析等
使用tsimp
时,文件扩展名、模块解析和其他方面的规则与使用tsc
时相同。
这意味着:如果你在ESM模式下运行,即使磁盘上的实际文件是.ts
,你也需要将导入语句的文件名以.js
结尾,因为当module
设置为"NodeNext"
且目标方言是ESM时,TypeScript就是这样处理的。
编译诊断
设置TSIMP_DIAG
环境变量来控制编译诊断出现时的行为。
TSIMP_DIAG=warn
(默认)将诊断信息打印到stderr
,但如果可能的话仍会转译代码。TSIMP_DIAG=error
将诊断信息打印到stderr
,如果有任何诊断信息就会失败。TSIMP_DIAG=ignore
仅转译代码,忽略所有诊断信息。(类似于ts-node的TS_NODE_TRANSPILE_ONLY=1
选项。)
它有多快?
如果守护进程正在运行,即使启用了类型检查,它也非常快。如果守护进程正在运行,并且之前已经编译过你正在运行的文件,它会快得惊人,快到你会觉得它是不是坏了,甚至超过了用Rust和Go编写的TypeScript编译器,因为它实际上只需要检查一些文件状态,然后将缓存的结果传给Node。(事实上,由于它在内存和磁盘上都进行缓存,在许多情况下可能甚至比运行普通的JavaScript还要快,特别是如果程序很大的话。)
而且,这是在进行完整的类型检查的情况下,这也是使用TypeScript的意义所在。无论你的编译器有多快,如果你随后还要运行tsc --noEmit
来检查类型,那实际上并没有获得多少好处。
如果守护进程没有运行,而且是一次冷启动且没有缓存,那它会相当慢,特别是在启用类型检查的情况下,速度与ts-node相当。
下面是一个非常不科学的对比示例:
$ time node --loader @swc-node/register/esm hello.ts (node:89220) ExperimentalWarning: `--experimental-loader`可能在未来被移除;请改用`register()`: --import 'data:text/javascript,import { register } from "node:module"; import { pathToFileURL } from "node:url"; register("%40swc-node/register/esm", pathToFileURL("./"));' (使用`node --trace-warnings ...`来显示警告的创建位置) hello, world real 0m0.268s user 0m0.255s sys 0m0.033s $ time node --import=tsx hello.ts hello, world real 0m0.135s user 0m0.126s sys 0m0.020s $ time node --import=./dist/esm/hooks/import.mjs hello.ts hello.ts:2:18 - 错误 TS2322: 类型"string"不能赋给类型"boolean"。 2 const f: Foo = { bar: 'hello' } ~~~ hello.ts:1:14 1 type Foo = { bar: boolean } ~~~ 预期的类型来自此处声明的"Foo"类型的"bar"属性 hello, world real 0m0.126s user 0m0.110s sys 0m0.022s
它为什么这么快?
基本的缓存和工作跳过。