Node.js 是一個基于 Chrome V8 引擎的 JavaScript 運作時環境。早期的 Node.js 采用的是 CommonJS 子產品規範,從 Node v13.2.0 版本開始正式支援 ES Modules 特性。直到 v15.3.0 版本 ES Modules 特性才穩定下來并與 NPM 生态相相容。
(圖檔來源:https://nodejs.org/api/esm.html)
本文将介紹 Node.js 中
require
函數的工作流程、如何讓 Node.js 直接執行 ts 檔案及如何正确地劫持 Node.js 的
require
函數,進而實作鈎子的功能。接下來,我們先來介紹
require
函數。
require 函數
Node.js 應用由子產品組成,每個檔案就是一個子產品。對于 CommonJS 子產品規範來說,我們通過
require
函數來導入子產品。那麼當我們使用
require
函數來導入子產品的時候,該函數内部發生了什麼?這裡我們通過調用堆棧來了解一下
require
的過程:
由上圖可知,在使用
require
導入子產品時,會調用
Module
對象的
load
方法來加載子產品,該方法的實作如下所示:
// lib/internal/modules/cjs/loader.js
Module.prototype.load = function(filename) {
this.filename = filename;
this.paths = Module._nodeModulePaths(path.dirname(filename));
const extension = findLongestRegisteredExtension(filename);
Module._extensions[extension](this, filename);
this.loaded = true;
// 省略部分代碼
};
注意:本文所引用 Node.js 源碼所對應的版本是 v16.13.1
在以上代碼中,重要的兩個步驟是:
- 步驟一:根據檔案名找出擴充名;
- 步驟二:通過解析後的擴充名,在
對象中查找比對的加載器。Module._extensions
在 Node.js 中内置了 3 種不同的加載器,用于加載
node
、
json
和
js
檔案。
node 檔案加載器
// lib/internal/modules/cjs/loader.js
Module._extensions['.node'] = function(module, filename) {
return process.dlopen(module, path.toNamespacedPath(filename));
};
json 檔案加載器
// lib/internal/modules/cjs/loader.js
Module._extensions['.json'] = function(module, filename) {
const content = fs.readFileSync(filename, 'utf8');
try {
module.exports = JSONParse(stripBOM(content));
} catch (err) {
err.message = filename + ': ' + err.message;
throw err;
}
};
js 檔案加載器
// lib/internal/modules/cjs/loader.js
Module._extensions['.js'] = function(module, filename) {
// If already analyzed the source, then it will be cached.
const cached = cjsParseCache.get(module);
let content;
if (cached?.source) {
content = cached.source;
cached.source = undefined;
} else {
content = fs.readFileSync(filename, 'utf8');
}
// 省略部分代碼
module._compile(content, filename);
};
下面我們來分析比較重要的 js 檔案加載器。通過觀察以上代碼,我們可知
js
加載器的核心處理流程,也可以分為兩個步驟:
- 步驟一:使用
方法加載 fs.readFileSync
檔案的内容;js
- 步驟二:使用
方法編譯已加載的 module._compile
代碼。js
那麼了解以上的知識之後,對我們有什麼用處呢?其實在了解
require
函數的工作流程之後,我們就可以擴充 Node.js 的加載器。比如讓 Node.js 能夠運作
ts
檔案。
// register.js
const fs = require("fs");
const Module = require("module");
const { transformSync } = require("esbuild");
Module._extensions[".ts"] = function (module, filename) {
const content = fs.readFileSync(filename, "utf8");
const { code } = transformSync(content, {
sourcefile: filename,
sourcemap: "both",
loader: "ts",
format: "cjs",
});
module._compile(code, filename);
};
在以上代碼中,我們引入了内置的
module
子產品,然後利用該子產品的
_extensions
對象來注冊我們的自定義 ts 加載器。
其實,加載器的本質就是一個函數,在該函數内部我們利用 esbuild 子產品提供的
transformSync
API 來實作 ts -> js 代碼的轉換。當完成代碼轉換之後,會調用
module._compile
方法對代碼進行編譯操作。
看到這裡相信有的小夥伴,也想到了 Webpack 中對應的 loader,想深入學習的話,可以閱讀 多圖詳解,一次性搞懂Webpack Loader 這篇文章。
篇幅有限,具體的編譯過程,我們就不展開介紹了。下面我們來看一下如何讓自定義的 ts 加載器生效。要讓 Node.js 能夠執行 ts 代碼,我們就需要在執行 ts 代碼前,先完成自定義 ts 加載器的注冊操作。慶幸的是,Node.js 為我們提供了子產品的預加載機制:
$ node --help | grep preload
-r, --require=... module to preload (option can be repeated)
即利用
-r, --require
指令行配置項,我們就可以預加載指定的子產品。了解完相關知識之後,我們來測試一下自定義 ts 加載器。
首先建立一個
index.ts
檔案并輸入以下内容:
// index.ts
const add = (a: number, b: number) => a + b;
console.log("add(a, b) = ", add(3, 5));
然後在指令行輸入以下指令:
$ node -r ./register.js index.ts
當以上指令成功運作之後,控制台會輸出以下内容:
add(a, b) = 8
很明顯我們自定義的 ts 檔案加載器生效了,這種擴充機制還是值得我們學習的。另外,需要注意的是在
load
方法中,
findLongestRegisteredExtension
函數會判斷檔案的擴充名是否已經注冊在
Module._extensions
對象中,若未注冊的話,預設會傳回
.js
字元串。
// lib/internal/modules/cjs/loader.js
Module.prototype.load = function(filename) {
this.filename = filename;
this.paths = Module._nodeModulePaths(path.dirname(filename));
const extension = findLongestRegisteredExtension(filename);
Module._extensions[extension](this, filename);
this.loaded = true;
// 省略部分代碼
};
這就意味着隻要檔案中包含有效的
js
代碼,
require
函數就能正常加載它。比如下面的 a.txt 檔案:
module.exports = "hello world";
看到這裡相信你已經了解
require
函數是如何加載子產品及如何自定義 Node.js 檔案加載器。那麼讓 Node.js 支援加載
ts
、
png
或
css
等其它類型的檔案,有更優雅、更簡單的方案麼?答案是有的,我們可以使用 pirates 這個第三方庫。
pirates 是什麼
pirates 這個庫讓我們可以正确地劫持 Node.js 的
require
函數。利用這個庫,我們就可以很容易擴充 Node.js 加載器的功能。
pirates 的用法
你可以使用 npm 來安裝 pirates:
npm install --save pirates
在成功安裝 pirates 這個庫之後,就可以利用該子產品導出提供的
addHook
函數來添加鈎子:
// register.js
const addHook = require("pirates").addHook;
const revert = addHook(
(code, filename) => code.replace("@@foo", "console.log('foo');"),
{ exts: [".js"] }
);
需要注意的是調用
addHook
之後會傳回一個
revert
函數,用于取消對
require
函數的劫持操作。下面我們來驗證一下 pirates 這個庫是否能正常工作,首先建立一個
index.js
檔案并輸入以下内容:
// index.js
console.log("@@foo")
然後在指令行輸入以下指令:
$ node -r ./register.js index.js
當以上指令成功運作之後,控制台會輸出以下内容:
console.log('foo');
觀察以上結果可知,我們通過
addHook
函數添加的鈎子生效了。是不是覺得挺神奇的,接下來我們來分析一下 pirates 的工作原理。
pirates 是如何工作的
pirates 底層是利用 Node.js 内置
module
子產品提供的擴充機制來實作
Hook
功能。前面我們已經介紹過了,當使用
require
函數來加載子產品時,Node.js 會根據檔案的字尾名來比對對應的加載器。
其實 pirates 的源碼并不會複雜,我們來重點分析
addHook
函數的核心處理邏輯:
// 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
const matcher = opts.matcher || null;
const ignoreNodeModules = opts.ignoreNodeModules !== false;
exts = opts.extensions || opts.exts || opts.extension || opts.ext
|| ['.js'];
if (!Array.isArray(exts)) {
exts = [exts];
}
exts.forEach((ext) {
// ...
}
}
為了提高執行效率,
addHook
函數提供了
matcher
和
ignoreNodeModules
配置項來實作檔案過濾操作。在擷取到
exts
擴充名清單之後,就會使用新的加載器來替換已有的加載器。
exts.forEach((ext) => {
if (typeof ext !== 'string') {
throw new TypeError(`Invalid Extension: ${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
函數,進而對代碼進行處理。
好的,至此本文的主要内容都介紹完了,在實際工作中,如果你想讓 Node.js 直接執行 ts 檔案,可以利用 ts-node 或 esbuild-register 這兩個庫。其中 esbuild-register 這個庫内部就是使用了 pirates 提供的 Hook 機制來實作對應的功能。