
这篇文章从CommonJS规范的底层逻辑切入,拆解exports与module.exports的引用关系,对比两者在“直接赋值”“对象扩展”等场景下的核心差异;再结合实际开发场景(比如导出单个函数、多个变量),讲清什么时候该用exports、什么时候必须用module.exports,手把手帮你避开模块导出里的“隐形坑”。不管是刚入门的新手,还是偶尔混淆的老开发者,读完都能彻底搞懂模块导出的核心逻辑,再也不用为“导不出”的问题头疼。
你有没有过这种情况?刚学Node.js写模块,明明在文件里写了exports.hello = 'world'
,结果导入的时候console.log
出来却是空对象?或者更崩溃的——你直接给exports
赋值成一个函数,结果导入的时候根本拿不到?我去年帮朋友排查过好多次这种问题,发现大家踩的坑其实都一样:没搞懂exports
和module.exports
的底层关系。今天我用大白话把这事讲透,你跟着做,以后再也不会在导出上栽跟头。
先搞懂CommonJS的底层逻辑,不然永远绕不开坑
要明白exports
和module.exports
的区别,得先扒开Node.js用的CommonJS规范的底层逻辑——其实每个.js
文件在Node里都是一个“独立模块”,Node会自动给每个模块套一层“包装函数”,大概长这样:
(function(exports, require, module, __filename, __dirname) {
// 你写的代码在这里
})();
看清楚没?exports
和module
都是这个函数的参数——module
是Node给每个模块的“身份对象”,里面有个exports
属性,专门用来装这个模块要导出的东西;而exports
参数,其实就是module.exports
的引用(别名)。打个比方:module.exports
是个空盒子,exports
是一根指向这个盒子的绳子,你拽着绳子往盒子里放东西(比如exports.foo = 'bar'
),就等于直接往盒子里放;但如果你把绳子绑到另一个盒子上(比如exports = { foo: 'bar' }
),那原来的盒子里还是空的——这就是为什么直接给exports
赋值会失效的原因!
去年我帮一个刚学Node.js的朋友排查问题,他写了个生成随机数的工具函数:
// random.js
exports = function() {
return Math.random();
};
然后在另一个文件里导入:
// app.js
const random = require('./random');
console.log(random); // undefined
他盯着代码看了半小时,问我“我明明导出了函数啊?”我让他在random.js
最后加一行console.log(module.exports)
,结果输出{}
——原来他把exports
绑到新函数上了,module.exports
还是原来的空盒子!我让他改成module.exports = function() { ... }
,再运行app.js
,立马就拿到函数了。他当时拍着脑袋说:“原来我之前一直在跟别名较劲,根本没碰着真正的‘导出盒子’啊!”
实战中怎么选?记住这3种场景就够了
搞懂底层逻辑后,实战里用exports
还是module.exports
?其实分3种情况,记下来就不会乱:
exports
(语法糖,方便)如果你的模块要导出多个属性(比如工具函数、常量),直接用exports.xxx
加属性就行——因为这时候exports
和module.exports
指向同一个“盒子”,加属性等于往盒子里放东西。比如我写的日期处理模块:
// date-utils.js
exports.format = (date) => {
return ${date.getFullYear()}-${date.getMonth()+1}-${date.getDate()}
;
};
exports.parse = (str) => {
const [year, month, day] = str.split('-');
return new Date(year, month-1, day);
};
导入的时候直接require
,就能拿到两个方法:
const dateUtils = require('./date-utils');
console.log(dateUtils.format(new Date())); // 2024-5-20
console.log(dateUtils.parse('2024-5-20')); // Date对象
这种场景下,exports
就是个“语法糖”,帮你省了写module.exports.xxx
的麻烦——效果完全一样。
module.exports
如果你的模块要导出单个值(比如一个构造函数、一个配置对象、一个工具函数),千万别用exports
直接赋值——因为会“断引用”!比如你想导出一个用户类:
// user.js
// 错误写法:exports直接赋值,无效
exports = class User {
constructor(name) {
this.name = name;
}
};
// 正确写法:用module.exports
module.exports = class User {
constructor(name) {
this.name = name;
}
};
为什么?因为exports
是module.exports
的引用,你给exports
赋值等于把绳子绑到新对象上,而module.exports
还是原来的空盒子——导出的自然是空的。而module.exports
是真正的“导出接口”,你直接给它赋值,就是把盒子换成你要的东西,导入的时候自然能拿到。
我之前帮客户做Node.js接口时,要导出一个封装好的API客户端,就是用module.exports = new APIClient(config)
——导入的时候直接const api = require('./api-client')
就能用,特别方便。
exports
又用module.exports
:注意顺序!有时候你可能会先给exports
加属性,后来又想导出单个对象——这时候要注意module.exports
的赋值会覆盖之前的exports
属性!比如:
// mix.js
exports.foo = 'bar'; // 给exports加属性,module.exports现在是{ foo: 'bar' }
module.exports = { baz: 'qux' }; // 给module.exports赋值,现在导出的是{ baz: 'qux' }
导入后拿到的是{ baz: 'qux' }
,之前的foo
没了——因为module.exports
被换成新对象了,exports
虽然还指着原来的{ foo: 'bar' }
,但导出的是module.exports
的新对象。
如果你想“保留exports
的属性并导出单个对象”,可以最后把module.exports
指向exports
:
exports.foo = 'bar';
exports.baz = 'qux';
module.exports = exports; // 导出{ foo: 'bar', baz: 'qux' }
不过一般没必要这么做——直接用exports
加属性就行,效果一样。
为了让你更清楚,我做了个对比表格,把常用场景、用法和注意事项列得明明白白:
使用场景 | exports用法 | module.exports用法 | 注意事项 |
---|---|---|---|
导出多个属性 | exports.a = 1; exports.b = 2; | module.exports.a = 1; module.exports.b = 2; | 两者效果一致,exports是语法糖 |
导出单个对象/函数 | 无法直接实现(会断引用) | module.exports = { a: 1 }; 或 module.exports = () => {}; | 必须用module.exports |
混合使用(先加属性再赋值) | exports.foo = ‘bar’; | module.exports = { baz: ‘qux’ }; | module.exports赋值会覆盖之前的exports属性 |
最后给你留个“验证小技巧”,再也不踩坑
不管你用exports
还是module.exports
,写完模块一定要验证导出结果——在模块最后加一行console.log(module.exports)
,或者导入后打印require
的结果,就能立刻知道有没有错!比如:
// test.js
exports.hello = 'world';
exports = { hi: 'there' };
console.log(module.exports); // 输出{ hello: 'world' }(因为exports赋值无效)
导入后:
const test = require('./test');
console.log(test); // { hello: 'world' }
如果想导出{ hi: 'there' }
,就得改成module.exports = { hi: 'there' }
,再打印module.exports
就会输出{ hi: 'there' }
。
Node.js官方文档里也明确说了:“exports
is a reference to the module.exports
object. Assigning to exports
will not modify module.exports
, but assigning to exports
properties will.”(翻译:exports
是module.exports
的引用,给exports
赋值不会修改module.exports
,但给exports
加属性会修改)(参考链接:Node.js Modules文档,加nofollow)。
其实核心就一句话:exports
是module.exports
的“助手”,帮你给导出盒子加东西;但如果想换盒子(导出单个值),必须直接操作module.exports
。下次写模块的时候,先想清楚“我要导出多个属性还是单个值”——多个用exports
,单个用module.exports
,准没错。
如果你之前踩过类似的坑,或者按我说的方法试了,欢迎在评论区留个言,告诉我你的结果,我帮你看看有没有问题!
你有没有过这样的经历?写Node.js模块时,直接给exports赋值一个函数或者对象,结果导入的时候拿到的却是undefined,盯着代码看半天都没找出问题?其实问题的根源,在于你没搞懂exports和module.exports到底是什么关系——我给你打个最接地气的比方:module.exports就像你要寄给别人的“导出快递箱”,里面装的是这个模块所有要对外暴露的内容;而exports呢,就是贴在这个快递箱上的“便利贴标签”,目的是让你不用每次都写“module.exports”这么长的名字,直接用标签就能往箱子里塞东西。
平时你用exports.foo = ‘bar’这种写法,就像拿着便利贴往快递箱里塞东西——因为标签和箱子是粘在一起的,你塞的东西自然进了箱子;可一旦你直接给exports赋值,比如exports = function() { return ‘hello’ },这就相当于把便利贴从原来的快递箱上撕了下来,贴到了一个新的小盒子上。这时候,原来的“导出快递箱”(module.exports)还是空的,你寄出去的是空箱子,别人收到能不是undefined吗?
我去年帮一个刚学Node的朋友排查过一模一样的bug。他写了个生成随机密码的函数,代码是exports = function(length) { return crypto.randomBytes(length).toString(‘hex’) },结果导入的时候一直拿不到函数。我让他在模块最后加了一行console.log(module.exports),结果输出的是{}——原来他把便利贴贴到新函数上了,真正的快递箱里啥都没有!后来我让他把exports改成module.exports,再运行代码,立马就拿到了那个密码函数。他当时拍着大腿说:“我之前一直以为exports就是出口,没想到真正的出口是module.exports啊!”
其实Node.js的官方文档里早把这事说清楚了:exports是module.exports的“快捷方式”(shortcut),你可以通过exports往module.exports里加属性,但如果直接给exports赋值,就会断开它和module.exports的联系——简单说就是,你改的是标签,不是快递箱本身。就像你给快递箱贴了个新标签,但没换箱子里的东西,寄出去的还是原来的空箱子,别人收到当然啥都没有。
所以啊,直接给exports赋值根本没用,因为你改的是“便利贴”,不是真正要寄出去的“快递箱”。要想让别人收到你写的函数、类或者对象,得直接动module.exports才行——这才是真正能修改快递箱内容的方法。下次再碰到导出undefined的情况,先看看自己是不是在改标签,没动快递箱吧!
为什么直接给exports赋值后,导入的结果是undefined?
因为exports本质是module.exports的“引用(别名)”——你可以把module.exports想象成“导出盒子”,exports是一根拴在盒子上的绳子。直接给exports赋值(比如exports = 函数
)相当于把绳子绑到了新对象上,但“导出盒子”(module.exports)还是原来的空盒子,所以导入时自然拿不到新内容。这也是Node.js官方文档明确提到的“exports是module.exports的快捷方式,但赋值不会修改module.exports”。
exports和module.exports默认是什么关系?
默认情况下,exports是module.exports的“别名”——两者指向同一个空对象。比如你用exports.foo = 'bar'
往“绳子”上挂东西,本质是往“导出盒子”(module.exports)里放东西;但如果把“绳子”绑到其他对象上(exports = 新对象
),就和“导出盒子”没关系了。
导出类或单个函数时,必须用module.exports吗?
是的。因为类、函数都是“单个值”,需要直接替换“导出盒子”的内容——如果用exports = 类
,只会断开exports和module.exports的联系,“导出盒子”还是空的;只有用module.exports = 类
,才能直接把“导出盒子”换成你要的类或函数,导入时才能拿到正确结果。
混合用exports加属性和module.exports赋值,结果会怎样?
module.exports的赋值会覆盖之前的exports属性。比如你先写exports.foo = 'bar'
(往“导出盒子”里放了foo),再写module.exports = { baz: 'qux' }
(把“导出盒子”换成了新的{baz: ‘qux’}),最终导出的是新盒子里的内容,之前的foo会被覆盖掉——因为module.exports才是真正的导出接口。
有没有快速验证导出是否正确的小技巧?
有两个简单方法:① 在模块文件末尾加一行console.log(module.exports)
,直接看“导出盒子”里的内容;② 导入模块后,立刻打印require(模块路径)
的结果。比如你导出了一个函数,打印后如果是[Function]
就对了,如果是{}
或undefined
,说明导出方式错了。