使用 Loader Hooks 让 Node 运行 TypeScript
发布时间:
最后更新:
随着 JavaScript 的项目越来越复杂,类型约束的优势就逐渐的体现了出来。放眼望去,现在的新工具 Deno、Bun、Vitest 等等,都把直接运行 TypeScript 文件作为卖点,唯有老态龙钟的 NodeJS 还在冥顽不灵。
于是咱来扶它一把,让 Node 也能直接运行 TS 文件。
本项目地址 https://github.com/Kaciras/ts-directly
需求场景 #
最近写了一个 Benchmark 框架 ESBench,Benchmark 跟单元测试差不多,为了让用户省一步构建,我决定让它直接运行 TS 源文件,就像各个单测框架一样。
更进一步,我希望它能保持轻量和兼容,最好别捆绑编译器,用户的装了哪个就用哪个,跟已有的技术栈整合。要知道 Vitest 开发的初衷就是为了和 Vite 搭配。
另外它还得快,所以新一代的编译器 SWC 和 esbuild 必须得支持,同时为了兼容性也要能使用 TypeScript 的 API 来转换代码,在运行时优先选择最快的。
方案分析 #
提到在 Node 里直接运行 TypeScript,首先就能找到两个库:ts-node 和 tsx,可惜的是它们不能完美的满足我的需求。
tsx 钦定了 esbuild 作为编译器,这直接就跟我的目标相悖。ts-node 不支持 esbuild 使其跟 Vite 那套体系不搭,同时它似乎不维护了,连 ESM 都不支持。
同时 tsx 和 ts-node 都是历史悠久的库,代码可不少,而本项目最终仅用 200 行(不含注释)就实现了目标。
还有前面提到的 Vite,它也能直接跑 TS 代码,但它的实现原理是先运行一次构建,把配置打包成临时文件然后导入,而本项目仅需要编译,用不着打包,所以不使用这种方案。
最后研究了一下发现前不久才进入 RC 阶段的特性:ESM Loader Hooks 正好能实现我的需求,于是又是自己动手,没轮子就造轮子!
ESM Loader Hooks #
早在 CommonJS 时代,就有了让 Node 运行 TS 的方法,即替换require
函数,把编译流程加入进去。但到了 ESM 纪元,由于import
不是函数而是一个关键字,没法去动它,同样的方案就不再可行。
为了让用户能够扩展import
,NodeJS 项目组提出的方案就是 Loader Hooks,它允许注册一个模块,其中导出仨函数,在import
的时候被调用,返回自定义的结果,API 长这样:
export async function initialize({ number, port }) {
// 在注册的时候调用,用于接收参数和初始化。
}
export async function resolve(specifier, context, nextResolve) {
// 解析导入的模块路径。
}
export async function load(url, context, nextLoad) {
// 加载代码,在这里可以把 TS 编译成 JS 后返回。
}
注册的代码是这样的:
import { register } from "module";
register("./上面的模块.js", import.meta.url);
// 注册完即生效,接下来就可以导入 TS 文件了。
await import("./xxx.ts");
看到这 API,一股亲切感就油然而生——这不跟 Rollup 的插件一样嘛,这些年我写了好多,对这玩意可太熟了。
核心流程 #
看完 Hooks API,基本上在加载钩子里做一下转换就行了,不过还是有一些细节需要完善,先来依次看看这几个函数吧。
决定编译选项 #
initialize
钩子是来接收参数的,之所以要有它是因为 Loader Hooks 运行在单独的线程中,参数不能直接传递而是得序列化。
不过本项目并不需要使用它,首先就没什么需要传递的参数;其次如果有,在更上层来看本项目是作为库被 ESBench 使用的,那么这些选项要写在哪里呢?
如果是写在配置文件,那么该文件也有可能是 TS 文件,但在 Loader Hooks 初始化完成前是无法直接加载 TS 文件的,这是个死循环,更何况配置会增加复杂性。
其它的方案是写在package.json
里(ts-node 就是这么搞的),或者靠环境变量,本项目的话可配置东西的不多,目前只想到一个指定编译器,这用环境变量就成。
在编译的时候倒是有参数需要确定,这里直接遵循 TypeScript 自己的约定就好:从tsconfig.json
里读。
解析模块 #
接下来是resolve
钩子,解析的意思就是将不同模块中的导入进行转换,其中某些导入对应的是同一个文件,比如:
- 在
a.js
里import "./c.js
- 在
dir/b.js
里import "../c.js
他俩导入的是同一个文件,所以resolve
的返回值应当相同,c.js
也只会加载一次并缓存下来。
在这个钩子里我们要做的就是当 JS 文件不存在时,再去尝试下对应的 TS 源文件是否存在。
之所以要这样是因为 TypeScript 不会转换导入,也就是说当你在 .ts
文件里导入 module.ts
这个文件的时候得写 import "./module.js"
而不是 import "./module.ts"
,如果是后者使用 tsc
构建后的代码是无法运行的。
export const resolve: ResolveHook = async (specifier, context, nextResolve) => {
try {
// 先调用 Node 内置的解析,成功就直接返回。
return await nextResolve(specifier, context);
} catch (e) {
// 如果失败且导入的是 JS 文件。
const isFile = /^(?:file:|\.{1,2}\/)/i.test(specifier);
const isJSFile = isFile && /\.[cm]?jsx?$/i.test(specifier);
if (!isJSFile || e.code !== "ERR_MODULE_NOT_FOUND") {
throw e;
}
// 则将文件扩展名中的 j 字母替换为 t 再次解析。
if (specifier.at(-1) !== "x") {
return nextResolve(specifier.slice(0, -2) + "ts", context);
} else {
return nextResolve(specifier.slice(0, -3) + "tsx", context);
}
}
};
转换代码 #
import { LoadHook, ModuleFormat } from "module";
export type CompileFn = (code: string, filename: string, isESM: boolean) => Promise<string>;
let compile: CompileFn;
export const load: LoadHook = async (url, context, nextLoad) => {
// 似乎是 Node 的 BUG,会丢失 importAttributes。
if (context.format === "json") {
context.importAttributes.type = "json";
return nextLoad(url, context);
}
// 非 TS 文件咱不处理。
const match = /\.[cm]?tsx?$/i.exec(url);
if (!match || !url.startsWith("file:")) {
return nextLoad(url, context);
}
// 调用 Node 自己的加载函数,设置自定义的 format 可避免文件内容被处理。
context.format = "ts" as any;
const ts = await nextLoad(url, context);
const code = ts.source!.toString();
const filename = fileURLToPath(url);
// 根据文件名和 package.json 的 type 属性判断文件是 CJS 还是 ESM。
let format: ModuleFormat;
switch (match[0].charCodeAt(1)) {
case 99: /* c */
format = "commonjs";
break;
case 109: /* m */
format = "module";
break;
default: /* t */
// 这个函数就不写了,去源码里看吧。
format = getPackageType(filename);
}
// 加载编译器,最后返回编译后的代码。
compile ??= await detectTypeScriptCompiler();
return {
shortCircuit: true,
format,
source: await compile(code, filename, format === "module"),
};
};
load
钩子就是编译 TypeScript 到 JavaScript 的地方,在编译之前,还需要获取两个信息:
-
文件对应的
tsconfig.json
,这个有库 tsconfck 可以做,它足够轻量 (4.7 KB gzip,无依赖)。 -
文件的类型是 ESM 还是 CJS,这可以通过文件的扩展名判断,
.cts
是 CJS 模块而.mts
是 ESM,如果扩展名是.ts
的话就看最近的package.json
文件里的type
属性,不存在则默认为 CJS。
然后是导入编译器,可以通过import()
来尝试加载,记得把编译器添加到peerDependencies
里哦。
// 三个编译函数就省略了,里头就是转换下选项然后编译而已。
async function swcCompiler() {
const swc = await import("@swc/core");
return code => { /* 转换 code 为 JS 代码 */};
}
async function esbuildCompiler() {
const esbuild = await import("esbuild");
return code => { /* 转换 code 为 JS 代码 */};
}
async function tsCompiler() {
const { default: ts } = await import("typescript");
return code => { /* 转换 code 为 JS 代码 */};
}
// 速度快的在前面
export const compilers = [swcCompiler, esbuildCompiler, tsCompiler];
async function detectTypeScriptCompiler() {
// 如果设置了环境变量,就用指定的编译器。
const name = process.env.TS_COMPILER;
if (name) {
const i = ["swc", "esbuild", "tsc"].indexOf(name);
return compilers[i]();
}
// 否则挨个尝试支持的。
for (const create of compilers) {
try {
return await create();
} catch (e) {
if (e.code !== "ERR_MODULE_NOT_FOUND") throw e;
}
}
throw new Error("No TypeScript transformer found");
}
其它事项 #
编译参数优化 #
虽然说本项目从 tsconfig.json
读取编译选项,但针对特殊场景还是可以做一些调整的:
- 开启 Source Map,因为不写文件所以还得是内联的,但源码无需内联进去因为源文件是存在的。
- 移除注释节省体积,毕竟编译的结果直接就执行了,根本看不到,所以注释无用。
- 但是不要压缩代码,因为不仅会让编译时间增加,还会使 Source Map 的精度下降。
性能测试 #
在我的笔记本上编译 storybook v8.1.1,1468 个文件,代码 benchmark/loader.ts
编译器 | 结果大小 | 结果大小(对比) | 编译用时 | 编译用时(对比) |
---|---|---|---|---|
SWC | 9.36 MiB | 0.00% | 355.13 ms | 0.00% |
esbuild | 8.98 MiB | -4.09% | 398.08 ms | +12.10% |
tsc | 9.38 MiB | +0.18% | 5,028.82 ms | +1316.07% |
可以看出速度 SWC > esbuild >> tsc。
还在实验中 #
最后需要注意的一点是 ESM Loader Hooks 还在 Release candidate 阶段,仍然有一些 BUG,在写本项目时就遇到了:
context.importAttributes
某些情况下为空,即使导入的时候有指定。- 当
require
一个不存在的文件时,resolve
钩子不触发。
这些问题只在很罕见的情况下才触发,影响不大,而且以后肯定会解决的。
总结 #
通过使用最新的 API,仅 200 行代码就搞定了需求,比现有的库都小。
本项目虽然不自带编译器,但一个项目里存在 TS 文件,那么绝大多数情况都安装了typescript
这个依赖,此时本项目开箱即用。如果要在项目之外运行,或者 typescript
是全局安装的,那也只需要再装一个 @swc/core
或 esbuild
,非常简单。