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

加载顺序是这样的:

  1. 先检查是不是核心模块(比如 fs、path)
  2. 如果是路径开头(./ 或 ../),按相对路径找
  3. 否则当作第三方模块,从当前目录的 node_modules 开始,逐级向上找
  4. 找到 package.json 里的 main 字段,确定入口文件
  5. 如果没找到,就报错 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 模块加载流程 调用 require() 检查模块缓存 有缓存 无缓存 解析模块完整路径 读取文件内容 执行模块代码 返回缓存结果 返回 module.exports

整个流程走下来,你会发现 Node.js 的模块系统设计得相当精巧。缓存机制保证了性能,循环依赖的处理虽然不完美,但至少不会让程序崩溃。理解这些底层机制,对排查问题非常有帮助。

好了,这一章的内容就到这里。下一章我们会聊聊 ES Modules,看看它和 CommonJS 有什么不同,以及在实际项目中怎么选型。