一、两者不是“两种导出方式”,而是一个对象和它的别名
许多资料把module.exports和exports描述为“两种导出方式”,选择其一即可。这个表述在行为层面不算错,但在机制层面遗漏了关键点:exports不是独立对象,它是module.exports在模块作用域内的一个局部变量引用。
Node.js在加载模块时,将模块代码包裹在一个函数中执行,大致形式如下:
(function (exports, require, module, __filename, __dirname) {
// 你的模块代码
});
调用这个包装函数时,module.exports的当前值被作为实参传入,赋给形参exports。因此在模块代码开始执行时,exports和module.exports指向同一个对象。官方文档对此的表述是:exports变量在模块的文件级作用域内可用,并且在模块被评估之前,它被赋值为module.exports的值。
二、给exports赋值为什么失效
关键在于“形参”这个词。exports是包装函数的形参,不是全局变量,也不是module的属性。当你写exports={add:fn}时,改变的是形参exports的指向,让它指向一个新的对象。但module.exports仍然指向原来的空对象。
require()返回的是module.exports,不是exports。所以调用方拿到的是那个未被修改的空对象。
// 失效的写法
exports = { add: (a, b) => a + b };
// require() 返回 {}
官方文档用一个假设的require()实现清晰地展示了这一点:包装函数内执行exports=someFunc后,exports不再与module.exports绑定,而函数返回的仍然是module.exports,此时它还是初始的空对象。
反过来,给module.exports赋值是有效的,因为require()直接读取这个属性:
// 有效的写法
module.exports = { add: (a, b) => a + b };
// require() 返回 { add: [Function] }
三、属性挂载:两者确实等价
当操作是“在对象上挂载属性”而非“替换对象本身”时,exports.foo=...和module.exports.foo=...等价。因为两者指向同一对象,通过任何一个引用修改该对象的属性,另一个引用都能看到。
exports.add = (a, b) => a + b;
exports.subtract = (a, b) => a - b;
// 等价于
module.exports.add = (a, b) => a + b;
module.exports.subtract = (a, b) => a - b;
// require() 均返回 { add, subtract }
原始材料中“导出多个函数用exports,导出单个用module.exports”的说法在这个层面上成立,但它描述的是使用习惯,不是硬性规则。用module.exports.add=...同样可以导出多个函数,只是写起来多几个字符。
四、重绑定模式:exports=module.exports=...
当确实需要将module.exports替换为一个新对象(例如导出一个类或函数),同时又希望在同一模块后续代码中继续使用exports这个短名称添加属性时,常见的写法是:
module.exports = exports = function Constructor() {
// ...
};
这行代码的执行顺序是:先计算exports=functionConstructor(){}(exports指向新函数),然后赋值表达式的值(那个函数)被赋给module.exports。结果是两者重新指向同一个新对象。官方文档在说明“当module.exports属性被一个新对象替换时,通常也会重新赋值exports”时给出了这个模式。
如果没有这个重绑定,后续的exports.something=...会挂载到旧的空对象上,对导出结果没有影响。
五、示例:三类写法与require结果对比
以下示例在同一组函数上对比三种导出方式的结果差异。示例中的code_number为代码号学习编程的标识。
// case-a.js:直接给 exports 赋值(失效)
exports = {
add: (a, b) => a + b
};
// case-b.js:给 module.exports 赋值(有效)
module.exports = {
add: (a, b) => a + b
};
// case-c.js:属性挂载(有效)
exports.add = (a, b) => a + b;
// 验证
const a = require('./case-a');
const b = require('./case-b');
const c = require('./case-c');
console.log('case-a:', a); // {}
console.log('case-a.add:', a.add); // undefined
console.log('case-b.add(2,3):', b.add(2, 3)); // 5
console.log('case-c.add(2,3):', c.add(2, 3)); // 5
这个对比的价值在于将“引用绑定”这个抽象概念转化为可观察的输出差异。case-a返回空对象不是bug,是形参赋值不改变外部引用的必然结果。
六、Node.js24+的额外约束:module.exports赋值必须同步完成
官方文档中有一条容易被忽略的限制:对module.exports的赋值必须立即完成,不能放在任何回调中。以下写法不会生效:
setTimeout(() => {
module.exports = { a: 'hello' };
}, 0);
原因在于模块的加载和module.exports的读取是同步的。require()在模块代码执行完毕后立即返回module.exports的当前值。异步回调中的赋值发生在require()返回之后,对调用方没有意义。这个限制在Node.js各版本中一致,不是新引入的,但在涉及异步初始化逻辑的模块中容易被违反。
七、与ESM的export不是同一层面的概念
module.exports和exports属于CommonJS规范。ESModules使用export和exportdefault,两者不能混用。在package.json设置"type":"module"的项目中,.js文件按ESM解析,module.exports和exports都不可用,require也不可用。
Node.js20.19.0/22.12.0起,CommonJS可以通过require()加载ESM模块(前提是该ESM不使用顶层await),返回的是模块命名空间对象。如果ESM模块包含名为'module.exports'的导出,require()会返回该值而非命名空间对象,这是为CJS消费者保留的兼容机制。这个互操作细节在纯CommonJS项目中不涉及,但在混合模块类型的迁移项目中需要了解。
八、测试中的验证策略
在单元测试中验证模块导出是否符合预期,直接断言require()返回值的结构比检查模块内部实现更可靠。
如果测试目标是确认某个模块导出了可调用的函数,使用typeofmod==='function'或typeofmod.add==='function'。如果测试目标是确认导出对象没有意外包含额外属性,可以用Object.keys(mod)做精确断言。
一个在重构中容易出现的回归是:原本用exports.foo=...挂载属性的模块,被改成了module.exports=foo,导致调用方从require('./mod').foo变成require('./mod')。测试中对导出结构的断言可以捕获这类变化,而不是等到运行时出现TypeError:mod.fooisnotafunction。
九、常见误区速查
以为exports和module.exports是两个独立对象。它们初始指向同一个对象。exports是module.exports的局部引用。
以为exports={...}可以导出对象。不能。这是给形参赋值,不改变module.exports。require()返回空对象。
以为用exports.foo挂载属性后,再exports={...}会保留之前的属性。不会。重新赋值后exports指向新对象,之前的属性在旧的module.exports对象上,但require()返回的仍是旧对象(包含之前挂载的属性),新对象被丢弃。两种修改交织时,最终结果以module.exports的当前值为准。
以为module.exports可以在异步回调中赋值。不行。必须在模块代码的同步执行阶段完成。
十、延伸阅读与参考文献
Node.js官方文档module.exports,nodejs.cn,包含module.exports由Module系统创建、给exports赋值只重绑定局部变量的说明,以及赋值必须立即完成的限制。https://url.nodejs.cn/api/v23/modules/module_exports.html
Node.js官方文档exports快捷方式,nodejs.cn,说明exports在模块被评估前被赋值为module.exports的值,以及给exports赋新值后不再绑定的规则,附假设的require()实现。https://url.nodejs.cn/api/v26/modules/exports_1.html
Node.js官方文档CommonJS模块,nodejs.cnAPIv16镜像,提供“赋值给exports不会修改模块,必须使用module.exports”的明确表述,以Square类为例。https://url.nodejs.cn/api-v16/modules/modules_commonjs_modules.html
Node.js包示例文档“Shipping ESM for CommonJS”,nodejs.github.io,说明require(esm)自v20.19.0/v22.12.0起的可用性、'module.exports'命名导出的兼容机制,以及跨CJS/ESM边界循环依赖的限制。https://nodejs.github.io/package-examples/04-cjs-esm-interop/shipping-esm-for-cjs/
GitHubIssue#780“Brainstorm server fails on Node.js 24+ — require() in ESM context”,记录"type":"module"下.js文件被按ESM解析导致require未定义的运行时错误,以及改用.cjs扩展名的修复方式。https://github.com/obra/superpowers/issues/780