在 JavaScript 的发展历程中,模块化一直是一个核心话题。目前主流的两种规范------CommonJS (CJS) 和 ES Modules (ESM)------在语法、机制和设计理念上有着本质的区别。
本文将跳出单纯的代码堆砌,从设计理念、运行机制和实际应用角度,带你深入理解这两者的差异。
核心差异速览
如果不想看长篇大论,这张表总结了最核心的区别:
| 特性 | CommonJS (CJS) | ES Modules (ESM) |
|---|---|---|
| 主要环境 | Node.js (服务端) | 浏览器 & Node.js (通用) |
| 设计理念 | 动态运行时对象 | 静态声明构建 |
| 加载方式 | 同步 (阻塞执行) | 异步 (非阻塞) |
| 依赖解析 | 运行时 (Runtime) | 编译时 (Compile-time) |
| 导出值 | 值拷贝 (导出后不再变化) | 实时绑定 (引用值随内部变化) |
| Tree Shaking | ❌ 不支持 | ✅ 支持 (减少代码体积) |
1. 语法与设计理念
CommonJS: "对象"的传递
CommonJS 诞生于 Node.js 早期,它的核心思想非常简单:模块就是一个对象 。 当你使用 require 时,你实际上是在获取这个对象;当你使用 module.exports 时,你是在给这个对象添加属性。
- 直观感受:像是在操作一个普通的 JavaScript 对象。
- 灵活性:因为是对象,你可以动态地修改它,甚至根据条件导出不同的内容。
javascript
// CommonJS 就像在拼装一个对象
const lib = require('./lib'); // 同步读取文件并执行
if (process.env.NODE_ENV === 'development') {
module.exports = { ...lib, debug: true }; // 动态修改导出
}
🔍 深入:CommonJS 导出的"潜规则" (exports vs module.exports)
很多开发者容易混淆 exports 和 module.exports。记住一句话:exports 只是 module.exports 的引用(快捷方式),Node.js 最终认的只有 module.exports。
- 默认情况 :两者指向同一个空对象
{}。 - 断开引用 :如果你直接给
exports赋值,它就断开了与module.exports的联系,导致导出无效。 - 优先级 :如果两者发生冲突,
module.exports拥有最高优先级。
javascript
// ✅ 正确:给引用对象添加属性
exports.a = 1;
module.exports.b = 2;
// 最终导出:{ a: 1, b: 2 }
// ❌ 错误:直接赋值 exports (断开引用)
exports = { a: 1 };
// 最终导出:{} (默认值),因为 module.exports 还是空的
// ⚠️ 覆盖:module.exports 优先级最高
exports.a = 1;
module.exports = { b: 2 };
// 最终导出:{ b: 2 } (exports.a 的修改被丢弃了)
ES Modules: "静态"的契约
ES Modules 是 JavaScript 官方标准,它的设计目标是静态分析。 这意味着在代码运行之前,编译器就能知道模块之间的依赖关系。
- 直观感受 :更像是一种声明式语法 (
import/export),而不是执行语句。 - 优势:正因为这种静态特性,工具链(如 Webpack, Vite)才能进行 Tree Shaking(摇树优化),移除未使用的代码。
javascript
// ESM 是静态的声明
import { func } from './lib.js'; // 必须在顶层,不能在 if 语句中
export const value = 123; // 明确的导出接口
2. 加载机制:同步 vs 异步
这是两者在运行时行为上最大的区别。
text
┌──────────────────────┐ ┌──────────────────────┐
│ CommonJS (同步阻塞) │ │ ES Modules (异步) │
└──────────┬───────────┘ └──────────┬───────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ 开始执行 main.js │ │ 解析 main.js │
└──────────┬───────────┘ └──────────┬───────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ 遇到 require │ │ 构建依赖图 │
└──────────┬───────────┘ └──────────┬───────────┘
│ │
▼ ▼
╔══════════════════════╗ ╔══════════════════════╗
║ 🛑 暂停执行,加载文件 ║ ║ ⚡️ 并行下载/读取依赖 ║
║ (等待 I/O 完成) ║ ║ (不阻塞主线程) ║
╚══════════════════════╝ ╚══════════════════════╝
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ 获取导出对象 │ │ 按序实例化与求值 │
└──────────┬───────────┘ └──────────┬───────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ 继续执行 main.js │ │ 执行代码 │
└──────────────────────┘ └──────────────────────┘
CommonJS 的同步阻塞
CommonJS 专为服务端设计,文件都在本地磁盘,读取速度快。因此,require() 会暂停当前代码的执行,直到模块加载完成。
- 优点:逻辑简单,编写线性代码容易。
- 缺点:不适合浏览器环境(网络请求慢,会造成页面卡顿)。
ES Modules 的异步加载
ESM 采用了两阶段过程:解析 和执行。
- 解析阶段:浏览器或 Node.js 会先构建整个依赖图,并行下载所有模块。
- 执行阶段:按照依赖顺序执行代码。
- 优点:性能更好,支持复杂的依赖关系处理,支持浏览器环境。
3. 深度解析:值拷贝 vs 实时绑定
这是最容易被忽视,但导致 Bug 最多的差异。
CommonJS:按下快门的瞬间 (Value Copy)
当 CommonJS 导出基础类型(数字、字符串)时,它导出的是那一瞬间的值的拷贝。 就像拍了一张照片,之后原模块里的值怎么变,照片里的样子都不会变。
javascript
// counter.js
let count = 1;
module.exports = {
count, // 导出的是 1 这个数字
inc: () => count++
};
// main.js
const mod = require('./counter');
mod.inc();
console.log(mod.count); // 仍然是 1!因为导出的是拷贝
ES Modules:连接的通道 (Live Binding)
ESM 导出的是引用的绑定。 就像建立了一个连接通道,当你访问导出的变量时,你直接读取的是原模块内部的那个变量。
javascript
// counter.js
export let count = 1;
export const inc = () => count++;
// main.js
import { count, inc } from './counter.js';
inc();
console.log(count); // 变成了 2!这是实时更新的
4. 实际使用场景指南
与其列举代码,不如看看在什么情况下该用什么。
✅ 什么时候必须/应该用 ESM?
- 浏览器端开发:所有现代前端框架(React, Vue, etc.)和构建工具(Vite, Webpack)默认都是 ESM。
- 新开启的 Node.js 项目:Node.js 12+ 已完全支持 ESM,这是未来的标准。
- 需要代码优化 (Tree Shaking):如果你希望打包后的文件尽可能小,ESM 是必须的。
- 跨平台库开发:如果你开发的库既要在 Node.js 跑,又要在浏览器跑。
⚠️ 什么时候还得用 CommonJS?
- 维护遗留的 Node.js 服务:很多老项目深耦合了 CommonJS 特性(如动态 require)。
- 编写配置文件 :某些老工具的配置文件(如旧版
webpack.config.js,.eslintrc.js)可能仍默认使用 CJS。 - 非常特殊的动态加载需求 :虽然 ESM 有
import()动态导入,但在某些极端灵活的同步加载场景下,require仍有优势。
🌟 日常开发最佳实践
在日常开发中,遵循这些原则可以避免大部分坑:
- 文件扩展名 :
- 明确使用
.mjs(ESM) 或.cjs(CJS) 可以避免歧义。 - 如果在
package.json中设置了"type": "module",则.js默认为 ESM。
- 明确使用
- 导入路径 :
- 在 ESM 中,导入路径必须 包含文件扩展名(如
import x from './file.js'),这与 CommonJS 不同。
- 在 ESM 中,导入路径必须 包含文件扩展名(如
- 混合使用 :
- 尽量避免在同一个项目中混用。
- 如果必须混用,记住:ESM 可以导入 CJS (使用
import),但 CJS 无法requireESM(因为 ESM 是异步的,CJS 是同步的)。
- 默认导出 (Default Export) :
- 尽量使用命名导出 (Named Export)。它们对重构更友好,IDE 支持更好,且能更好地支持 Tree Shaking。
🚀 从 CJS 迁移到 ESM 的注意事项
如果你正在将老项目从 CJS 迁移到 ESM,注意这几个常见的"拦路虎":
__dirname和__filename不见了 :- ESM 中没有这两个全局变量。
- 解决方案 :使用
import.meta.url结合path模块手动创建。
- JSON 文件的导入 :
- CJS:
require('./data.json')直接用。 - ESM: 需要使用断言语法
import data from './data.json' assert { type: 'json' };(Node.js 17.5+)。
- CJS:
- 严格模式 :
- ESM 默认开启严格模式 (
'use strict'),这可能会暴露老代码中未声明变量等问题。
- ESM 默认开启严格模式 (
总结:ES Modules 是 JavaScript 的统一未来。虽然 CommonJS 在 Node.js 生态中仍将长期存在,但拥抱 ESM 意味着拥抱更好的性能、更标准的语法和更广阔的生态。