如何编写一个可以直接执行 js,ts 的执行器(cli)

95 阅读4分钟

调研相关方案

首先先调研一下需要用到的相关技术。

cjs

  1. require.extensions
  2. vm

esm

  1. module.register

来看一下它们的使用方法

require.extensions

const { readFileSync } = require('fs');

require.extensions['.ts'] = (module, filename) => {
	const code = readFileSync(filename, 'utf-8');

	// transform

	// async
	setTimeout(() => {
		module._compile(`console.log("hello world")`, filename);
	}, 1000);
	// sync
	module._compile(`console.log("hello world")`, filename);
};
require('./aaa.ts');

require.extensions 的优点

  1. 方便,只要将对应的 transform 附加至 extensions 对象中即可
  2. resolvenode 帮忙做的,也就是说,我们执行 require('./aaa.ts') 时,它会帮助我们寻找到 aaa.ts 文件并转换为绝对路径,并以参数的方式传递给回调函数
  3. 通过手动调用 module._compile 可同步可异步的方式去编译源代码(需要注意的是不要执行多次)
  4. 可以直接通过 require 加载添加了 extensions 的文件即可引入,并非其它方案需要通过入口文件路径才能启动

缺点

  1. resolve 无法自行处理,require 所指向的文件必须在真实的文件系统存在,否则会报错,该情况下无法触发 require.extensions 中注册的 hook

vm

首先 vm 可以达到使用 eval() 一样执行代码,而我们要使用的 runInNewContexteval 的区别是它无法访问外部作用域,但是它提供了一个方法,使得我们可以通过传递一个对象注入一个作用域,可以称它为 ctx

在内部找不到变量时,会尝试从 ctx 中寻找(变量名为 key

const vm = require('node:vm');
function executeModule(_filename, importer) {
    const code = `console.log('hello world');\nconsole.log(require)`;
    const _ctx = vm.createContext({});
    const proxy = new Proxy(_ctx, {
        get(target, p, receiver) {
            if (p in target) {
                return target[p];
            }
            if (p in globalThis) {
                return globalThis[p];
            }
            if (p === 'require') {
                return require;
            }
        },
    });
    vm.runInNewContext(code, proxy);
}
executeModule('./aaa.ts');

因为转换后的代码可能也需要 require,所以这里我们还需要通过 proxy 来返回一个包装的 require, 这个包装的 require 通过执行对应文件的代码,并返回代码执行后附加到 module.exports 上的数据

const { readFileSync } = require('node:fs');
const path = require('node:path');
const m = require('node:module');
const vm = require('node:vm');

function getAbsolutePath(filename, importer) {
    return path.isAbsolute(filename) ? filename : importer ? path.join(importer, filename) : path.join(process.cwd(), filename);
}

function executeModule(_filename, importer) {
    const filename = getAbsolutePath(_filename, importer);
    const code = readFileSync(filename, 'utf-8');

    // transform code

    const fileModule = new m.Module(filename);

    const r = id => {
        return executeModule(id, filename);
    };

    for (const key of ['cache', 'resolve', 'extensions']) {
        r[key] = require[key];
    }

    const _ctx = vm.createContext({
        module: fileModule,
        exports: fileModule.exports,
        __filename: filename,
        __dirname: path.dirname(filename),
        require: r,
        arguments: {
            0: fileModule.exports,
            1: r,
            2: fileModule,
            3: filename,
            4: path.dirname(filename),
            length: 5,
        },
    });

    const proxy = new Proxy(_ctx, {
        get(target, p) {
            if (p in target) {
                return target[p];
            }

            if (p in globalThis) {
                return globalThis[p];
            }
        },
    });

    vm.runInNewContext(code, proxy);

    fileModule.loaded = true;

    return fileModule.exports;
}

executeModule('./c.js');

优点

  • 可控性高

缺点

  • 需要手动的 resolveload,需要处理并注入 require 方法,需要模拟 require 的执行

以上就是执行 cjs 格式代码的一些方法

module.register

该方法是 node 给出的一个类似 require.extensions 的方法,它需要我们通过 cli 的参数指定一个文件,此文件可以调用 register 来注册通过 esm 的方式导出三个 hook 的文件,后续则在某个时机点触发导出的 hook, 比如 intialize(初始化)、resolve(索引)、load(根据索引的路径加载代码)

register

// register.js
const m = require("node:module");
const path = require("node:path");

m.register("./hook.js", {
	parentURL: path.pathToFileURL(__filename),
	// 传递给 initialize 的参数
	data: { number: 1 },
	transferList: []
});
node --import ./register.js

需要注意的是

  1. 可以通过 register 注册多个 hook
  2. 钩子是独立线程的

initialize

初始化函数,当 hookregister 注册时就会执行该函数, initialize 的参数就是通过 register 第二位参数中的 data 传递的

resolve

resolve 有三个参数,分别是

  • specifier: import 填写的 url
  • context: 一个对象,包含解析 import 得到的相关信息
  • nextResolve: 一个函数,执行后将信息传递给下一个通过 register 注册的 resolve 钩子
// ...
return nextResolve(specifier, {
  ...context,
  conditions: [...context.conditions, 'another-condition'],
});
// ...

resolve 可以通过返回一个确定的值,交给 load hook 去加载代码

return {
  shortCircuit: true,
  url: parentURL ?
	new URL(specifier, parentURL).href :
	new URL(specifier).href,
};

接下来就是 load

load

load 有三个参数

  • url: resolve hook 返回的 url
  • context: 一些 resolve 返回的 context 参数
  • nextLoad: 一个函数,执行后交给下一个通过 register 注册的 load 钩子

load 也可以通过 return 一些数据返回加载代码的结果,如下

// ...
return {
	// 此次加载的结果类型是什么
	format: 'builtin' | 'commonjs' | 'json' | 'module' | 'wasm',
	// 是否将该返回作为结果
	shortCircuit: boolean,
	source: string | ArrayBuffer | TypedArray,
}
// ...
export function initialize() {}

export function resolve(specifier, context, nextResolve) {
    return nextResolve(specifier, context);
}

export async function load(url, context, nextLoad) {
    const result = await nextLoad(url, context);

	// transform
    result.source = `console.log("hello world")`;

    return result;
}

resolveload 分别有一个默认的 hook,所以在某些情况下我们可以选择执行 next 即可(或者不导出)

如何做 transform

现在比较流行的执行器如 tsx/ts-node/@swc-node/register 都各有不同,tsx 使用 esbuild,ts-node 使用 typescript@swc-node/register 使用 @swc/core

应该做 transform 的位置也在上面的代码中用注释标注了,可以直接引入 babel/swc/esbuild 等编译库的 transform 来转换加载的源码后即可运行

farmup(推荐环节)

farmup 地址

学习了怎么能不用呢,所以我基于以上方案和 farm 开发出来一个执行器,可以直接执行 ts/js/html 文件

欢迎大家使用、提 issuespr 以及麻烦点个 star

reference