模块系统是nodejs的基础,使用频率也很高。在使用nodejs过程中,以下几个关于模块系统的问题是否常常困扰着你:
- 为什么在模块中有全局的require、module.exports、exports、dirname、filename等关键字,它们是从哪来的?
- 为什么一定要使用module.exports或者exports导出模块信息?
- module.exports和exports的区别,它们之间的关系是什么?
接下来通过源码分析lib/module.js(opens new window)来解决这些困惑。
CommonJS规范
众所周知,nodejs是基于CommonJS规范来实现,CommonJS规范主要有以下几点内容:
- 每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。
- 每个模块内部,module变量代表当前模块。这个变量是一个对象,它的exports属性(即module.exports)是对外的接口。加载某个模块,其实是加载该模块的module.exports属性。
- require方法用于加载模块 ```json // moduleA.js module.exports = function( value ){ return value * 2; }
// moduleB.js var multiplyBy2 = require(‘./moduleA’); var result = multiplyBy2(4);
看以上定义内容我们知道,CommonJS规范规定了每个模块内部都有module变量表示当前模块,使用exports导出模块内容以及require导入模块,到具体源码上它是如何实现的呢?<a name="J9T5f"></a>## 源码分析先从引入模块require进行分析。```json// lib/internal/modules/cjs/loader.js// require方法挂载到Module原型链上Module.prototype.require = function(id) {return Module._load(id, this, /* isMain */ false);};Module._load = function(request, parent, isMain) {// 解析出完整绝对路径,request路径可能有多种形式// 1. 内部模块:require('http')// 2. 相对位置-文件:require('./module')// 3. 相对位置-文件夹:require('./module/')// 4. 绝对位置: require('/temp/module')var filename = Module._resolveFilename(request, parent, isMain);// 缓存处理,提升性能// 同时可以解决a、b模块互相依赖导致循环的问题// 因为只加载一次,第二次加载直接从缓存中读取,不用重新加载var cachedModule = Module._cache[filename];if (cachedModule) {return cachedModule.exports; // 导出的永远是module.exports的内容}// 先实例化一个空的modulevar module = new Module(filename, parent);Module._cache[filename] = module; // 存入缓存// 加载modulemodule.load(filename);// 问题2答案:导出的是exports内容return module.exports;};// 每个模块对应就是Module实例function Module(id, parent) {this.id = id; // 模块的识别符,通常是带有绝对路径的模块文件名this.exports = {}; // 模块对外输出的值this.parent = parent; // 返回一个对象,表示调用该模块的模块this.filename = null; // 模块的文件名,带有绝对路径this.loaded = false; // 是否已加载模块标记this.children = []; // 返回一个数组,表示该模块要用到的其他模块}module.exports = Module; // node内部源码使用的也是模块系统
1 . 可以看到require方法是绑定在Module类的原型链方法,说明只有获取到当前实例module才能调用require。而每个模块都可以拿到自己的当前实例module变量,它是如何把实例module注入到模块中的呢?答案是使用沙箱环境,以闭包函数的方式传入当前module,后续源码解读会有详细说明。
2 . node模块系统路径加载多种多样,有内置的、有从相对位置读取、有从绝对位置读取,加载详细规则可以看NodeJS官方文档 modules_file_modules(opens new window)。想了解具体实现原理可以看下Module._resolveFilename方法源码,该方法主要确定模块加载的绝对路径。了解该源码后,如下官方文档解释很容易理解:
- File Modules(opens new window)
- Folders as Modules#(opens new window)
- Loading from node_modules Folders(opens new window)
- Loading from the global folders(opens new window)
3 . 可以看到Module._load方法通过new Module()来创建一个空的module实例,然后通过原型方法module.load真正的去读取模块内容。注意return导出的是module.exports,这就解释了CommonJS规范中要求的最终导出的内容是module.exports(第二个问题答案)。至于exports是module.exports的简写,即exports = module.exports,下文会解释这关系。
Module.prototype.load = function(filename) {// module实例上可以拿到filename、paths属性this.filename = filename;this.paths = Module._nodeModulePaths(path.dirname(filename));// node引用模块可以默认不写后缀,顺序规则:.js、.json .nodevar extension = findLongestRegisteredExtension(filename);// 不同后缀的文件模块,使用不同的策略。Module._extensions[extension](this, filename);this.loaded = true; // 标记成模块已加载}Module._extensions['.js'] = function(module, filename) {var content = fs.readFileSync(filename, 'utf8');// 获得模块代码纯字符串,然后编译compile字符串代码// stripBOM方法作用是剥离 utf8 编码特有的BOM文件头module._compile(stripBOM(content), filename);};Module._extensions['.json'] = function(module, filename) {const content = fs.readFileSync(filename, 'utf8');// json后缀加载策略:把字符串JSON.parse解析成对象// 将对象赋值给module.exports,因为最终对外导出module.exportsmodule.exports = JSON.parse(stripBOM(content));};
再来看看Module.prototype.load做了什么。nodejs模块系统中是可以不带后缀的,他会根据.js,.json,.node的顺序规则去确定最终使用哪个文件。而不同后缀的文件模块加载策略是不一样的,json策略是把字符串JSON.parse解析成对应代码,通过module.exports导出供外部使用。js策略是使用module._compile方法处理,让我们看下_compile的源码。
Module.prototype._compile = function(content, filename) {// 将模块内容使用function包装起来const wrapper = Module.wrap(content);// 关键:通过内部vm模块方法,把string字符串代码,变成真正的可执行代码cosnt compiledWrapper = vm.runInThisContext(wrapper, {...})var dirname = path.dirname(filename);var require = makeRequireFunction(this); // 对外暴露的require api// 问题3答案:exports和module.exports的关系// 即exports = module.exports = {}var exports = this.exports;var thisValue = exports;var module = this; // 把当前实例传入// 问题1答案:在模块内部,拥有require、module、exports等全局变量// 原理是通过compiledWrapper.call执行函数,把这些内容传入到模块内部var result = compiledWrapper.call(thisValue, exports, require, module, filename, dirname);return result}// 最新版node使用Proxy,使得Module.wrap代理wrap对象Object.defineProperty(Module, 'wrap', {get() {return wrap;},set(value) {wrap = value;}});let wrap = function(script) {return Module.wrapper[0] + script + Module.wrapper[1];};const wrapper = ['(function (exports, require, module, __filename, __dirname) { ','\n});'];
Module.prototype._compile是整个模块加载的核心内容,其本质是将字符串源码拼接成闭包函数(通过VM模块的runInThisContext),注入exports、require、module等全局变量,再执行模块源码,将module的exports值输出。等同于如下代码:
(function (exports, require, module, __filename, __dirname) {// 模块内部定义代码const otherModule = require('./other') // 内部可以使用require、module等全局变量module.exports = function() {...} // 必须使用module.exports以导出本模块内容})(this.exports, this.require, this, filename, dirname)
了解以上源码后,如下官方文档解释很容易理解:
- The module wrapper(opens new window)
- The module scope(opens new window)
- exports shortcut(opens new window)
总结
- 模块加载,是通过沙箱方式,把字符串拼接成闭包函数的形式,把实例module、exports、require、filename、dirname以参数方式注入到环境变量中。
- 模块导出的内容是必须是module.exports的内容,exports是module.exports简写,指向同一块内存。
- exports = module.exports,但exports被覆盖时,exports被赋值的是一个新开辟的内存,不再指向module.exports。所以官网建议不要在模块内部直接覆盖exports,即不要写exports = …代码。
- 可以使用nodejs vm模块,将拼接字符串代码转可执行代码,解决一些非常规需求,如用户自定义执行函数、自定义Mock函数、自定义模块加载器等。
