跳到主要內容
黯羽輕揚每天積累一點點

Node 模組載入機制

免費2020-04-12#Node#Node模块寻径#Node Module Resolution#Node模块别名#Node虚拟模块#Node virtual module

require() 時發生了什麼?Node.js 內部是怎麼實現的?知道這些有什麼用?

一.require() 時發生了什麼?

Node.js 中,模組載入過程分為 5 步:

  • 路徑解析(Resolution):根據模組標識找出對應模組(入口)檔案的絕對路徑

  • 載入(Loading):如果是 JSON 或 JS 檔案,就把檔案內容讀入記憶體。如果是內建的原生模組,將其共享庫動態連結到當前 Node.js 程序

  • 包裝(Wrapping):將檔案內容(JS 程式碼)包進一個函式,建立模組作用域,exports, require, module 等作為引數注入

  • 執行(Evaluation):傳入引數,執行包裝得到的函式

  • 快取(Caching):函式執行完畢後,將 module 快取起來,並把 module.exports 作為 require() 的返回值返回

其中,模組標識(Module Identifiers) 就是傳入 require(id) 的第一個字串引數 id,例如 require('./myModule') 中的 './myModule'無需指定副檔名(但帶上也無礙)

對於 .../ 開頭的檔案路徑,嘗試當做檔案、目錄來匹配,具體過程 如下:

  1. 若路徑存在並且是個檔案,就當做 JS 程式碼來載入(無論檔案副檔名是什麼,require(./myModule.abcd) 完全正確)

  2. 若不存在,依次嘗試拼上 .js.json.node(Node.js 支援的二進位制擴充套件)副檔名

  3. 如果路徑存在並且是個資料夾,就在該目錄下找 package.json,取其 main 欄位,並載入指定的模組(相當於一次重定向)

  4. 如果沒有 package.json,就依次嘗試 index.jsindex.jsonindex.node

對於模組標識不是檔案路徑的,先看是不是 Node.js 原生模組(fspath 等)。如果不是,就從當前目錄開始,逐級向上在各個 node_modules 下找,一直找到頂層的 /node_modules,以及一些全域性目錄:

  • NODE_PATH 環境變數中指定的位置

  • 預設的全域性目錄:$HOME/.node_modules$HOME/.node_libraries$PREFIX/lib/node

P.S. 關於全域性目錄的更多資訊,見 Loading from the global folders

找到模組檔案後,讀取內容,並包一層函式:

(function(exports, require, module, __filename, __dirname) {
// Module code actually lives in here
});

(摘自 The module wrapper

執行時從外部注入這些模組變數(exports, require, module, __filename, __dirname),模組匯出的東西通過 module.exports 帶出來,並將整個 module 物件快取起來,最後返回 require() 結果

迴圈依賴

特殊的,模組之間可能會出現迴圈依賴,對此,Node.js 的處理策略非常簡單:

// module1.js
exports.a = 1;
require('./module2');
exports.b = 2;
exports.c = 3;

// module2.js
const module1 = require('./module1');
console.log('module1 is partially loaded here', module1);

module1.js 執行中引用了 module2.jsmodule2 又引了 module1,此時 module1 尚未載入完(exports.b = 2; exports.c = 3; 還沒執行)。而在 Node.js 裡,只載入了一部分的模組也可以正常引用

When there are circular require() calls, a module might not have finished executing when it is returned.

所以 module1.js 執行結果是:

module1 is partially loaded here { a: 1 }

P.S. 關於迴圈引用的更多資訊,見 Cycles

二.Node.js 內部是怎麼實現的?

實現上,模組載入的絕大多數工作都是由 module 模組來完成的:

const Module = require('module');
console.log(Module);

Module 是個函式/類:

function Module(id = '', parent) {
  this.id = id;
  this.path = path.dirname(id);
  // 即 module.exports
  this.exports = {};
  this.parent = parent;
  updateChildren(parent, this, false);
  this.filename = null;
  this.loaded = false;
  this.children = [];
}

每載入一個模組都建立一個 Module 例項,模組檔案執行完後,該例項仍然保留,模組匯出的東西依附於 Module 例項存在

模組載入的所有工作都是由 module 原生模組來完成的,包括 Module._loadModule.prototype._compile

Module._load

Module._load() 負責載入新模組、管理快取,具體如下:

Module._load = function(request, parent, isMain) {
  // 0. 解析模組路徑
  const filename = Module._resolveFilename(request, parent, isMain);
  // 1. 優先找快取 Module._cache
  const cachedModule = Module._cache[filename];
  // 2. 嘗試匹配原生模組
  const mod = loadNativeModule(filename, request, experimentalModules);
  // 3. 未命中快取,也沒匹配到原生模組,就建立一個新的 Module 例項
  const module = new Module(filename, parent);
  // 4. 把新例項快取起來
  Module._cache[filename] = module;
  // 5. 載入模組
  module.load(filename);
  // 6. 如果載入/執行出錯了,就刪掉快取
  if (threw) {
    delete Module._cache[filename];
  }
  // 7. 返回 module.exports
  return module.exports;
};

Module.prototype.load = function(filename) {
  // 0. 判定模組型別
  const extension = findLongestRegisteredExtension(filename);
  // 1. 按型別載入模組內容
  Module._extensions[extension](this, filename);
};

支援的型別有 .js.json.node 3 種:

// Native extension for .js
Module._extensions['.js'] = function(module, filename) {
  // 1. 讀取 JS 檔案內容
  const content = fs.readFileSync(filename, 'utf8');
  // 2. 包裝、執行
  module._compile(content, filename);
};

// Native extension for .json
Module._extensions['.json'] = function(module, filename) {
  // 1. 讀取 JSON 檔案內容
  const content = fs.readFileSync(filename, 'utf8');
  // 2. 直接 JSON.parse() 完事
  module.exports = JSONParse(stripBOM(content));
};

// Native extension for .node
Module._extensions['.node'] = function(module, filename) {
  // 動態載入共享庫
  return process.dlopen(module, path.toNamespacedPath(filename));
};

P.S. process.dlopen 具體見 process.dlopen(module, filename[, flags])

Module.prototype._compile

Module.prototype._compile = function(content, filename) {
  // 1. 包一層函式
  const compiledWrapper = wrapSafe(filename, content, this);
  // 2. 把要注入的引數準備好
  const dirname = path.dirname(filename);
  const require = makeRequireFunction(this, redirects);
  const exports = this.exports;
  const thisValue = exports;
  const module = this;
  // 3. 注入引數、執行
  compiledWrapper.call(thisValue, exports, require, module, filename, dirname);
};

包裝部分的實現如下:

function wrapSafe(filename, content, cjsModuleInstance) {
  let compiled = compileFunction(
    content,
    filename,
    0,
    0,
    undefined,
    false,
    undefined,
    [],
    [
      'exports',
      'require',
      'module',
      '__filename',
      '__dirname',
    ]
  );

  return compiled.function;
}

P.S. 模組載入的完整實現見 node/lib/internal/modules/cjs/loader.js

三。知道這些有什麼用?

知道了模組的載入機制,在一些需要擴充套件篡改載入邏輯的場景很有用,比如用來實現虛擬模組、模組別名等

虛擬模組

比如,VS Code 外掛通過 require('vscode') 來訪問外掛 API:

// The module 'vscode' contains the VS Code extensibility API
import * as vscode from 'vscode';

vscode 模組實際上是不存在的,是個執行時擴充套件出來的虛擬模組:

// ref: src/vs/workbench/api/node/extHost.api.impl.ts
function defineAPI() {
  const node_module = <any>require.__$__nodeRequire('module');
  const original = node_module._load;
  // 1. 劫持 Module._load
  node_module._load = function load(request, parent, isMain) {
    if (request !== 'vscode') {
      return original.apply(this, arguments);
    }

    // 2. 注入虛擬模組 vscode
    // get extension id from filename and api for extension
    const ext = extensionPaths.findSubstr(parent.filename);
    let apiImpl = extApiImpl.get(ext.id);
    if (!apiImpl) {
      apiImpl = factory(ext);
      extApiImpl.set(ext.id, apiImpl);
    }
    return apiImpl;
  };
}

具體見 [API 注入機制及外掛啟動流程_VSCode 外掛開發筆記 2](/articles/api 注入機制及外掛啟動流程-vscode 外掛開發筆記 2/),這裡不再贅述

模組別名

類似的,可以通過重寫 Module._resolveFilename 來實現模組別名,比如把 proj/src 中的 @lib/my-module 模組引用對映到 proj/lib/my-module

// src/index.js
require('./patchModule');

const myModule = require('@lib/my-module');
console.log(myModule);

patchModule 具體實現如下:

const Module = require('module');
const path = require('path');

const _resolveFilename =  Module._resolveFilename;
Module._resolveFilename = function(request) {
  const args = Array.from(arguments);
  // 別名對映
  const LIB_PREFIX = '@lib/';
  if (request.startsWith(LIB_PREFIX)) {
    console.log(request);
    request = path.resolve(__dirname, '../' + request.slice(1));
    args[0] = request;
    console.log(` => ${request}`);
  }
  return _resolveFilename.apply(null, args);
}

P.S. 當然,一般不需要這樣做,可以通過 Webpack 等構建工具來完成

清掉快取

預設 Node.js 模組載入過就有快取,而有些時候可能想要禁掉快取,強制重新載入一個模組,比如想要讀取能被使用者頻繁修改的 JS 檔案(如 webpack.config.js

此時可以手動刪掉掛在 require.cache 身上的 module.exports 快取:

delete require.cache[require.resolve('./b.js')]

然而,如果 b.js 還引用了其它外部(非原生)模組,也需要一併刪除

const mod = require.cache[require.resolve('./b.js')];
// 把引用樹上所有模組快取全都刪掉
(function traverse(mod) {
  mod.children.forEach((child) => {
    traverse(child);
  });

  console.log('decache ' + mod.id);
  delete require.cache[mod.id];
}(mod));

P.S. 或者採用 decache 模組

參考資料

評論

暫無評論,快來發表你的看法吧

提交評論