深入浅析Node.js的模块加载机制
手写require
前面其实我们已经将道理讲的七七八八了,下面来到我们的重头戏,本人实现一个require
。实现require
其实就是实现整个Node.js的模块加载机制,我们再来理一下需要解决的问题:
- 通过传入的途径名寻到对应的文件。
- 施行寻到的文件,同时要注入
module
和require
这些办法和属性,以便模块文件使用。- 返回模块的
module.exports
本文的手写代码全部参照Node.js官方源码,函数名和变量名尽量保持一致,其实就是精简版的源码,大家可以对比着看,写到详细办法时我也会贴上对应的源码地址。总体的代码都在这个文件里面:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js
Module类
Node.js模块加载的功效全部在Module
类里面,整个代码使用面向对象的思想,假如你对JS的面向对象还不是很熟知可以先看看这篇文章。Module
类的结构函数也不复杂,主如果一些值的初始化,为了跟官方Module
名字区分开,我们本人的类命名为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办法
我们不断用的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
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
加载js文件: MyModule._extensions['.js']
前面我们说过不一样文件类型的处置办法都挂载在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
编译施行js文件:MyModule.prototype._compile
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文件: MyModule._extensions['.json']
加载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
。 - Node.js实现的这套加载机制叫CommonJS。
本文完全代码已上传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