大家好,我是若川。持续组织了6个月源码共读活动,感兴趣的可以点此加我微信 ruochuan02 参与,每周大家一起学习200行左右的源码,共同进步。同时极力推荐订阅我写的《学习源码整体架构系列》 包含20余篇源码文章。历史面试系列
esno我在我的很多源码文章中提到过,但没有写文章,分享这篇好文章。
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 nodeconst spawn = require('cross-spawn')
const spawnSync = spawn.syncconst 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 = []; // 存放新的loaderconst oldLoaders = []; // 存放旧的loaderlet exts;const originalJSLoader = Module._extensions['.js']; // 原始的JS Loader // 省略部分代码exts.forEach((ext) => {// 获取已注册的loader,若未找到,则默认使用JS Loaderconst 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 nodeimport spawn from 'cross-spawn'
import { resolve } from 'import-meta-resolve'
const spawnSync = spawn.syncconst 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 这几个项目的源码。若有遇到问题的话,可以跟阿宝哥交流哟。
参考资源
esbuild 官网
Node.js 官网 - ESM
如何为 Node.js 的 require 函数添加钩子?
················· 若川简介 ·················
你好,我是若川,毕业于江西高校。现在是一名前端开发“工程师”。写有《学习源码整体架构系列》20余篇,在知乎、掘金收获超百万阅读。
从2014年起,每年都会写一篇年度总结,已经写了7篇,点击查看年度总结。
同时,最近组织了源码共读活动,帮助3000+前端人学会看源码。公众号愿景:帮助5年内前端人走向前列。
识别上方二维码加我微信、拉你进源码共读群
今日话题
略。分享、收藏、点赞、在看我的文章就是对我最大的支持~