天天看點

【Nodejs】1129- 如何為 Node.js 的 require 函數添加鈎子?

Node.js 是一個基于 Chrome V8 引擎的 JavaScript 運作時環境。早期的 Node.js 采用的是 CommonJS 子產品規範,從 Node v13.2.0 版本開始正式支援 ES Modules 特性。直到 v15.3.0 版本 ES Modules 特性才穩定下來并與 NPM 生态相相容。

【Nodejs】1129- 如何為 Node.js 的 require 函數添加鈎子?

(圖檔來源:https://nodejs.org/api/esm.html)

本文将介紹 Node.js 中 ​

​require​

​​ 函數的工作流程、如何讓 Node.js 直接執行 ts 檔案及如何正确地劫持 Node.js 的 ​

​require​

​​ 函數,進而實作鈎子的功能。接下來,我們先來介紹 ​

​require​

​ 函數。

require 函數

Node.js 應用由子產品組成,每個檔案就是一個子產品。對于 CommonJS 子產品規範來說,我們通過 ​

​require​

​​ 函數來導入子產品。那麼當我們使用 ​

​require​

​​ 函數來導入子產品的時候,該函數内部發生了什麼?這裡我們通過調用堆棧來了解一下 ​

​require​

​ 的過程:

【Nodejs】1129- 如何為 Node.js 的 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 機制來實作對應的功能。

繼續閱讀