一.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' で、拡張子を指定する必要はありません(ただし付けても問題ありません)
.、..、/ で始まるファイルパスの場合、ファイル、ディレクトリとしてマッチしようとします。具体的なプロセス は以下の通り:
-
パスが存在しファイルであれば、JS コードとして読み込みます(ファイル拡張子が何であっても、
require(./myModule.abcd)は完全に正しい) -
存在しない場合、順に
.js、.json、.node(Node.js がサポートするバイナリ拡張)拡張子を付けて試行 -
パスが存在しディレクトリであれば、そのディレクトリ下で
package.jsonを探し、そのmainフィールドを取得し、指定されたモジュールを読み込みます(リダイレクトに相当) -
package.jsonがない場合、順にindex.js、index.json、index.nodeを試行
モジュール識別子がファイルパスでない場合、まず Node.js 組み込みモジュール(fs、path など)かどうかを確認します。そうでない場合、現在のディレクトリから開始し、順に上位の各 node_modules 下で探し、トップレベルの /node_modules まで、およびいくつかのグローバルディレクトリまで探します:
-
NODE_PATH環境変数で指定された位置 -
デフォルトのグローバルディレクトリ:
$HOME/.node_modules、$HOME/.node_librariesおよび$PREFIX/lib/node
P.S. グローバルディレクトリに関するより多くの情報は、Loading from the global folders を参照
モジュールファイルを見つけた後、内容を読み込み、関数で 1 層包みます:
(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.js を参照し、module2 がまた 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 = [];
}
モジュールを 1 つ読み込むごとに Module インスタンスを 1 つ作成し、モジュールファイル実行完了後、該イ���スタンスは依然として保持され、モジュールがエクスポートするものは Module インスタンスに付随して存在します
モジュール読み込みのすべての作業は module 組み込みモジュールによって完了され、Module._load、Module.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.関数で 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 モジュールを採用
コメントはまだありません