Maison >interface Web >js tutoriel >Une analyse approfondie du mécanisme de chargement des modules de Node.js
Le module est un concept très basique et important dans Node.js. Diverses bibliothèques de classes natives sont fournies via des modules, et des bibliothèques tierces sont également gérées et référencées via des modules. Cet article partira du principe de base du module, et à la fin nous utiliserons ce principe pour implémenter nous-mêmes un mécanisme de chargement de module simple, c'est-à-dire implémenter un require
par nous-mêmes.
Node utilise les modules JavaScript et commonjs, et utilise npm/yarn comme gestionnaire de packages.
[Recommandation tutoriel vidéo : tutoriel node js]
Anciennes règles, prenons un exemple simple avant d'expliquer le principe, de This Cet exemple commence par une compréhension approfondie, étape par étape, des principes. Si vous souhaitez exporter certains contenus dans Node.js, vous devez utiliser module.exports
. L'utilisation de module.exports
peut exporter presque n'importe quel type d'objet JS, y compris des chaînes, des fonctions, des objets, des tableaux, etc. Construisons d'abord un a.js
pour exporter le hello world
le plus simple:
// a.js module.exports = "hello world";
, puis créons un b.js
pour exporter une fonction:
// b.js function add(a, b) { return a + b; } module.exports = add;
puis utilisons-les dans index.js
, Autrement dit, require
eux, le résultat renvoyé par la fonction require
est la valeur du fichier correspondant module.exports
:
// index.js const a = require('./a.js'); const add = require('./b.js'); console.log(a); // "hello world" console.log(add(1, 2)); // b导出的是一个加法函数,可以直接使用,这行结果是3
lorsque nous require
un certain module, au lieu de simplement prendre son module.exports
, il exécutera le fichier à partir de zéro module.exports = XXX
n'est en fait qu'une seule ligne de code. Comme nous en parlerons plus tard, l'effet de cette ligne de code. consiste en fait à modifier le exports
dans la propriété du module. Par exemple, prenons un autre c.js
:
// c.js let c = 1; c = c + 1; module.exports = c; c = 6;
Dans c.js
, nous avons exporté un c
Ce c
a traversé plusieurs étapes de calcul lors de l'exécution vers la ligne module.exports = c;
<.> La valeur de est c
, donc la valeur de 2
de notre require
est c.js
Changer la valeur de 2
en c
n'affecte pas la ligne de code précédente : 6
.
const c = require('./c.js'); console.log(c); // c的值是2La variable
dans le c.js
précédent est un type de données de base, donc le c
suivant n'affecte pas le c = 6;
précédent. Et s'il s'agissait d'un type de référence ? Essayons-le directement : module.exports
// d.js let d = { num: 1 }; d.num++; module.exports = d; d.num = 6;Puis
à l'intérieur de index.js
: require
const d = require('./d.js'); console.log(d); // { num: 6 }Nous avons constaté que l'attribution d'une valeur à
après module.exports
prend toujours effet, car d.num
C'est un objet et un type référence. Nous pouvons modifier sa valeur grâce à cette référence. En fait, pour les types référence, non seulement sa valeur peut être modifiée après d
, mais elle peut également être modifiée en dehors du module. Par exemple, elle peut être modifiée directement à l'intérieur de module.exports
: index.js
const d = require('./d.js'); d.num = 7; console.log(d); // { num: 7 }<.> et
require
module.exports
Comme nous pouvons le voir dans l'exemple précédent, ce que font n'est pas compliqué Supposons d'abord qu'il existe un objet global require
, qui est vide au départ. , lorsque vous module.exports
un certain fichier, retirez le fichier et exécutez-le. S'il y a {}
dans ce fichier, lorsque cette ligne de code est exécutée, ajoutez la valeur de require
à cela. objet, et la clé est le nom du fichier correspondant. Au final, l'objet ressemblera à ceci : module.exports
{ "a.js": "hello world", "b.js": function add(){}, "c.js": 2, "d.js": { num: 2 } }
module.exports
Lorsque vous un fichier, s'il y a une valeur correspondante dans l'objet, il vous sera renvoyé directement. Sinon, répétez les étapes précédentes Exécutez le fichier cible, puis ajoutez son à cet objet global et renvoyez-le à l'appelant. Cet objet global est en fait le cache dont on entend souvent parler. require
Il n'y a donc pas de magie noire dans module.exports
et Ils s'exécutent simplement et obtiennent la valeur du fichier cible, puis l'ajoutent au cache et le suppriment simplement en cas de besoin. require
Regardez cet objet, car module.exports
est un type référence, vous pouvez donc modifier sa valeur partout où vous obtenez cette référence. Si vous ne souhaitez pas que la valeur de votre module soit modifiée, vous devez écrire le. modulez vous-même le traitement, comme l'utilisation de , d.js
et d'autres méthodes. Object.freeze()
Object.defineProperty()
Type de module et ordre de chargement
Type de module
.
- Modules intégrés : Ce sont les fonctions fournies nativement par Node.js, comme
fs
,http
, etc. Ces modules sont chargés lorsque le Node.js Le processus .js démarre.- Module de fichiers : Les différents modules que nous avons écrits plus tôt, ainsi que les modules tiers, c'est-à-dire les modules ci-dessous
node_modules
sont tous des modules de fichiers.
L'ordre de chargement fait référence à l'ordre dans lequel on doit chercher require(X)
quand on X
, il y a dans le document officiel Le pseudocode détaillé peut être résumé comme suit :
- Les modules intégrés sont chargés en premier Même s'il existe un fichier du même nom, le module intégré le sera. utilisé en premier.
- Ce n'est pas un module intégré, allez d'abord dans le cache pour le trouver.
- S'il n'y a pas de cache, recherchez le fichier avec le chemin correspondant.
- Si le fichier correspondant n'existe pas, ce chemin sera chargé sous forme de dossier. Si vous ne trouvez pas les fichiers et dossiers correspondant à
- , accédez à
node_modules
et recherchez-les ci-dessous.- Une erreur a été signalée avant de pouvoir être trouvée.
Comme mentionné précédemment, si vous ne trouvez pas le fichier, recherchez le dossier, mais il est impossible de charger l'intégralité du dossier, et le il en va de même lors du chargement d'un dossier. Il existe une séquence de chargement :
- Vérifiez d'abord s'il y a un
package.json
sous ce dossier. S'il y en a, recherchez le champmain
. à l'intérieur. Si le champmain
a une valeur, chargez-le. Donc, si vous ne trouvez pas l'entrée en regardant le code source de certaines bibliothèques tierces, regardez simplement le champpackage.json
dans sonmain
. Par exemple, le champjquery
demain
est comme. ceci :"main": "dist/jquery.js"
.- S'il n'y a pas de
package.json
ou s'il n'y a pas depackage.json
dansmain
, recherchez le fichierindex
.- S'il est introuvable lors de ces deux étapes, une erreur sera signalée.
require
Prend principalement en charge trois types de fichiers :
- .js : Le fichier
.js
est notre type de fichier le plus couramment utilisé lors du chargement, l'intégralité du fichier JS sera exécuté en premier, puis lemodule.exports
mentionné précédemment sera utilisé comme valeur de retour derequire
.- .json : Le fichier
.json
est un fichier texte ordinaire. Utilisez simplementJSON.parse
pour le convertir en objet et le renvoyer.- .node : Le fichier
.node
est un fichier binaire compilé en C++. Les frontaux purs entrent généralement rarement en contact avec ce type.
require
En fait, nous avons déjà expliqué le principe en détail, vient maintenant notre point culminant, en mettre en œuvre une par nous-mêmesrequire
. Implémenter require
consiste en fait à implémenter le mécanisme de chargement de module de l'ensemble de Node.js. Jetons un coup d'œil aux problèmes qui doivent être résolus :
- Recherchez le fichier correspondant via le fichier correspondant. nom du chemin entrant.
- exécute le fichier trouvé, et injecte en même temps
module
etrequire
ces méthodes et attributs pour une utilisation par le fichier du module.- Module de retour
module.exports
Le code manuscrit de cet article fait tous référence au code source officiel de Node.js. Les noms de fonctions et les noms de variables doivent rester cohérents. autant que possible. En fait, c'est une version simplifiée du code source, vous pouvez le vérifier. Lorsque j'écrirai la méthode spécifique, je publierai également l'adresse du code source correspondant. Le code global est dans ce fichier : https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js
Les fonctions de chargement du module Node.js sont toutes dans la classe Module
L'ensemble du code utilise la pensée orientée objet Si vous n'êtes pas très familier avec l'orientation objet JS, vous pouvez d'abord lire cet article. . . Le constructeur de la classe Module
n'est pas compliqué non plus. Il initialise principalement certaines valeurs Afin de la distinguer du nom officiel Module
, notre propre classe s'appelle MyModule
:
function MyModule(id = '') { this.id = id; // 这个id其实就是我们require的路径 this.path = path.dirname(id); // path是Node.js内置模块,用它来获取传入参数对应的文件夹路径 this.exports = {}; // 导出的东西放这里,初始化为空对象 this.filename = null; // 模块对应的文件名 this.loaded = false; // loaded用来标识当前模块是否已经加载 }
我们一直用的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
更多编程相关知识,可访问:编程教学!!
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!