在 Node.js 项目里,很多老代码和大量现有包仍然建立在 CommonJS 之上。真正容易让人混淆的,并不是 require() 和 module.exports 这两个名字本身,而是它们在“导出单个值、导出多个成员、使用 exports 简写、切换到 ESM”这些场景下的行为差异。下面按常见使用路径拆开讲,你可以直接对照代码判断自己该用哪种写法、哪些写法会踩坑。
CommonJS 在 Node.js 里到底做什么
CommonJS 是 Node.js 默认的模块系统。每个 .js 文件都可以看成一个独立模块,模块内部有自己的私有作用域,外部不能直接访问文件里的变量、函数或常量。
模块之间要通信,核心就两件事:
- 用
module.exports把内容显式暴露出去 - 用
require()把别的模块加载进来
它的特点是语法直接、运行时生效,特别适合传统的 Node.js 服务端开发场景。理解这一点后,后面的导入导出规则就会清晰很多:外部拿到的始终是目标模块最终的 module.exports。
怎么用 module.exports 导出内容
如果一个模块想对外提供能力,必须通过 module.exports 导出。它可以导出单个值,也可以导出一个对象,甚至可以导出任意类型。

1. 导出单个函数或类
当一个文件只打算提供一个主要能力时,最直接的写法就是把这个值整体赋给 module.exports。导入方拿到的也就是这个值本身。
// utils.js
const helper = (x) => x * 2;
const VERSION = "1.0";
// 方式一:导出单个函数
module.exports = helper;
// 方式二:导出多个成员(推荐常用)
// module.exports = { helper, VERSION };
2. 导出多个成员
如果一个模块需要同时暴露多个函数、常量或配置项,更常见的做法是导出一个对象:

- 对象的属性名,就是导入后可访问的键名
- 适合工具库、配置模块、聚合模块这类场景
例如上面的注释写法:
module.exports = { helper, VERSION };
这样导入后就可以通过对象属性或解构来使用。
3. CommonJS 不限制导出类型
module.exports 并不要求你只能导出对象。函数、对象、数组、字符串、数字,甚至 undefined 都可以导出。也正因为如此,导入方必须先知道对方导出的到底是什么结构,否则很容易把“默认拿到一个函数”和“拿到一个对象后再解构”这两种情况混在一起。
怎么用 require 导入模块
require() 的作用是加载目标模块,并返回该模块的 module.exports。它是同步执行的,也就是说调用发生时,Node.js 会立刻去加载并执行目标模块代码。
// app.js
const helper = require('./utils.js'); // 若 utils.js 导出的是函数
console.log(helper(5)); // 输出 10
// 若 utils.js 导出的是 { helper, VERSION }
// const { helper, VERSION } = require('./utils.js');
路径怎么写
- 相对路径必须以
./或../开头 - 绝对路径可以从磁盘根目录开始,但实际项目里不常用
- 如果写的是包名,比如
lodash,Node.js 会去node_modules中查找
扩展名能不能省略
可以。Node.js 会按顺序尝试这些扩展名:
.js.json.node
不过从代码可读性出发,团队里如果希望路径更明确,显式写出 .js 也完全合理。
为什么多次 require 不会重复执行
这是因为 CommonJS 模块有缓存机制。对同一路径多次调用 require(),模块通常只会在第一次加载时执行一次,后续返回的是缓存结果。
这会带来两个直接影响:
- 有初始化副作用的模块,不会因重复导入而反复执行
- 模块内部如果维护状态,多处
require可能共享同一份结果
exports 和 module.exports 到底有什么区别
这是 CommonJS 里最容易写错的地方。很多人以为 exports 是另一个独立的导出接口,其实不是。
exports 只是 module.exports 的初始引用。也就是说,在模块刚开始执行时,它们指向的是同一个空对象,因此下面这种写法是成立的:
exports.fn = () => {};
exports.num = 42;
这等价于:
module.exports.fn = () => {};
module.exports.num = 42;
问题出在“重新赋值”上。下面这种写法看起来像是在导出一个新对象,实际上会失效:
exports = { a: 1 };
原因是这行代码只是让局部变量 exports 指向了一个新对象,但真正决定模块导出结果的 module.exports 并没有被改掉。于是外部 require() 进来时,拿到的仍然不是你新赋值后的那个对象,甚至可能是空对象。
可以这样记
- 想导出单个值:用
module.exports = xxx - 想导出多个成员:用
module.exports = { a, b } - 如果只是给同一个导出对象追加属性:可以写
exports.a = a - 不要写
exports = ...这种重赋值形式
一句话总结就是:最终生效的只有 module.exports,exports 只是它的一个便捷别名。
常见问题与和 ESM 的关系
循环依赖为什么会拿到“不完整对象”
在循环依赖场景下,require() 返回的是目标模块“当前已经执行完成的那部分” module.exports。如果对方模块还没跑完,你拿到的就可能是不完整对象。这也是很多循环依赖 bug 难排查的原因:代码不一定报错,但值可能是半初始化状态。
CommonJS 和 ESM 不能在同一文件里混写
如果项目已经启用 ESM,例如在 package.json 中声明了 "type": "module",那这个文件环境下就不能再直接使用 require 和 module.exports。
同样地,import/export 和 CommonJS 也不该在同一文件里混着写。看到导入导出报错时,先确认项目当前到底跑的是哪一种模块系统,往往比盯着语法本身更重要。
ESM 如何导入 CommonJS 模块
如果你是在 ESM 中使用 CommonJS 模块,可以直接这样写:
import cjs from './cjs-file.js'
这种情况下,CommonJS 模块通常会被自动包装成默认导出。也就是说,ESM 侧拿到的是那个 CommonJS 模块整体导出的值。
实际开发里,最稳妥的判断方式是先看这两个问题:
- 当前文件所在项目是不是已经启用了
"type": "module" - 目标模块导出的究竟是单个值,还是一个对象
把这两点先确认清楚,绝大多数 CommonJS 导入导出问题都能快速定位。









