1. CommonJS 的前世今生:从混乱到规范
2009年,当Ryan Dahl首次发布Node.js时,JavaScript的模块化还处于蛮荒时代。浏览器端的脚本往往通过全局变量和<script>标签来组织代码,这种方式在服务端开发中显然行不通。CommonJS规范正是在这样的背景下应运而生,它最初是为服务端JavaScript环境设计的模块标准,后来成为Node.js的默认模块系统。
有趣的是,CommonJS这个名字来源于其最初的目标——让JavaScript不仅能在浏览器运行,还能在服务端、桌面应用等更"common"的场景中使用。
CommonJS的核心思想非常简单:每个文件就是一个模块,拥有自己的作用域;通过require函数来加载其他模块;通过module.exports或exports来暴露模块内容。这种同步加载的方式非常适合服务端环境,因为本地文件I/O的延迟可以忽略不计。
javascript复制// 模块定义 (math.js)
function add(a, b) {
return a + b
}
module.exports = { add }
// 模块使用 (app.js)
const math = require('./math')
console.log(math.add(2, 3)) // 输出5
2. CommonJS 的核心机制解析
2.1 模块加载的完整生命周期
当一个模块被require时,Node.js会经历以下几个关键步骤:
-
路径解析:根据传入的模块标识符确定绝对路径。如果是核心模块(如
fs)直接返回;如果是相对路径(如./module)则转换为绝对路径;如果是非核心模块(如lodash)则从node_modules查找。 -
缓存检查:Node.js会首先检查模块是否已经被缓存(存储在
require.cache中)。如果已缓存则直接返回缓存内容,避免重复加载。 -
文件读取:对于未缓存的模块,Node.js会同步读取文件内容。这里有个细节:Node.js会尝试添加
.js、.json、.node等扩展名来查找文件。 -
模块包装:读取到的文件内容会被包装在一个函数中,这个函数有五个参数:
exports,require,module,__filename,__dirname。这就是为什么我们能在模块中直接使用这些变量。
javascript复制// Node.js实际执行的代码
(function(exports, require, module, __filename, __dirname) {
// 模块代码被放在这里
function add(a, b) { return a + b }
module.exports = { add }
})
- 执行与缓存:执行包装后的函数,将返回结果存入缓存,然后返回
module.exports对象。
2.2 module.exports 与 exports 的关系
初学者经常困惑于module.exports和exports的区别。其实exports只是module.exports的一个引用:
javascript复制console.log(exports === module.exports) // true
当直接给exports赋值时(如exports = { add }),这个引用就被切断了,所以不会生效。正确的做法是:
javascript复制// 正确做法
exports.add = function(a, b) { return a + b }
// 或者
module.exports = { add: function(a, b) { return a + b } }
实际开发中,我建议统一使用
module.exports,避免混淆。只有在需要逐个添加属性时才使用exports.xxx形式。
3. CommonJS 在 Node.js 中的实现细节
3.1 模块缓存机制
Node.js对加载过的模块会进行缓存,存储在require.cache对象中。这个缓存是基于模块的绝对路径作为键的:
javascript复制// 查看缓存
console.log(require.cache)
// 删除缓存
delete require.cache[require.resolve('./module')]
缓存机制带来了性能提升,但也可能导致问题。比如当你修改了一个模块文件后重新require,由于缓存存在,获取的还是旧版本。在开发环境下,可以通过工具如nodemon自动重启应用,或者手动清除缓存。
3.2 循环依赖的处理
CommonJS对循环依赖的处理方式经常让人感到意外。考虑以下两个模块:
javascript复制// a.js
console.log('a starting')
exports.done = false
const b = require('./b')
console.log('in a, b.done =', b.done)
exports.done = true
console.log('a done')
// b.js
console.log('b starting')
exports.done = false
const a = require('./a')
console.log('in b, a.done =', a.done)
exports.done = true
console.log('b done')
执行node a.js会输出:
code复制a starting
b starting
in b, a.done = false
b done
in a, b.done = true
a done
这是因为当b.js加载a.js时,a.js还没有执行完,所以b.js只能拿到a.js已经导出的部分内容。这种处理方式虽然避免了无限循环,但容易导致难以调试的问题。
在实际项目中,我强烈建议重构代码避免循环依赖。如果确实需要,要确保相互依赖的部分是简单、稳定的接口。
4. CommonJS 与现代模块系统的对比
4.1 与ES Modules的区别
ES Modules(ESM)是JavaScript的官方模块标准,与CommonJS有几个关键区别:
- 加载方式:CommonJS是同步加载,ESM是异步加载
- 语法:CommonJS使用
require/module.exports,ESM使用import/export - 静态分析:ESM的导入导出是静态的,可以在编译时确定;CommonJS是动态的
- 顶层this:ESM模块顶层
this是undefined,CommonJS是当前模块的exports
Node.js现在支持ES Modules,可以通过以下方式启用:
- 使用
.mjs扩展名 - 在
package.json中设置"type": "module" - 使用
--input-type=module标志
4.2 与AMD/UMD的比较
AMD(Asynchronous Module Definition)是为浏览器设计的异步模块系统,代表实现是RequireJS。它与CommonJS的主要区别在于加载方式。
UMD(Universal Module Definition)是一种兼容模式,可以同时在浏览器和Node.js环境中工作:
javascript复制(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD
define(['exports'], factory)
} else if (typeof exports === 'object' && typeof exports.nodeName !== 'string') {
// CommonJS
factory(exports)
} else {
// 浏览器全局变量
factory((root.commonJsStrict = {}))
}
}(typeof self !== 'undefined' ? self : this, function (exports) {
// 模块代码
}))
5. CommonJS 的最佳实践与常见问题
5.1 路径处理的注意事项
在require中使用路径时,有几个常见陷阱:
- 相对路径的基准目录:相对路径是基于调用
require的文件的,不是基于工作目录 - 目录引用:当
require一个目录时,Node.js会查找package.json的main字段,或者index.js文件 - 模块查找算法:对于非核心模块,Node.js会从当前目录开始向上查找
node_modules
javascript复制// 假设目录结构:
// project/
// ├── node_modules/
// │ └── lodash/
// ├── src/
// │ ├── utils/
// │ │ └── helper.js
// │ └── app.js
// 在app.js中:
require('lodash') // 查找project/node_modules/lodash
require('./utils/helper') // 查找project/src/utils/helper.js
5.2 性能优化技巧
- 减少顶层require:将
require语句移到实际使用的地方,可以加快应用启动速度 - 利用缓存:对于频繁使用的模块,可以局部缓存其方法
- 避免动态require:动态路径(如
require(path.join(__dirname, fileName)))会影响静态分析和优化
javascript复制// 不推荐
function loadModule(name) {
return require(`./modules/${name}`)
}
// 推荐:预先定义可能的模块
const modules = {
a: require('./modules/a'),
b: require('./modules/b')
}
function getModule(name) {
return modules[name]
}
5.3 调试技巧
当模块加载出现问题时,可以使用以下方法调试:
- 查看模块查找路径:
javascript复制console.log(module.paths)
- 打印完整的解析路径:
javascript复制console.log(require.resolve('lodash'))
- 使用
--inspect标志:结合Chrome DevTools调试Node.js应用
6. CommonJS 的未来展望
尽管ES Modules已经成为JavaScript的官方标准,但CommonJS仍将在相当长的时间内存在于Node.js生态中。这主要是因为:
- 庞大的现有代码库:NPM上有数百万个使用CommonJS的包
- 同步加载的适用场景:对于服务端脚本和工具,同步加载通常更直观
- 渐进式迁移路径:Node.js支持CommonJS和ESM的互操作
在Node.js中,两种模块系统的互操作规则如下:
- CommonJS模块可以
requireESM模块(但会得到Promise) - ESM模块可以
importCommonJS模块(但有些限制,如不能使用动态require)
对于新项目,建议考虑使用ES Modules;对于现有项目,可以根据实际情况决定是否迁移。如果决定迁移,可以使用工具如eslint-plugin-import和@babel/plugin-transform-modules-commonjs来辅助过渡。
