模块系统是nodejs的基础,使用频率也很高。在使用nodejs过程中,以下几个关于模块系统的问题是否常常困扰着你:

  1. 为什么在模块中有全局的require、module.exports、exports、dirname、filename等关键字,它们是从哪来的?
  2. 为什么一定要使用module.exports或者exports导出模块信息?
  3. module.exports和exports的区别,它们之间的关系是什么?

接下来通过源码分析lib/module.js(opens new window)来解决这些困惑。


CommonJS规范

众所周知,nodejs是基于CommonJS规范来实现,CommonJS规范主要有以下几点内容:

  1. 每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。
  2. 每个模块内部,module变量代表当前模块。这个变量是一个对象,它的exports属性(即module.exports)是对外的接口。加载某个模块,其实是加载该模块的module.exports属性。
  3. require方法用于加载模块 ```json // moduleA.js module.exports = function( value ){ return value * 2; }

// moduleB.js var multiplyBy2 = require(‘./moduleA’); var result = multiplyBy2(4);

  1. 看以上定义内容我们知道,CommonJS规范规定了每个模块内部都有module变量表示当前模块,使用exports导出模块内容以及require导入模块,到具体源码上它是如何实现的呢?
  2. <a name="J9T5f"></a>
  3. ## 源码分析
  4. 先从引入模块require进行分析。
  5. ```json
  6. // lib/internal/modules/cjs/loader.js
  7. // require方法挂载到Module原型链上
  8. Module.prototype.require = function(id) {
  9. return Module._load(id, this, /* isMain */ false);
  10. };
  11. Module._load = function(request, parent, isMain) {
  12. // 解析出完整绝对路径,request路径可能有多种形式
  13. // 1. 内部模块:require('http')
  14. // 2. 相对位置-文件:require('./module')
  15. // 3. 相对位置-文件夹:require('./module/')
  16. // 4. 绝对位置: require('/temp/module')
  17. var filename = Module._resolveFilename(request, parent, isMain);
  18. // 缓存处理,提升性能
  19. // 同时可以解决a、b模块互相依赖导致循环的问题
  20. // 因为只加载一次,第二次加载直接从缓存中读取,不用重新加载
  21. var cachedModule = Module._cache[filename];
  22. if (cachedModule) {
  23. return cachedModule.exports; // 导出的永远是module.exports的内容
  24. }
  25. // 先实例化一个空的module
  26. var module = new Module(filename, parent);
  27. Module._cache[filename] = module; // 存入缓存
  28. // 加载module
  29. module.load(filename);
  30. // 问题2答案:导出的是exports内容
  31. return module.exports;
  32. };
  33. // 每个模块对应就是Module实例
  34. function Module(id, parent) {
  35. this.id = id; // 模块的识别符,通常是带有绝对路径的模块文件名
  36. this.exports = {}; // 模块对外输出的值
  37. this.parent = parent; // 返回一个对象,表示调用该模块的模块
  38. this.filename = null; // 模块的文件名,带有绝对路径
  39. this.loaded = false; // 是否已加载模块标记
  40. this.children = []; // 返回一个数组,表示该模块要用到的其他模块
  41. }
  42. module.exports = Module; // node内部源码使用的也是模块系统

1 . 可以看到require方法是绑定在Module类的原型链方法,说明只有获取到当前实例module才能调用require。而每个模块都可以拿到自己的当前实例module变量,它是如何把实例module注入到模块中的呢?答案是使用沙箱环境,以闭包函数的方式传入当前module,后续源码解读会有详细说明。
2 . node模块系统路径加载多种多样,有内置的、有从相对位置读取、有从绝对位置读取,加载详细规则可以看NodeJS官方文档 modules_file_modules(opens new window)。想了解具体实现原理可以看下Module._resolveFilename方法源码,该方法主要确定模块加载的绝对路径。了解该源码后,如下官方文档解释很容易理解:

3 . 可以看到Module._load方法通过new Module()来创建一个空的module实例,然后通过原型方法module.load真正的去读取模块内容。注意return导出的是module.exports,这就解释了CommonJS规范中要求的最终导出的内容是module.exports(第二个问题答案)。至于exports是module.exports的简写,即exports = module.exports,下文会解释这关系。

  1. Module.prototype.load = function(filename) {
  2. // module实例上可以拿到filenamepaths属性
  3. this.filename = filename;
  4. this.paths = Module._nodeModulePaths(path.dirname(filename));
  5. // node引用模块可以默认不写后缀,顺序规则:.js、.json .node
  6. var extension = findLongestRegisteredExtension(filename);
  7. // 不同后缀的文件模块,使用不同的策略。
  8. Module._extensions[extension](this, filename);
  9. this.loaded = true; // 标记成模块已加载
  10. }
  11. Module._extensions['.js'] = function(module, filename) {
  12. var content = fs.readFileSync(filename, 'utf8');
  13. // 获得模块代码纯字符串,然后编译compile字符串代码
  14. // stripBOM方法作用是剥离 utf8 编码特有的BOM文件头
  15. module._compile(stripBOM(content), filename);
  16. };
  17. Module._extensions['.json'] = function(module, filename) {
  18. const content = fs.readFileSync(filename, 'utf8');
  19. // json后缀加载策略:把字符串JSON.parse解析成对象
  20. // 将对象赋值给module.exports,因为最终对外导出module.exports
  21. module.exports = JSON.parse(stripBOM(content));
  22. };

再来看看Module.prototype.load做了什么。nodejs模块系统中是可以不带后缀的,他会根据.js,.json,.node的顺序规则去确定最终使用哪个文件。而不同后缀的文件模块加载策略是不一样的,json策略是把字符串JSON.parse解析成对应代码,通过module.exports导出供外部使用。js策略是使用module._compile方法处理,让我们看下_compile的源码。

  1. Module.prototype._compile = function(content, filename) {
  2. // 将模块内容使用function包装起来
  3. const wrapper = Module.wrap(content);
  4. // 关键:通过内部vm模块方法,把string字符串代码,变成真正的可执行代码
  5. cosnt compiledWrapper = vm.runInThisContext(wrapper, {...})
  6. var dirname = path.dirname(filename);
  7. var require = makeRequireFunction(this); // 对外暴露的require api
  8. // 问题3答案:exportsmodule.exports的关系
  9. // exports = module.exports = {}
  10. var exports = this.exports;
  11. var thisValue = exports;
  12. var module = this; // 把当前实例传入
  13. // 问题1答案:在模块内部,拥有requiremoduleexports等全局变量
  14. // 原理是通过compiledWrapper.call执行函数,把这些内容传入到模块内部
  15. var result = compiledWrapper.call(thisValue, exports, require, module, filename, dirname);
  16. return result
  17. }
  18. // 最新版node使用Proxy,使得Module.wrap代理wrap对象
  19. Object.defineProperty(Module, 'wrap', {
  20. get() {
  21. return wrap;
  22. },
  23. set(value) {
  24. wrap = value;
  25. }
  26. });
  27. let wrap = function(script) {
  28. return Module.wrapper[0] + script + Module.wrapper[1];
  29. };
  30. const wrapper = [
  31. '(function (exports, require, module, __filename, __dirname) { ',
  32. '\n});'
  33. ];

Module.prototype._compile是整个模块加载的核心内容,其本质是将字符串源码拼接成闭包函数(通过VM模块的runInThisContext),注入exports、require、module等全局变量,再执行模块源码,将module的exports值输出。等同于如下代码:

  1. (function (exports, require, module, __filename, __dirname) {
  2. // 模块内部定义代码
  3. const otherModule = require('./other') // 内部可以使用requiremodule等全局变量
  4. module.exports = function() {...} // 必须使用module.exports以导出本模块内容
  5. })(this.exports, this.require, this, filename, dirname)

了解以上源码后,如下官方文档解释很容易理解:

  1. 模块加载,是通过沙箱方式,把字符串拼接成闭包函数的形式,把实例module、exports、require、filename、dirname以参数方式注入到环境变量中。
  2. 模块导出的内容是必须是module.exports的内容,exports是module.exports简写,指向同一块内存。
  3. exports = module.exports,但exports被覆盖时,exports被赋值的是一个新开辟的内存,不再指向module.exports。所以官网建议不要在模块内部直接覆盖exports,即不要写exports = …代码。
  4. 可以使用nodejs vm模块,将拼接字符串代码转可执行代码,解决一些非常规需求,如用户自定义执行函数、自定义Mock函数、自定义模块加载器等。