简介
esno 自己的介绍是 Node.js runtime enhanced with esbuild for loading TypeScript & ESM。意思就是一个用 esbuild 加强过的 node 的运行时。可以用来和直接执行 ts 的代码。现在的 esno 是一个 tsx 的别名,他的文件直接引用了 import('tsx/cli')。 内部的逻辑都存在于 tsx 这个文件中,本文就是介绍一个 tsx 是如何直接执行 ts 代码的。
前置知识
首先来介绍一个 node --loader参数,这个参数允许我们自定义加载 ESM 模块的规则。 执行 node --loader ./my-loader.mjs index.mjs。在加载 index.mjs 的时候,就会先去执行 my-loader.mjs 里面的内容。如果书写 my-loader.js 呢。node 内置了两个 hooks,分别是 resolve 和 load 可以在 import 的时候被执行(注:这两个 hooks 仅仅对 ESM 的模块生效,对 CJS 无效)。
下面来介绍两个 hooks 的用法:
resolve
The
resolvehook chain is responsible for resolving file URL for a given module specifier and parent URL, and optionally its format (such as'module') as a hint to theloadhook. If a format is specified, theloadhook is ultimately responsible for providing the finalformatvalue (and it is free to ignore the hint provided byresolve); ifresolveprovides aformat, a customloadhook is required even if only to pass the value to the Node.js defaultloadhook。
我的理解就是 resolve 函数可以让我们拿到文件名和文件 format 的信息。我们可以改变传入的模块的信息,然后返回回去,返回的信息会交给 load 这个 hooks 来执行。
export async function resolve(specifier, context, nextResolve) {
const { parentURL = null } = context;
if (Math.random() > 0.5) { // Some condition.
// For some or all specifiers, do some custom logic for resolving.
// Always return an object of the form {url: <string>}.
return {
shortCircuit: true,
url: parentURL ?
new URL(specifier, parentURL).href :
new URL(specifier).href,
};
}
if (Math.random() < 0.5) { // Another condition.
// When calling `defaultResolve`, the arguments can be modified. In this
// case it's adding another value for matching conditional exports.
return nextResolve(specifier, {
...context,
conditions: [...context.conditions, 'another-condition'],
});
}
// Defer to the next hook in the chain, which would be the
// Node.js default resolve if this is the last user-specified loader.
return nextResolve(specifier);
}
执行 node --loader ./loader.mjs index.mjs 调试的参数结果如图:
specifier 就是待执行文件的路径,context 记录了 parentURL 和 可以导入的 conditions 的规则。nextResolve 就是写一个 resolve 的 函数。如果没有的话就是默认的。返回的结构
{
format: 'commonjs' | 'module' | 'wasm',
shortCircuit: boolean, // default false 是否结束 resolve hooks
url: string; // 文件的 URL 我们可以在里面处理文件原来的 URL 。
}
load
The
loadhook provides a way to define a custom method of determining how a URL should be interpreted, retrieved, and parsed. It is also in charge of validating the import assertion
这个 hook 决定了一个文件的 url 如何被检索和解析。
我们观察 index.mjs 和 loader.mjs。看到的现象是我们可以在返回的 source 中添加上自己内容,然后 node 在执行代码的时候,也会把我们添加上的内容带进去。如果我们在这里用一些工具将 ts 的代码变成 js 的代码,是不是就可以执行了呢!! tsx 就是这么做的。后面的文章会说明。
cjs 的 loader
上面介绍了一些 ESM 的 hooks。下面再介绍一下如何在 CJS 里面实现上面 load 的功能。
下面的内容来源于阮一峰的博客
// 模块的加载
Module.prototype.load = function(filename) {
var extension = path.extname(filename) || '.js';
if (!Module._extensions[extension]) extension = '.js';
Module._extensions[extension](this, filename);
this.loaded = true;
};
// 调用不同后缀名的解析方法
Module._extensions['.js'] = function(module, filename) {
var content = fs.readFileSync(filename, 'utf8');
module._compile(stripBOM(content), filename);
};
Module._extensions['.json'] = function(module, filename) {
var content = fs.readFileSync(filename, 'utf8');
try {
module.exports = JSON.parse(stripBOM(content));
} catch (err) {
err.message = filename + ': ' + err.message;
throw err;
}
};
// 最后调用 _compile 方法来编译我们的模块。
Module.prototype._compile = function(content, filename) {
var self = this;
var args = [self.exports, require, self, filename, dirname];
return compiledWrapper.apply(self.exports, args);
};
观察上面的例子,我们实现了和上面 ESM 一样的
注入我们多余代码的逻辑
除了 load Module也有类似于 resolve 的功能。
我们也可以拓展 ___resolveFilename 来实现类似于 上面的 resolve 的功能。
tsx && esno
其实写到这里一般的读者应该猜到了。tsx && esno 就是拦截了 "load" 函数,然后里面用 esbuild 将代码 transform 一下。变成了 js 然后给 node 执行,就可以执行了。下面来分析一个 tsx 的实现过程。
分析 src 下面的 cli.ts 和 watchCommand 里面的文件。大致的意思就是接受命令行里面的参数,将参数收集起来,然后交给 run 函数去执行。run 函数的内容就是
run 函数之后也是去执行 node 的命令,然后只用 --loadr 的命令来导入我们自定义的 loader。
loader.js
require('@esbuild-kit/cjs-loader');
export * from '@esbuild-kit/esm-loader';
里面引入了 cjs-loader 和 esm-loader 来处理不同的模块。下面我们来看看分别是实现了啥东西。
esbuild-kit/cjs-loader
这里面给
.js .ts, .tsx, .jsx 解析的时候自定义了 transformer 方法。
transformer
function transformer(module: Module, filePath: string) {
/**
* For tracking dependencies in watch mode
*/
if (process.send) {
process.send({
type: 'dependency',
path: filePath,
});
}
let code = fs.readFileSync(filePath, 'utf8');
if (filePath.endsWith('.cjs') && nodeSupportsImport) {
const transformed = transformDynamicImport(code);
if (transformed) {
code = applySourceMap(transformed, filePath, sourcemaps);
}
} else {
const transformed = transformSync(code, filePath, {
tsconfigRaw: tsconfigRaw as TransformOptions['tsconfigRaw'],
});
code = applySourceMap(transformed, filePath, sourcemaps);
}
module._compile(code, filePath);
}
这里面就是读取文件里面的内容,然后调用 transform 的方法来将文件转化一下。返回 code 用 compile 方法执行。transform 底层用的是 esbuild 提供的 API。这里也是拦截了 "load" 方法,将编译好的文件传入 compile里面。
esbuild-kit/esm-loader
export const load: load = async function (url, context, defaultLoad) {
if (process.send) {
process.send({
type: 'dependency',
path: url,
});
}
if (url.endsWith('.json')) {
if (!context.importAssertions) {
context.importAssertions = {};
}
context.importAssertions.type = 'json';
}
const loaded = await defaultLoad(url, context, defaultLoad);
if (!loaded.source) {
return loaded;
}
const code = loaded.source.toString();
if (loaded.format === 'json' || tsExtensionsPattern.test(url)) {
const transformed = await transform(code, url, {
tsconfigRaw,
});
return {
format: 'module',
source: applySourceMap(transformed, url, sourcemaps),
};
}
const dynamicImportTransformed = transformDynamicImport(code);
if (dynamicImportTransformed) {
loaded.source = applySourceMap(dynamicImportTransformed, url, sourcemaps);
}
return loaded;
};
这里面的实现思路类似,针对于 ts 类的的文件进行编译,然后在 return 回去。供给 node 来执行。
注:真正的源码里面有很多分支判断,本文没有提到,可以自己去调试。
来个图理梳理一下流程。
总结
以上就是对 esno && tsx 流程的分析。如果有纰漏,请指正。
参考文档
tsx
cjs-loader
esm-loader
esno
node module loaders
require 源码解读