ホームページ > 記事 > ウェブフロントエンド > Node.js のモジュール読み込みメカニズムの詳細な分析
モジュールは Node.js の非常に基本的かつ重要な概念です。さまざまなネイティブ クラス ライブラリがモジュールを通じて提供され、サードパーティのライブラリもモジュールを通じて管理および参照されます。この記事ではモジュールの基本原則から始めて、最終的にはこの原則を使用して簡単なモジュール読み込みメカニズムを自分で実装します。つまり、require
を自分で実装します。
Node は JavaScript と commonjs モジュールを使用し、パッケージ マネージャーとして npm/yarn を使用します。
[ビデオチュートリアルの推奨: node js チュートリアル]
古いルール、原理を説明する前に簡単な例を見てみましょう。この例は、原則を段階的に深く理解することから始まります。 Node.js で何かをエクスポートしたい場合は、module.exports
を使用する必要があります。module.exports
を使用すると、文字列、関数、およびオブジェクト、配列など。まず、最も単純な hello world
:<pre class="brush:js;toolbar:false">// a.js
module.exports = "hello world";</pre>
をエクスポートする
を構築し、次に、 function : <pre class="brush:php;toolbar:false">// b.js
function add(a, b) {
return a + b;
}
module.exports = add;</pre>
次に、それらを
で使用します。つまり、require
関数によって返される結果は次のとおりです。対応するファイル module.exports
の値: <pre class="brush:js;toolbar:false">// index.js
const a = require(&#39;./a.js&#39;);
const add = require(&#39;./b.js&#39;);
console.log(a); // "hello world"
console.log(add(1, 2)); // b导出的是一个加法函数,可以直接使用,这行结果是3</pre>
requireはターゲットファイルを最初に実行します特定のモジュールを
をそのまま使用するのではなく、このファイルを最初から実行します。module.exports = XXX
は実際には 1 行のコードです。後で説明しますが、このコード行の効果は、実際にはモジュール内の exports
属性を変更することです。たとえば、別の c.js
を考えてみましょう。 <pre class="brush:js;toolbar:false">// c.js
let c = 1;
c = c + 1;
module.exports = c;
c = 6;</pre>
c.js
では、
をエクスポートしました。この c
いくつかの計算ステップを経て、module.exports = c;
行まで実行すると、c
の値は 2
となるため、require
c.js
の値は 2
であり、後で c
の値を 6
に変更しても、前のコード行には影響しません。 :<pre class="brush:js;toolbar:false">const c = require(&#39;./c.js&#39;);
console.log(c); // c的值是2</pre>
前の c.js
の変数
は基本的なデータ型であるため、次の c = 6;
は影響しません。前の module.exports
は、参照型の場合はどうなるでしょうか?直接試してみましょう: <pre class="brush:php;toolbar:false">// d.js
let d = {
num: 1
};
d.num++;
module.exports = d;
d.num = 6;</pre>
次に、index.js
him: <pre class="brush:js;toolbar:false">const d = require(&#39;./d.js&#39;);
console.log(d); // { num: 6 }</pre>
それは module で見つかりました。
d
の d.num
への割り当ては引き続き有効です。実際、参照型の場合、その値は module.exports
の後だけでなく、モジュールの外側でも変更できます。たとえば、index.js
内で直接変更できます。 :<pre class="brush:js;toolbar:false">const d = require(&#39;./d.js&#39;);
d.num = 7;
console.log(d); // { num: 7 }</pre>
require
と
前の例からわかるように、require
and 私たちが行うことは複雑ではありません。まず、グローバル オブジェクト {}
があると仮定しましょう。最初は空です。require
を実行すると、このコード行を実行すると、ファイルが取り出されて実行されます。このファイルに module.exports
が存在する場合、このコード行を実行すると、module.exports
の値が取得されます。 exports がこのオブジェクトに追加されます。キーは対応するファイル名です。最終的に、オブジェクトは次のようになります:
{ "a.js": "hello world", "b.js": function add(){}, "c.js": 2, "d.js": { num: 2 } }
特定のファイルを再度
require したとき, このオブジェクトに対応する値がある場合は、その値が直接返されます。そうでない場合は、前の手順を繰り返し、ターゲット ファイルを実行して、その module.exports をグローバル オブジェクトに追加し、発信者に返します。このグローバル オブジェクトは、実際にはよく耳にするキャッシュです。
つまり、require
と module.exports には黒魔術はありません。単に実行してターゲット ファイルの値を取得し、それをキャッシュに追加して、取得するだけです。必要なときに出します。
このオブジェクトをもう一度見てください。d.js
は参照型であるため、この参照を取得した場所でその値を変更できます。モジュールの値を変更したくない場合は、 , Object.freeze()、
Object.defineProperty() などのメソッドを使用するなど、モジュールを自分で記述する必要がある場合は、これを処理する必要があります。
モジュールのタイプとロード順序
- 組み込みモジュール:
fs
、http
など、Node.js によってネイティブに提供される関数です。これらのモジュールはノード内にあります。.js プロセスは開始時にロードされます。- ファイル モジュール: 前に作成したモジュールとサードパーティ モジュール、つまり
node_modules
以下のモジュールはすべてファイル モジュールです。
ロード順序とは、require(X)
の場合に #XX## を探す順序を指します。 #, 公式ドキュメントに 詳細な疑似コード
が記載されていますが、まとめると、
組み込みモジュールが存在する場合でも、最初に組み込みモジュールをロードするという順序になります。同じ名前のファイルがある場合は、組み込みモジュールを使用することが優先されます。フォルダーの読み込み
- これは組み込みモジュールではないため、最初にキャッシュに移動して見つけてください。
- キャッシュがない場合は、対応するパスを持つファイルを探します。
- 対応するファイルが存在しない場合、このパスはフォルダーとしてロードされます。
- 対応するファイルやフォルダーが見つからない場合は、
node_modules- に移動して探してください。
エラーが見つからなかった場合でも、エラーを報告しました。
まず、このフォルダーの下にrequirepackage.json
- があるかどうかを確認します。存在する場合は、
package.jsonmain
フィールド内のmain
フィールドに値がある場合、対応するファイルをロードします。したがって、一部のサードパーティ ライブラリのソース コードを見て入り口が見つからない場合は、package.json
のmain
フィールド (例:) を見てください。 jquery
のmain
フィールドは次のようになります:"main": "dist/jquery.js"
。
- が存在しない場合、または
package.json
にmain
がない場合は、index## を探します。 # ファイル。
これら 2 つのステップのどちらも見つからない場合は、エラーが報告されます。
- #サポートされるファイル タイプ
##。 js
.js実は、原理については以前に詳しく説明しましたが、ここからがハイライトであり、自分たちで実装することになります ## #必要とする###。ファイルは最も一般的に使用されるファイル タイプです。ロード時、最初に JS ファイル全体が実行され、次に前述の
- module.exports が実行されます。 require
ファイルは通常のテキスト ファイルです。の戻り値として使用されます。
.json
:
.json- JSON.parse を使用してオブジェクトに変換し、返します。それ。
ファイルは C でコンパイルされたバイナリ ファイルです。通常、純粋なフロントエンドがこのタイプに接触することはほとんどありません。.node
:
.node手書き
require
の実装は、実際には Node.js 全体のモジュール読み込みメカニズムを実装することです。解決する必要がある問題を見てみましょう:
受信したパス名ドキュメントを通じて、対応するモジュールを確認します。
見つかったファイルを実行し、同時に
module
requireNode.js モジュールの読み込み関数はすべてメソッドと属性を挿入して、モジュール ファイルを使用できるようにします。
- Return モジュールの
- module.exports
この記事の手書きコードはすべて Node.js 公式ソース コードと関数名を参照しています。変数名と変数名は可能な限り統一してください。実際にはソースコードの簡易版です。比較してください。具体的な方法を書き留める際には、対応するソースコードのアドレスも掲載します。コード全体は次のファイルにあります:
- https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js
Module class
Module クラスにあります。コード全体ではオブジェクト指向の考え方が使用されています。JS オブジェクト指向にあまり詳しくない場合は、次のことができます。最初にお読みください この記事を読んでください
。 Moduleクラスのコンストラクタも複雑ではありません。主にいくつかの値を初期化します。正式な
Module名と区別するために、独自のクラスは次のようになります。 MyModule: という名前<pre class="brush:js;toolbar:false">function MyModule(id = &#39;&#39;) {
this.id = id; // 这个id其实就是我们require的路径
this.path = path.dirname(id); // path是Node.js内置模块,用它来获取传入参数对应的文件夹路径
this.exports = {}; // 导出的东西放这里,初始化为空对象
this.filename = null; // 模块对应的文件名
this.loaded = false; // loaded用来标识当前模块是否已经加载
}</pre><h3 id="item-4-7">require方法</h3>
<p>我们一直用的<code>require
其实是Module
类的一个实例方法,内容很简单,先做一些参数检查,然后调用Module._load
方法,源码看这里:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L970。精简版的代码如下:
MyModule.prototype.require = function (id) { return Module._load(id); }
MyModule._load
是一个静态方法,这才是require
方法的真正主体,他干的事情其实是:
- 先检查请求的模块在缓存中是否已经存在了,如果存在了直接返回缓存模块的
exports
。- 如果不在缓存中,就
new
一个Module
实例,用这个实例加载对应的模块,并返回模块的exports
。
我们自己来实现下这两个需求,缓存直接放在Module._cache
这个静态变量上,这个变量官方初始化使用的是Object.create(null)
,这样可以使创建出来的原型指向null
,我们也这样做吧:
MyModule._cache = Object.create(null); MyModule._load = function (request) { // request是我们传入的路劲参数 const filename = MyModule._resolveFilename(request); // 先检查缓存,如果缓存存在且已经加载,直接返回缓存 const cachedModule = MyModule._cache[filename]; if (cachedModule !== undefined) { return cachedModule.exports; } // 如果缓存不存在,我们就加载这个模块 // 加载前先new一个MyModule实例,然后调用实例方法load来加载 // 加载完成直接返回module.exports const module = new MyModule(filename); // load之前就将这个模块缓存下来,这样如果有循环引用就会拿到这个缓存,但是这个缓存里面的exports可能还没有或者不完整 MyModule._cache[filename] = module; module.load(filename); return module.exports; }
上述代码对应的源码看这里:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L735
可以看到上述源码还调用了两个方法:MyModule._resolveFilename
和MyModule.prototype.load
,下面我们来实现下这两个方法。
MyModule._resolveFilename
MyModule._resolveFilename
从名字就可以看出来,这个方法是通过用户传入的require
参数来解析到真正的文件地址的,源码中这个方法比较复杂,因为按照前面讲的,他要支持多种参数:内置模块,相对路径,绝对路径,文件夹和第三方模块等等,如果是文件夹或者第三方模块还要解析里面的package.json
和index.js
。我们这里主要讲原理,所以我们就只实现通过相对路径和绝对路径来查找文件,并支持自动添加js
和json
两种后缀名:
MyModule._resolveFilename = function (request) { const filename = path.resolve(request); // 获取传入参数对应的绝对路径 const extname = path.extname(request); // 获取文件后缀名 // 如果没有文件后缀名,尝试添加.js和.json if (!extname) { const exts = Object.keys(MyModule._extensions); for (let i = 0; i < exts.length; i++) { const currentPath = `${filename}${exts[i]}`; // 如果拼接后的文件存在,返回拼接的路径 if (fs.existsSync(currentPath)) { return currentPath; } } } return filename; }
上述源码中我们还用到了一个静态变量MyModule._extensions
,这个变量是用来存各种文件对应的处理方法的,我们后面会实现他。
MyModule._resolveFilename
对应的源码看这里:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L822
MyModule.prototype.load
MyModule.prototype.load
是一个实例方法,这个方法就是真正用来加载模块的方法,这其实也是不同类型文件加载的一个入口,不同类型的文件会对应MyModule._extensions
里面的一个方法:
MyModule.prototype.load = function (filename) { // 获取文件后缀名 const extname = path.extname(filename); // 调用后缀名对应的处理函数来处理 MyModule._extensions[extname](this, filename); this.loaded = true; }
注意这段代码里面的this
指向的是module
实例,因为他是一个实例方法。对应的源码看这里: https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L942
前面我们说过不同文件类型的处理方法都挂载在MyModule._extensions
上面的,我们先来实现.js
类型文件的加载:
MyModule._extensions['.js'] = function (module, filename) { const content = fs.readFileSync(filename, 'utf8'); module._compile(content, filename); }
可以看到js
的加载方法很简单,只是把文件内容读出来,然后调了另外一个实例方法_compile
来执行他。对应的源码看这里:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L1098
MyModule.prototype._compile
是加载JS文件的核心所在,也是我们最常使用的方法,这个方法需要将目标文件拿出来执行一遍,执行之前需要将它整个代码包裹一层,以便注入exports, require, module, __dirname, __filename
,这也是我们能在JS文件里面直接使用这几个变量的原因。要实现这种注入也不难,假如我们require
的文件是一个简单的Hello World
,长这样:
module.exports = "hello world";
那我们怎么来给他注入module
这个变量呢?答案是执行的时候在他外面再加一层函数,使他变成这样:
function (module) { // 注入module变量,其实几个变量同理 module.exports = "hello world"; }
所以我们如果将文件内容作为一个字符串的话,为了让他能够变成上面这样,我们需要再给他拼接上开头和结尾,我们直接将开头和结尾放在一个数组里面:
MyModule.wrapper = [ '(function (exports, require, module, __filename, __dirname) { ', '\n});' ];
注意我们拼接的开头和结尾多了一个()
包裹,这样我们后面可以拿到这个匿名函数,在后面再加一个()
就可以传参数执行了。然后将需要执行的函数拼接到这个方法中间:
MyModule.wrap = function (script) { return MyModule.wrapper[0] + script + MyModule.wrapper[1]; };
这样通过MyModule.wrap
包装的代码就可以获取到exports, require, module, __filename, __dirname
这几个变量了。知道了这些就可以来写MyModule.prototype._compile
了:
MyModule.prototype._compile = function (content, filename) { const wrapper = Module.wrap(content); // 获取包装后函数体 // vm是nodejs的虚拟机沙盒模块,runInThisContext方法可以接受一个字符串并将它转化为一个函数 // 返回值就是转化后的函数,所以compiledWrapper是一个函数 const compiledWrapper = vm.runInThisContext(wrapper, { filename, lineOffset: 0, displayErrors: true, }); // 准备exports, require, module, __filename, __dirname这几个参数 // exports可以直接用module.exports,即this.exports // require官方源码中还包装了一层,其实最后调用的还是this.require // module不用说,就是this了 // __filename直接用传进来的filename参数了 // __dirname需要通过filename获取下 const dirname = path.dirname(filename); compiledWrapper.call(this.exports, this.exports, this.require, this, filename, dirname); }
上述代码要注意我们注入进去的几个参数和通过call
传进去的this
:
- this:
compiledWrapper
是通过call
调用的,第一个参数就是里面的this
,这里我们传入的是this.exports
,也就是module.exports
,也就是说我们js
文件里面this
是对module.exports
的一个引用。- exports:
compiledWrapper
正式接收的第一个参数是exports
,我们传的也是this.exports
,所以js
文件里面的exports
也是对module.exports
的一个引用。- require: 这个方法我们传的是
this.require
,其实就是MyModule.prototype.require
,也就是MyModule._load
。- module: 我们传入的是
this
,也就是当前模块的实例。- __filename:文件所在的绝对路径。
- __dirname: 文件所在文件夹的绝对路径。
到这里,我们的JS文件其实已经记载完了,对应的源码看这里:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L1043
加载json
文件就简单多了,只需要将文件读出来解析成json
就行了:
MyModule._extensions['.json'] = function (module, filename) { const content = fs.readFileSync(filename, 'utf8'); module.exports = JSONParse(content); }
exports
和module.exports
的区别网上经常有人问,node.js
里面的exports
和module.exports
到底有什么区别,其实前面我们的手写代码已经给出答案了,我们这里再就这个问题详细讲解下。exports
和module.exports
这两个变量都是通过下面这行代码注入的。
compiledWrapper.call(this.exports, this.exports, this.require, this, filename, dirname);
初始状态下,exports === module.exports === {}
,exports
是module.exports
的一个引用,如果你一直是这样使用的:
exports.a = 1; module.exports.b = 2; console.log(exports === module.exports); // true
上述代码中,exports
和module.exports
都是指向同一个对象{}
,你往这个对象上添加属性并没有改变这个对象本身的引用地址,所以exports === module.exports
一直成立。
但是如果你哪天这样使用了:
exports = { a: 1 }
或者这样使用了:
module.exports = { b: 2 }
那其实你是给exports
或者module.exports
重新赋值了,改变了他们的引用地址,那这两个属性的连接就断开了,他们就不再相等了。需要注意的是,你对module.exports
的重新赋值会作为模块的导出内容,但是你对exports
的重新赋值并不能改变模块导出内容,只是改变了exports
这个变量而已,因为模块始终是module
,导出内容是module.exports
。
Node.js对于循环引用是进行了处理的,下面是官方例子:
a.js
:
console.log('a 开始'); exports.done = false; const b = require('./b.js'); console.log('在 a 中,b.done = %j', b.done); exports.done = true; console.log('a 结束');
b.js
:
console.log('b 开始'); exports.done = false; const a = require('./a.js'); console.log('在 b 中,a.done = %j', a.done); exports.done = true; console.log('b 结束');
main.js
:
console.log('main 开始'); const a = require('./a.js'); const b = require('./b.js'); console.log('在 main 中,a.done=%j,b.done=%j', a.done, b.done);
当 main.js
加载 a.js
时, a.js
又加载 b.js
。 此时, b.js
会尝试去加载 a.js
。 为了防止无限的循环,会返回一个 a.js
的 exports
对象的 未完成的副本 给 b.js
模块。 然后 b.js
完成加载,并将 exports
对象提供给 a.js
模块。
那么这个效果是怎么实现的呢?答案就在我们的MyModule._load
源码里面,注意这两行代码的顺序:
MyModule._cache[filename] = module; module.load(filename);
上述代码中我们是先将缓存设置了,然后再执行的真正的load
,顺着这个思路我能来理一下这里的加载流程:
main
加载a
,a
在真正加载前先去缓存中占一个位置a
在正式加载时加载了b
b
又去加载了a
,这时候缓存中已经有a
了,所以直接返回a.exports
,即使这时候的exports
是不完整的。
require
不是黑魔法,整个Node.js的模块加载机制都是JS
实现的。exports, require, module, __filename, __dirname
五个参数都不是全局变量,而是模块加载的时候注入的。vm
来实现。this, exports, module.exports
都指向同一个对象,如果你对他们重新赋值,这种连接就断了。module.exports
的重新赋值会作为模块的导出内容,但是你对exports
的重新赋值并不能改变模块导出内容,只是改变了exports
这个变量而已,因为模块始终是module
,导出内容是module.exports
。exports
。本文完整代码已上传GitHub:https://github.com/dennis-jiang/Front-End-Knowledges/blob/master/Examples/Node.js/Module/MyModule/index.js
参考资料
Node.js模块加载源码:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js
Node.js模块官方文档:http://nodejs.cn/api/modules.html
文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。
作者博文GitHub项目地址: https://github.com/dennis-jiang/Front-End-Knowledges
更多编程相关知识,可访问:编程教学!!
以上がNode.js のモジュール読み込みメカニズムの詳細な分析の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。