1. 为什么需要CommonJS?
十年前的前端开发者面临着一个巨大的困境——浏览器环境缺乏原生的模块系统。想象一下,当你需要在多个HTML文件中复用同一个工具函数时,只能通过<script>标签手动管理依赖顺序,这种开发体验简直是一场噩梦。
2009年,Kevin Dangoor在Mozilla项目中首次提出了CommonJS规范。它的核心目标很简单:让JavaScript在浏览器之外(主要是服务器端)也能像其他语言一样拥有完善的模块系统。虽然现在ES Modules已经成为官方标准,但了解CommonJS仍然至关重要,因为:
- Node.js生态中90%以上的包仍然采用CommonJS格式
- 大量存量项目和老旧工具链仍依赖CommonJS
- 理解模块系统演变历史有助于深入掌握现代前端工程化
注意:CommonJS与ES Modules的关键区别在于前者是同步加载,后者支持异步。这决定了它们各自适合的场景。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 基础模块定义与导出
2.1 最简单的模块示例
创建一个mathUtils.js文件:
javascript复制// 私有变量
const PI = 3.1415926
// 公共方法
function circleArea(r) {
return PI * r * r
}
// 导出接口
module.exports = {
calculateArea: circleArea
}
这里有几个关键点需要注意:
module是Node.js环境注入的全局变量exports实际上是module.exports的引用- 模块内部的变量默认都是私有的
2.2 导出语法糖
CommonJS提供了几种等效的导出方式:
javascript复制// 方式1:直接赋值
module.exports = {
add: (a, b) => a + b
}
// 方式2:属性赋值
exports.add = (a, b) => a + b
// 方式3:混合使用
exports.multiply = (a, b) => a * b
module.exports.divide = (a, b) => a / b
但要注意这个经典陷阱:
javascript复制// 错误!这会切断exports与module.exports的关联
exports = {
greet: () => console.log('Hello')
}
// 正确做法
module.exports = {
greet: () => console.log('Hello')
}
3. 模块引入机制详解
3.1 基本引入方式
在app.js中使用刚才的模块:
javascript复制const math = require('./mathUtils')
console.log(math.calculateArea(5)) // 输出78.539815
require函数的工作原理:
- 解析模块路径(支持省略.js扩展名)
- 检查模块缓存
- 同步读取文件内容
- 包裹成函数执行
- 返回module.exports对象
3.2 模块缓存机制
Node.js会对加载过的模块进行缓存,这意味着:
javascript复制// config.js
let count = 0
setInterval(() => count++, 1000)
module.exports = { count }
// a.js
const { count } = require('./config')
setInterval(() => console.log('a:', count), 1000)
// b.js
const { count } = require('./config')
setInterval(() => console.log('b:', count), 1000)
// 两个文件会打印相同的count值
3.3 循环引用问题
考虑以下场景:
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')
// main.js
console.log('main starting')
const a = require('./a')
const b = require('./b')
console.log('in main, a.done=', a.done, 'b.done=', b.done)
输出顺序会是这样:
code复制main starting
a starting
b starting
in b, a.done = false
b done
in a, b.done = true
a done
in main, a.done=true b.done=true
这是因为Node.js通过部分执行和缓存机制解决了循环依赖问题。
4. 高级应用场景
4.1 动态加载
虽然CommonJS主要是同步加载,但也可以实现动态加载:
javascript复制// 根据条件动态加载
if (process.env.NODE_ENV === 'development') {
const devTools = require('./devTools')
devTools.monitor()
}
// 运行时决定模块路径
const moduleName = 'lodash'
const _ = require(moduleName)
4.2 JSON文件加载
CommonJS可以直接加载JSON文件:
data.json复制{
"name": "CommonJS Demo",
"version": "1.0.0"
}
javascript复制const config = require('./data.json')
console.log(config.name) // 输出"CommonJS Demo"
4.3 核心模块与第三方模块
Node.js内置的核心模块可以直接引入:
javascript复制const fs = require('fs')
const path = require('path')
第三方模块会从node_modules查找:
javascript复制// 查找顺序:
// 1. 当前目录/node_modules/lodash
// 2. 上级目录/node_modules/lodash
// 3. 直到根目录
const _ = require('lodash')
5. 实战中的经验技巧
5.1 路径解析规则
require的查找规则非常值得注意:
javascript复制require('module') // 1. 核心模块 2. node_modules
require('./module') // 当前目录
require('../module') // 上级目录
require('/absolute/path') // 绝对路径
我在实际项目中遇到过这样的问题:当使用require('utils/logger')时,有时会意外加载到node_modules中的同名模块而非项目文件。解决方案是:
javascript复制// 明确使用相对路径
require('./utils/logger')
5.2 模块热更新方案
由于CommonJS模块会被缓存,开发时需要特殊处理才能实现热更新:
javascript复制function watchModule(modulePath) {
delete require.cache[require.resolve(modulePath)]
return require(modulePath)
}
// 开发时使用
const config = watchModule('./config')
5.3 性能优化建议
- 避免在频繁调用的函数内使用require
- 对大型库可以按需引入:
javascript复制// 代替 const _ = require('lodash')
const cloneDeep = require('lodash/cloneDeep')
- 使用
module.parent判断是被直接运行还是被引用
5.4 调试技巧
查看模块的完整路径:
javascript复制console.log(require.resolve('lodash'))
检查缓存:
javascript复制console.log(require.cache)
6. 与现代ES Modules的互操作
6.1 在CommonJS中使用ESM
从Node.js 12开始,可以在CommonJS中动态导入ES模块:
javascript复制async function loadESM() {
const { default: chalk } = await import('chalk')
console.log(chalk.green('Hello'))
}
6.2 在ESM中引入CommonJS
ES模块可以像这样引入CommonJS模块:
javascript复制// 在ESM文件中
import _ from 'lodash' // 会自动转换CJS模块
import { readFile } from 'fs' // 核心模块也适用
但要注意:
- CommonJS的
module.exports会变成ESM的default导出 - 具名导出需要通过
import * as语法访问
7. 构建工具中的处理
7.1 Webpack的优化
Webpack会对CommonJS进行静态分析:
javascript复制// 可以被Tree Shaking
const { pick } = require('lodash')
// 这种动态用法会使整个lodash被打包
const _ = require('lodash')
const method = 'pick'
_[method](...)
7.2 Babel转换
通过@babel/plugin-transform-modules-commonjs插件,可以把ESM转成CJS:
输入:
javascript复制export const PI = 3.14
export default function area() {...}
输出:
javascript复制"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.PI = exports.default = void 0;
const PI = 3.14;
exports.PI = PI;
function area() {...}
var _default = area;
exports.default = _default;
7.3 Rollup的处理
Rollup默认只处理ESM,但可以通过插件支持CJS:
javascript复制// rollup.config.js
import commonjs from '@rollup/plugin-commonjs'
export default {
plugins: [
commonjs({
include: 'node_modules/**'
})
]
}
8. 常见问题解决方案
8.1 "Cannot find module"错误排查
- 检查文件路径是否正确
- 确认文件扩展名是否需要显式声明
- 检查node_modules是否安装完整
- 查看NODE_PATH环境变量设置
8.2 循环依赖的替代方案
虽然Node.js能处理循环依赖,但更好的做法是:
- 提取公共逻辑到第三个模块
- 使用依赖注入模式
- 考虑事件驱动架构
8.3 浏览器端使用方案
在浏览器中使用CommonJS需要打包工具:
- Browserify
bash复制browserify main.js -o bundle.js
- Webpack
- 或者使用UMD格式的打包输出
9. 最佳实践总结
经过多年CommonJS开发,我总结了这些经验法则:
- 一个文件只做一件事,保持模块精简
- 优先使用
module.exports而非exports - 对于配置类模块,考虑使用冻结对象:
javascript复制module.exports = Object.freeze({
API_URL: 'https://api.example.com'
})
- 大型项目使用清晰的目录结构:
code复制lib/
utils/
math.js
string.js
services/
api.js
index.js
- 对核心业务模块编写单元测试,利用模块系统的隔离性
CommonJS可能看起来已经"过时",但它仍然是理解Node.js模块系统的基石。当你下次看到require语句时,希望你能想起它背后精妙的设计哲学和工程考量。
