又一個基於 Esbuild 的神器!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-register -
esmo
- 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 這幾個項目的源碼。若有遇到問題的話,可以跟阿寶哥交流喲。
參考資源
-
esbuild 官網
-
Node.js 官網 - ESM
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/aVisSW3mk7AJxBfJWhUX0w