IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    使用 Loader Hooks 让 Node 运行 TypeScript

    Kaciras 的博客发表于 2024-06-14 13:48:54
    love 0

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

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

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

    1. 需求场景
    2. 方案分析
    3. ESM Loader Hooks
    4. 核心流程
      1. 决定编译选项
      2. 解析模块
      3. 转换代码
    5. 其它事项
      1. 编译参数优化
      2. 性能测试
      3. 还在实验中
    6. 总结

    需求场景 #

    最近写了一个 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 长这样:

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

    • 在 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 构建后的代码是无法运行的。

    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 读取编译选项,但针对特殊场景还是可以做一些调整的:

    • 开启 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,非常简单。



沪ICP备19023445号-2号
友情链接