作者:蒋鹏飞 来源:segmentfaul 模块是Node.js里面一个很基本也很重要的概念,各种原生类库是通过模块提供的,第三方库也是通过模块进行管理和引用的。本文会从基本的模块原理出发,到最后我们会利用这个原理,自己实现一个简单的模块加载机制,即自己实现一个require。 简单例子 老规矩,讲原理前我们先来一个简单的例子,从这个例子入手一步一步深入原理。Node.js里面如果要导出某个内容,需要使用module.exports,使用module.exports几乎可以导出任意类型的JS对象,包括字符串,函数,对象,数组等等。我们先来建一个a.js导出一个最简单的hello world: // a.js module.exports = "hello world"; 然后再来一个b.js导出一个函数: // b.js function add(a, b) { return a + b; } module.exports = add; 然后在index.js里面使用它们,即require它它们,require函数返回的结果就是对应文件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 require会先运行目标文件 当我们require某个模块时,并不是只拿他的module.exports,而是会从头开始运行这个文件,module.exports = XXX其实也只是其中一行代码,我们后面会讲到,这行代码的效果其实就是修改模块里面的exports属性。比如我们再来一个c.js: // c.js let c = 1; cc = c + 1; module.exports = c; c = 6; 在c.js里面我们导出了一个c,这个c经过了几步计算,当运行到module.exports = c;这行时c的值为2,所以我们require的c.js的值就是2,后面将c的值改为了6并不影响前面的这行代码: const c = require('./c.js'); console.log(c); // c的值是2 前面c.js的变量c是一个基本数据类型,所以后面的c = 6;不影响前面的module.exports,那他如果是一个引用类型呢?我们直接来试试吧: // d.js let d = { num: 1 }; d.num++; module.exports = d; d.num = 6; 然后在index.js里面require他: const d = require('./d.js'); console.log(d); // { num: 6 } 我们发现在module.exports后面给d.num赋值仍然生效了,因为d是一个对象,是一个引用类型,我们可以通过这个引用来修改他的值。其实对于引用类型来说,不仅仅在module.exports后面可以修改它的值,在模块外面也可以修改,比如index.js里面就可以直接改: const d = require('./d.js'); d.num = 7; console.log(d); // { num: 7 } require和module.exports不是黑魔法 我们通过前面的例子可以看出来,require和module.exports干的事情并不复杂,我们先假设有一个全局对象{},初始情况下是空的,当你require某个文件时,就将这个文件拿出来执行,如果这个文件里面存在module.exports,当运行到这行代码时将module.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()之类的方法。 模块类型和加载顺序 这一节的内容都是一些概念,比较枯燥,但是也是我们需要了解的。 模块类型 Node.js的模块有好几种类型,前面我们使用的其实都是文件模块,总结下来,主要有这两种类型: 加载顺序 加载顺序是指当我们require(X)时,应该按照什么顺序去哪里找X,在官方文档上有详细伪代码,总结下来大概是这么个顺序: 加载文件夹 前面提到找不到文件就找文件夹,但是不可能将整个文件夹都加载进来,加载文件夹的时候也是有一个加载顺序的: 2. 如果没有package.json或者package.json里面没有main就找index文件。 3. 如果这两步都找不到就报错了。 支持的文件类型 require主要支持三种文件类型: 手写require 前面其实我们已经将原理讲的七七八八了,下面来到我们的重头戏,自己实现一个require。实现require其实就是实现整个Node.js的模块加载机制,我们再来理一下需要解决的问题: 本文的手写代码全部参照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方法的真正主体,他干的事情其实是: 我们自己来实现下这两个需求,缓存直接放在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: 到这里,我们的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,顺着这个思路我能来理一下这里的加载流程: 总结 本文完整代码已上传GitHub:https://github.com/dennis-jiang/Front-End-Knowledges/blob/master/Examples/Node.js/Module/MyModule/index.js ?