调研相关方案
首先先调研一下需要用到的相关技术。
cjs
require.extensionsvm
esm
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 的优点
- 方便,只要将对应的
transform附加至extensions对象中即可 resolve是node帮忙做的,也就是说,我们执行require('./aaa.ts')时,它会帮助我们寻找到aaa.ts文件并转换为绝对路径,并以参数的方式传递给回调函数- 通过手动调用
module._compile可同步可异步的方式去编译源代码(需要注意的是不要执行多次) - 可以直接通过
require加载添加了extensions的文件即可引入,并非其它方案需要通过入口文件路径才能启动
缺点
resolve无法自行处理,require所指向的文件必须在真实的文件系统存在,否则会报错,该情况下无法触发require.extensions中注册的hook
vm
首先 vm 可以达到使用 eval() 一样执行代码,而我们要使用的 runInNewContext 与 eval 的区别是它无法访问外部作用域,但是它提供了一个方法,使得我们可以通过传递一个对象注入一个作用域,可以称它为 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');
优点
- 可控性高
缺点
- 需要手动的
resolve,load,需要处理并注入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
需要注意的是
- 可以通过
register注册多个hook- 钩子是独立线程的
initialize
初始化函数,当 hook 被 register 注册时就会执行该函数, initialize 的参数就是通过 register 第二位参数中的 data 传递的
resolve
resolve 有三个参数,分别是
specifier:import填写的urlcontext: 一个对象,包含解析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返回的urlcontext: 一些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;
}
resolve 和 load 分别有一个默认的 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(推荐环节)
学习了怎么能不用呢,所以我基于以上方案和 farm 开发出来一个执行器,可以直接执行 ts/js/html 文件
欢迎大家使用、提 issues、pr 以及麻烦点个 star