Node.js 并不支持直接执行 TS 文件,如果要执行 TS 文件的话,我们就可以借助 ts-node 这个库。相信有些小伙伴在工作中也用过这个库,关于 ts-node 这个库的相关内容我就不展开介绍了,因为本文的主角是由 antfu 大佬开源的 esno 项目,接下来我将带大家一起来揭开这个项目背后的秘密。
阅读完本文后,你将了解 esno 项目是如何执行 TS 文件。此外,你还会了解如何劫持 Node.js 的 require 函数、如何为 ES Module 的 import 语句添加钩子及如何自定义 https 加载器,以支持 import React from "https://esm.sh/react"
导入方式。
esno 是什么
esno 是基于 esbuild 的 TS/ESNext node 运行时。该库会针对不同的模块化标准,采用不同的方案:
esno
- Node in CJS mode - by esbuild-registeresmo
- Node in ESM mode - by esbuild-node-loader
使用 esno 的方式很简单,你可以以全局或局部的方式来安装它:
全局安装
$ npm i -g esno
在安装成功后,你就可以通过以下方式来直接执行 TS 文件:
$ esno index.ts
$ esmo index.ts
局部安装
$ npm i esno
而对于局部安装的方式来说,一般情况下,我们会以 npm scripts 的方式来使用它:
{
"scripts": {
"start": "esno index.ts"
},
"dependencies": {
"esno": "0.14.0"
}
}
esno 是如何工作的
在开始分析 esno 的工作原理之前,我们先来熟悉一下该项目:
├── LICENSE
├── README.md
├── esmo.mjs
├── esno.js
├── package.json
├── pnpm-lock.yaml
├── publish.ts
└── tsconfig.json
观察以上的项目结构可知,该项目并不会复杂。在项目根目录下的 package.json 文件中,我们看到了前面介绍的 esno 和 esmo 命令。
{
"bin": {
"esno": "esno.js",
"esmo": "esmo.mjs"
},
}
此外,在 package.json 的 scripts 字段中,我们发现了 release 命令。顾名思义,该命令用来发布版本。
{
"scripts": {
"release": "npx bumpp --tag --commit --push && node esmo.mjs publish.ts"
},
}
需要注意的是,在 publish.ts
文件中,使用到了 2021 年度 Github 上最耀眼的项目 zx,利用该项目我们可以轻松地编写命令行脚本。写作本文时,它的 Star 数已经高达 27.5K,强烈推荐感兴趣的小伙伴关注一下该项目。
简单介绍了 esno 项目之后,接下来我们来分析 esno.js
文件:
#!/usr/bin/env node
const spawn = require('cross-spawn')
const spawnSync = spawn.sync
const register = require.resolve('esbuild-register')
const argv = process.argv.slice(2)
process.exit(spawnSync('node', ['-r', register, ...argv],
{ stdio: 'inherit' }).status)
由以上代码可知,当执行 esno index.ts
命令后,会通过 spawnSync
来启动 Node.js 程序执行脚本。需要注意的是,在执行时使用了 -r
选项,该选项的作用是预加载模块:
-r, --require = ... module to preload (option can be repeated)
这里预加载的模块是 esbuild-register,该模块就是 esno 命令执行 TS 文件的幕后英雄。
esbuild-register 是什么
esbuild-register 是一个基于 esbuild 来转换 JSX、TS 和 esnext 特性的工具。你可以通过以下多种方式来安装它:
$ npm i esbuild esbuild-register -D
# Or Yarn
$ yarn add esbuild esbuild-register --dev
# Or pnpm
$ pnpm add esbuild esbuild-register -D
在成功安装该模块之后,就可以在命令行中,直接通过 node
应用程序来执行 ts 文件:
$ node -r esbuild-register file.ts
-r, --require = ... module to preload (option can be repeated)
-r
用于指定预加载的文件,即在执行file.ts
文件前,提前加载esbuild-register
模块
它将会使用 tsconfig.json
中的 jsxFactory
, jsxFragmentFactory
和 target
配置项来执行转换操作。
esbuild-register 不仅可以在命令行中使用,而且还可以通过 API 的方式进行使用:
const { register } = require('esbuild-register/dist/node')
const { unregister } = register({
// ...options
})
// Unregister the require hook if you don't need it anymore
unregister()
了解完 esbuild-register 的基本使用之后,接下来我们来分析它内部是如何工作的。
esbuild-register 是如何工作的
esbuild-register 内部利用了 pirates 这个库来劫持 Node.js 的 require
函数,从而让你可以在命令行中,直接执行 ts
文件。下面我们来看一下 esbuild-register 模块中定义的 register
函数:
// esbuild-register/src/node.ts
import { transformSync, TransformOptions } from 'esbuild'
import { addHook } from 'pirates'
export function register(esbuildOptions: RegisterOptions = {}) {
const {
extensions = DEFAULT_EXTENSIONS,
hookIgnoreNodeModules = true,
hookMatcher,
...overrides
} = esbuildOptions
// 利用 transformSync
const compile: COMPILE = function compile(code, filename, format) {
const dir = dirname(filename)
const options = getOptions(dir)
format = format ?? inferPackageFormat(dir, filename)
const {
code: js,
warnings,
map: jsSourceMap,
} = transformSync(code, {
sourcefile: filename,
sourcemap: 'both',
loader: getLoader(filename),
target: options.target,
jsxFactory: options.jsxFactory,
jsxFragment: options.jsxFragment,
format,
...overrides,
})
// 省略部分代码
}
const revert = addHook(compile, {
exts: extensions,
ignoreNodeModules: hookIgnoreNodeModules,
matcher: hookMatcher,
})
return {
unregister() {
revert()
},
}
}
观察以上的代码可知,在 register
函数内部是利用 esbuild 模块提供的 transformSync
API 来实现 ts -> js 代码的转换。其实最关键的环节,还是通过调用 pirates 这个库提供的 addHook
函数来注册编译 ts 文件的钩子。
那么 addHook
函数内部到底做了哪些处理呢?下面我们来看一下它的实现:
// pirates-4.0.5/src/index.js
export function addHook(hook, opts = {}) {
let reverted = false;
const loaders = []; // 存放新的loader
const oldLoaders = []; // 存放旧的loader
let exts;
const originalJSLoader = Module._extensions['.js']; // 原始的JS Loader
// 省略部分代码
exts.forEach((ext) => {
// 获取已注册的loader,若未找到,则默认使用JS Loader
const oldLoader = Module._extensions[ext] || originalJSLoader;
oldLoaders[ext] = Module._extensions[ext];
loaders[ext] = Module._extensions[ext] = function newLoader(
mod, filename) {
let compile;
if (!reverted) {
if (shouldCompile(filename, exts, matcher, ignoreNodeModules)) {
compile = mod._compile;
mod._compile = function _compile(code) {
// 这里需要恢复成原来的_compile函数,否则会出现死循环
mod._compile = compile;
// 在编译前先执行用户自定义的hook函数
const newCode = hook(code, filename);
if (typeof newCode !== 'string') {
throw new Error(HOOK_RETURNED_NOTHING_ERROR_MESSAGE);
}
return mod._compile(newCode, filename);
};
}
}
oldLoader(mod, filename);
};
});
}
其实 addHook
函数的实现并不会复杂,该函数内部就是通过替换 mod._compile
方法来实现钩子的功能。即在调用原始的 mod._compile
方法进行编译前,会先调用 hook(code, filename)
函数来执行用户自定义的 hook
函数,从而对代码进行预处理。
而对于 esbuild-register 库中的 register
函数来说,当 hook
函数执行时,就会调用该函数内部定义的 compile
函数来编译 ts 代码,然后再调用 mod._compile
方法编译生成的 js
代码。
关于 esbuild-register 和 pirates 这两个库的内容就先介绍到这里,如果你想详细了解 pirates 这个库是如何工作的,可以阅读 如何为 Node.js 的 require 函数添加钩子? 这篇文章。
现在我们已经分析完 esno.js
文件,接下来我们来分析 esmo.mjs
文件。
esmo 是如何工作的
esmo 命令对应的是 esmo.mjs 文件:
#!/usr/bin/env node
import spawn from 'cross-spawn'
import { resolve } from 'import-meta-resolve'
const spawnSync = spawn.sync
const argv = process.argv.slice(2)
resolve('esbuild-node-loader', import.meta.url).then((path) => {
process.exit(spawnSync('node', ['--loader', path, ...argv],
{ stdio: 'inherit' }).status)
})
由以上代码可知,当使用 node 应用程序执行 ES Module 文件时,会通过 --loader
选项来指定自定义的 ES Module 加载器。
--loader, --experimental-loader = ... use the specified module as a custom loader
需要注意的是,通过 --loader
选项指定的自定义加载器只适用于 ES Module 的 import 调用,并不适用于 CommonJS 的 require 调用。
那么自定义加载器有什么作用呢?在当前最新的 Node.js v17.4.0 版本中,还不支持以 https://
开头的说明符。我们可以在自定义加载器中,利用 Node.js 提供的钩子机制,让 Node.js 可以使用 import
导入以 https://
协议开头的 ES 模块。
在分析如何自定义 https
资源加载器前,我们需要先介绍一下 import 说明符的概念。
import 说明符
import
语句的说明符是 from
关键字之后的字符串,例如 import { sep } from 'path'
中的 'path'
。 说明符也用于 export from
语句,并作为 import()
表达式的参数。
有三种类型的说明符:
- 相对说明符,如
'./startup.js'
或'../config.mjs'
。它们指的是相对于导入文件位置的路径。对于这种类型,文件扩展名是必须的。 - 裸说明符,如
'some-package'
或'some-package/shuffle'
。它们可以通过包名来引用包的主入口点。当包没有exports
字段的时候,才需要包含文件扩展名。 - 绝对说明符,如
file:///opt/nodejs/config.js
。它们直接且明确地引用完整路径。
裸说明符解析由 Node.js 模块解析算法处理,所有其他说明符解析始终仅使用标准的相对URL 解析语义进行解析。
和 CommonJS 一样,包内的模块文件可以通过在包名上添加路径来访问,除非包的 package.json 包含一个 "exports " 字段,在这种情况下,包中的文件只能通过 "exports " 中定义的路径访问。
介绍完 import 说明符之后,接下来我们来看一下如何自定义 https 加载器。
自定义 https 加载器
resolve 钩子
resolve
钩子用于根据模块的说明符和 parentURL
生成导入目标的绝对路径,调用该钩子后会返回一个包含 format
(可选) 和 url
属性的对象。
// https-loader.mjs
import { get } from 'https';
export function resolve(specifier, context, defaultResolve) {
const { parentURL = null } = context;
if (specifier.startsWith('https://')) {
return {
url: specifier
};
} else if (parentURL && parentURL.startsWith('https://')) {
return {
url: new URL(specifier, parentURL).href
};
}
// 让 Node.js 处理其它的说明符
return defaultResolve(specifier, context, defaultResolve);
}
在以上代码中,会先判断 specifier
字符串是否以 'https://'
开头,如果条件满足的话,该字符串的值直接作为 url
属性的值,直接返回 { url: specifier }
对象。否则,会判断 parentURL
是否以 'https://'
开头,如果条件满足的话,则会调用 URL 构造函数,创建 URL 对象。
parentURL
是从 context
对象上获取的,那它什么时候会有值呢?假设在 ES 模块 A 中,以相对路径的形式导入 ES 模块 B。在导入 ES 模块 B 时,也会调用 resolve
钩子,此时 context
对象上的 parentURL
就会有值。
load 钩子
load
钩子用于定义应该如何解释、检索和解析 URL 的方法,调用该方法后,会返回包含 format
和 source
属性的对象。其中 format
属性值只能是 'builtin'
、'commonjs'
、'json'
、'module'
和 'wasm'
中的一种。而 source
属性值的类型可以为 string
、ArrayBuffer
或 TypedArray
。
import { get } from 'https';
export function load(url, context, defaultLoad) {
if (url.startsWith('https://')) {
return new Promise((resolve, reject) => {
get(url, (res) => {
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', () => resolve({
format: 'module',
source: data,
}));
}).on('error', (err) => reject(err));
});
}
// 让 Node.js 加载其它类型的文件
return defaultLoad(url, context, defaultLoad);
}
在以上代码中,会通过 https
模块中的 get
函数来加载 https://
协议的 ES 模块。如果不是以 'https://'
开头,则会使用默认的加载器来加载其它类型的文件。
创建完 https-loader
之后,我们来测试一下该加载器。首先创建一个 main.mjs
文件并输入以下内容:
// main.mjs
import React from "https://esm.sh/react@17.0.2"
console.dir(React);
然后在命令行输入以下命令:
$ node --experimental-loader ./https-loader.mjs ./main.mjs
当以上命令成功运行之后,控制台会输出以下内容:
{
Fragment: Symbol(react.fragment),
StrictMode: Symbol(react.strict_mode),
Profiler: Symbol(react.profiler),
Suspense: Symbol(react.suspense),
...
}
了解完以上的内容后,我们回过头来看一下 esmo.mjs
文件中所使用的 esbuild-node-loader 模块。下面我们来简单分析一下 load
钩子:
// loader.mjs(esbuild-node-loader v0.6.4)
export function load(url, context, defaultLoad) {
if (extensionsRegex.test(new URL(url).pathname)) {
const { format } = context;
let filename = url;
if (!isWindows) filename = fileURLToPath(url);
const rawSource = fs.readFileSync(new URL(url), { encoding: "utf8" });
const { js } = esbuildTransformSync(rawSource, filename, url, format);
return {
format: "module",
source: js,
};
}
// Let Node.js handle all other format / sources.
return defaultLoad(url, context, defaultLoad);
}
通过观察以上代码,我们可知 load
钩子的核心处理流程,可以分为两个步骤:
- 步骤一:使用
fs.readFileSync
方法读取文件资源的内容; - 步骤二:使用
esbuildTransformSync
函数对源代码进行转换。
而在 esbuildTransformSync
函数中,使用了 esbuild
模块提供的 transformSync
函数来实现代码的转换。该函数的相关代码如下所示:
// loader.mjs(esbuild-node-loader v0.6.4)
function esbuildTransformSync(rawSource, filename, url, format) {
const {
code: js,
warnings,
map: jsSourceMap,
} = transformSync(rawSource.toString(), {
sourcefile: filename,
sourcemap: "both",
loader: new URL(url).pathname.match(extensionsRegex)[1],
target: `node${process.versions.node}`,
format: format === "module" ? "esm" : "cjs",
});
// 省略部分代码
return { js, jsSourceMap };
}
关于 transformSync
函数的使用方式,我就不展开介绍了。感兴趣的小伙伴可以自行阅读一下 esbuild 官网上的相关文档。
好的,esno 这个项目就介绍到这里。如果你对 Node.js 平台下的 require
和 import
hook 机制感兴趣的话,可以详细阅读一下 pirates、esbuild-register 和 esbuild-node-loader 这几个项目的源码。若有遇到问题的话,欢迎跟阿宝哥交流哟。