使用 Loader Hooks 让 Node 运行 TypeScript

发布时间:

最后更新:

随着 JavaScript 的项目越来越复杂,类型约束的优势就逐渐的体现了出来。放眼望去,现在的新工具 Deno、Bun、Vitest 等等,都把直接运行 TypeScript 文件作为卖点,唯有老态龙钟的 NodeJS 还在冥顽不灵。

于是咱来扶它一把,让 Node 也能直接运行 TS 文件。

本项目地址 https://github.com/Kaciras/ts-directly

需求场景 #

最近写了一个 Benchmark 框架 ESBench,Benchmark 跟单元测试差不多,为了让用户省一步构建,我决定让它直接运行 TS 源文件,就像各个单测框架一样。

更进一步,我希望它能保持轻量和兼容,最好别捆绑编译器,用户的装了哪个就用哪个,跟已有的技术栈整合。要知道 Vitest 开发的初衷就是为了和 Vite 搭配。

另外它还得快,所以新一代的编译器 SWCesbuild 必须得支持,同时为了兼容性也要能使用 TypeScript 的 API 来转换代码,在运行时优先选择最快的。

方案分析 #

提到在 Node 里直接运行 TypeScript,首先就能找到两个库:ts-nodetsx,可惜的是它们不能完美的满足我的需求。

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 长这样:

javascript
export async function initialize({ number, port }) {
  // 在注册的时候调用,用于接收参数和初始化。
}

export async function resolve(specifier, context, nextResolve) {
  // 解析导入的模块路径。
}

export async function load(url, context, nextLoad) {
  // 加载代码,在这里可以把 TS 编译成 JS 后返回。
} 

注册的代码是这样的:

javascript
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钩子,解析的意思就是将不同模块中的导入进行转换,其中某些导入对应的是同一个文件,比如:

他俩导入的是同一个文件,所以resolve的返回值应当相同,c.js也只会加载一次并缓存下来。

在这个钩子里我们要做的就是当 JS 文件不存在时,再去尝试下对应的 TS 源文件是否存在。

之所以要这样是因为 TypeScript 不会转换导入,也就是说当你在 .ts 文件里导入 module.ts 这个文件的时候得写 import "./module.js" 而不是 import "./module.ts",如果是后者使用 tsc 构建后的代码是无法运行的。

typescript
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);
		}
	}
};

转换代码 #

typescript
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 的地方,在编译之前,还需要获取两个信息:

  1. 文件对应的 tsconfig.json,这个有库 tsconfck 可以做,它足够轻量 (4.7 KB gzip,无依赖)。

  2. 文件的类型是 ESM 还是 CJS,这可以通过文件的扩展名判断,.cts 是 CJS 模块而 .mts 是 ESM,如果扩展名是 .ts 的话就看最近的 package.json 文件里的 type 属性,不存在则默认为 CJS。

然后是导入编译器,可以通过import()来尝试加载,记得把编译器添加到peerDependencies里哦。

typescript
// 三个编译函数就省略了,里头就是转换下选项然后编译而已。
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 读取编译选项,但针对特殊场景还是可以做一些调整的:

性能测试 #

在我的笔记本上编译 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,在写本项目时就遇到了:

这些问题只在很罕见的情况下才触发,影响不大,而且以后肯定会解决的。

总结 #

通过使用最新的 API,仅 200 行代码就搞定了需求,比现有的库都小。

本项目虽然不自带编译器,但一个项目里存在 TS 文件,那么绝大多数情况都安装了typescript 这个依赖,此时本项目开箱即用。如果要在项目之外运行,或者 typescript 是全局安装的,那也只需要再装一个 @swc/coreesbuild,非常简单。

评论加载中