3、CommonJS规范:Node.js中的模块系统
聊到前端工程化,CommonJS 是个绕不开的话题。说实话,我刚开始接触 Node.js 时,对模块系统也是一头雾水。那时候写代码,所有功能都堆在一个文件里,维护起来简直噩梦。后来理解了 CommonJS,才真正体会到「模块化」这三个字的分量。
CommonJS 规范,说白了就是一套让 JavaScript 文件之间互相「交流」的规则。它定义了如何导出、如何导入,以及模块之间怎么协作。Node.js 从诞生起就拥抱了这个规范,直到今天,它依然是 Node.js 生态的基石。
3.1 module.exports 与 require:模块的进出口
每个 Node.js 文件,默认就是一个独立的模块。模块内部的作用域是隔离的,变量不会污染全局。那怎么把模块里的东西分享出去?靠的就是 module.exports。
我习惯把 module.exports 想象成模块的「出口」。你往这个出口上挂什么,外面就能拿到什么。来看个例子:
// math.js
const add = (a, b) => a + b
const subtract = (a, b) => a - b
module.exports = {
add,
subtract
}
然后另一个文件通过 require 来「进口」:
// app.js
const math = require('./math.js')
console.log(math.add(2, 3)) // 5
console.log(math.subtract(5, 2)) // 3
嗯,这里要注意:require 返回的就是 module.exports 指向的对象。所以如果你在模块里重新赋值 module.exports,那之前挂上去的东西就全没了。
exports 和 module.exports。直接给 exports 赋值会切断它与 module.exports 的引用关系。我曾经在项目里看到同事这么写,结果调试了半天才发现问题。
还有一种写法,直接给 exports 挂属性:
// utils.js
exports.hello = () => 'Hello World'
exports.goodbye = () => 'Goodbye'
这等价于 module.exports.hello = ...。但记住,千万别写 exports = { ... },那样就断了联系。
3.2 模块加载机制:Node.js 是怎么找到你的文件的?
你有没有想过,当你写 require('lodash') 时,Node.js 到底去哪里找这个模块?这个过程其实挺有意思的。
Node.js 的模块加载分为三类:
| 类型 | 示例 | 查找方式 |
|---|---|---|
| 核心模块 | require('fs') |
直接加载,无需路径 |
| 相对路径模块 | require('./foo') |
基于当前文件路径查找 |
| 第三方模块 | require('lodash') |
逐级向上查找 node_modules |
加载顺序是这样的:
- 先检查是不是核心模块(比如
fs、path) - 如果是路径开头(
./或../),按相对路径找 - 否则当作第三方模块,从当前目录的
node_modules开始,逐级向上找 - 找到
package.json里的main字段,确定入口文件 - 如果没找到,就报错
MODULE_NOT_FOUND
我个人习惯把第三方模块的查找过程叫做「爬楼梯」—— 一层一层往上翻,直到根目录。这在 monorepo 项目里特别常见,有时候一个包装在了根目录,子项目也能引用到。
3.3 模块缓存:为什么第二次 require 更快?
Node.js 对模块做了缓存。第一次 require 一个模块时,会执行模块代码,然后把 module.exports 缓存起来。第二次再 require 同一个模块,直接返回缓存,不再执行代码。
来看个例子:
// counter.js
let count = 0
module.exports = {
increment: () => ++count,
getCount: () => count
}
// app.js
const counter1 = require('./counter')
const counter2 = require('./counter')
counter1.increment()
console.log(counter2.getCount()) // 1,同一个对象
为什么会这样?因为 counter1 和 counter2 指向的是同一个 module.exports 对象。这在某些场景下是好事,比如共享配置。但如果你希望每次 require 都得到新实例,就得用工厂函数。
module.exports = () => ({ count: 0 })。每次调用返回新对象。
3.4 循环依赖处理:Node.js 是怎么「圆场」的?
循环依赖,说白了就是 A 引用了 B,B 又引用了 A。这在大型项目中很难完全避免。我早期在做一个支付系统时就踩过这个坑,两个模块互相依赖,结果拿到的是 undefined。
Node.js 处理循环依赖的策略是:只返回当前已执行的部分。什么意思呢?来看个经典例子:
// a.js
console.log('a 开始执行')
const b = require('./b')
console.log('a 中获取 b.done:', b.done)
module.exports = { done: true }
console.log('a 执行完毕')
// b.js
console.log('b 开始执行')
const a = require('./a')
console.log('b 中获取 a.done:', a.done)
module.exports = { done: true }
console.log('b 执行完毕')
执行 node a.js 的输出:
a 开始执行
b 开始执行
b 中获取 a.done: undefined // 注意这里!
b 执行完毕
a 中获取 b.done: true
a 执行完毕
看到了吗?当 b.js 引用 a.js 时,a.js 还没执行完,module.exports 还是默认的空对象。所以 a.done 是 undefined。但等 a.js 执行完后,b.js 的导出已经完成了,所以 a 能拿到 b 的完整导出。
undefined。
怎么避免?我总结了三条经验:
- 重构代码:把公共依赖抽成第三个模块,打破循环
- 延迟引用:在函数内部
require,而不是模块顶层 - 使用事件机制:通过 EventEmitter 解耦模块间的直接依赖
我曾经在一个项目中,因为循环依赖导致某个配置项始终是 undefined,排查了整整一个下午。最后发现是两个工具类互相引用,抽出一个公共模块就解决了。嗯,从那以后我对模块依赖图就格外敏感。
3.5 模块加载的完整流程
最后,我用一张图来总结 Node.js 加载一个模块的完整流程:
整个流程走下来,你会发现 Node.js 的模块系统设计得相当精巧。缓存机制保证了性能,循环依赖的处理虽然不完美,但至少不会让程序崩溃。理解这些底层机制,对排查问题非常有帮助。
好了,这一章的内容就到这里。下一章我们会聊聊 ES Modules,看看它和 CommonJS 有什么不同,以及在实际项目中怎么选型。