CommonJs模块中为何exports = 1导出是一个空对象

2,740 阅读3分钟

exports 和 module.exports的常用方式

当我们想在一个a.js中暴露一个常量a, 可以通过exports.a = 10或者module.exports = {a: 10}, b.js引入常量a的时候:

b.js:
const { a } = require('./a'); ## 对应exports.a = 10暴露方式
console.log(a); ##  输出10
const a = require('./a').a; ## 对应module.exports = {a: 10}
console.log(a); ##  输出10
## 上面的 const a = require('./a').a 使用对象的解构赋值等价于 const { a } = require('./a');

exports 和 module.exports的区别

一个标准化解析

var module = { exports: {} };
var exports = module.exports;
return module.exports;

可以看出:

  • 最终导出的是module.exports
  • exports就是module.exports的一个引用

exports = 1 暴露出来是一个空对象?

从上面的return module.exports, 当exports指向了一个新的变量, 已经和module.exports断开连接关系, module.exports不受任何影响

var module = { exports: {} };
var exports = module.exports;
exports.a = 1; ## module.exportsexports还是同一个引用, 此时module = { exports: { a: 1} };
exports = 1; ## 此时module.exports还是指向 { a:1 }, 并不受影响
return module.exports;

同理如过modules.exports = 1, 也会断开它和exports之间的联系, 导致exports失去意义, 所以一个模块只能存在一个暴露方式, 意思是module.exports会覆盖exports, 因为最终是返回module.exports

a.js:
export.a = 10;
module.exports = 1;

b.js:
const b = require('./a.js')
console.log(b); ## 输出 1;

模拟node的require函数实现加深理解exports 和 module.exports

来看看node对require函数的描述

require(id) id module name or path Returns: exported module content
Description: Used to import modules, JSON, and local files. Modules can be imported from node_modules. Local modules and JSON files can be imported using a relative path (e.g. ./, ./foo, ./bar/baz, ../foo) that will be resolved against the directory named by __dirname (if defined) or the current working directory.

require函数接受一个参数, 模块名称或者路径, 返回模块的内容。require函数用于导出模块, JSON和本地文件。模块可以从node_modules中导入, 本地文件和JSON可以通过相对路径(./, ./foo, ./bar/baz, ../foo)等导出, 相对路径最终会被path模块resolve为文件所处位置的绝对路径。

简单模拟require的实现:

  • 不考虑__filename和__dirname
  • 不考虑requrei.cache缓存
  • 不考虑循环引用
_require.js

function _require(dir) {
    ## 定义一个module对象
    var module = {
        exports: {}
    };
 
    ## 引入nodejs 文件模块 下面是nodejs中原生的require方法
    var fs = require('fs');
    var path = require('path');
    ## 同步读取该文件
    var moduleContent = fs.readFileSync(path.resolve(__dirname, dir), 'utf8');
    ## 处理本地文件为.json
    var ext = path.extname(path.resolve(__dirname, dir));
    var addModuleExports = ext === '.json' ? 'module.exports=' : '';
    ## 头尾拼接包装成新的字符串
    var packFuncStr = '(function(module,exports){ ' + addModuleExports + moduleContent + '};)';
    ## 字符串转换成包装函数
    var packFunc = eval(packFuncStr);
    
    ## 把上面声明module和它内部的module.exports都作为参数传进去 
    packFunc.call(module.exports, module, module.exports); 
    ## packFunc第二个参数已经指明了exports = module.exports
    ## packFunc执行后上面的声明的modeule对象得到 moduleContent 中挂载到module.exportsexports上的API

    return module.exports; 
    # 返回module.exports, 最终我们拿到了path代表的文件模块暴露的API
}
module.exports = _require;

测试 _require

demo
|____ a.js
|____ b.js
|____ _require.js
|____ c.json

a.js:
export.a = 10;

c.json:
{
    "name": 'tftoy'
}

b.js:
const _require = require('./_require.js');
const { a } = _require('./a.js');
const c = _require('./c.json');
console.log(a); ## 输出10;
console.log(c); ## 输出{ name: 'tftoy' };
// 测试结果, 符合预期

我们来分析下整个过程 当_require('./a.js')发生以下事情:

  1. fs模块读取文件a.js
  2. 拼接成包装函数字符串
  3. eval转换为包装函数
  4. 执行包装函数
  5. 返回module.exports
    _require('./a.js')
    
    module = {
        exports: {}
    };
    ## 文件内容
    moduleContent = 'exports.a = 10;';
    ## 文件后缀
    ext = '.js'
    addModuleExports = '';
    ## 包装函数字符串
    packFuncStr = '(function (module,exports){ ' + '' + 'exports.a = 10;' + '};)'
    ## 包装函数
    packFunc = function (module,exports) { exports.a = 10;};
    ## 执行包装函数
    packFunc.call(module.exports, module, module.exports); 
    ## 返回结果
    return { a: 10 };
    因此_require('./a.js') => { a: 10 };

同理_require('./c.json')也是这么一个过程。

总结

  • 介绍了exportsmodule.exports的用法
  • exportsmodule.exports的关系、区别
  • 简单实现了require函数

上述_require只是实现了一些基本功能, 还有很多场景没有考虑, 感兴趣的可以去看看webpack对require函数的实现, 它可以处理CommonJs模块和Es6模块, 但是require函数的思想是差不多的, 模块最后都是被装在一个函数字符串中 eval("__webpack_require__(/*! ./index.js */)中。