在当今的JavaScript生态中,CommonJS和ES Module是两个最重要的模块系统。理解它们的差异和适用场景,是每个前端开发者必须掌握的核心技能。
前言:为什么需要对比两大模块系统?
我们先来看看同一个功能,在两大模块中的不同写法:
// CommonJS版本
const { add } = require('./math.cjs');
console.log(add(2, 3));
// ES Module版本
import { add } from './math.mjs';
console.log(add(2, 3));
这两种写法看似相似,实则天差地别!在实际开发中,我们应该选择哪一种?为什么?何时使用?这些问题的答案决定了项目的架构质量。
核心差异全景对比
语法差异对比表
| 特性 | CommonJS | ES Module |
|---|---|---|
| 导出语法 | module.exports = value exports.name = value | export const name = value export default value |
| 导入语法 | const module = require(path) | import module from 'path' |
| 默认导出 | module.exports = value | export default value |
| 命名导出 | exports.name = value | export const name = value |
| 导入重命名 | const { name: newName } = require() | import { name as newName } |
| 导入所有 | const module = require() | import * as module |
| 条件导入 | 支持 | 不支持(需用动态导入) |
| 动态导入 | 原生支持(运行时) | import()(返回Promise) |
导入/导出对比
导出方式对比
以以下代码为例:
const add = (a, b) => a + b;
const multiply = (a, b) => a * b;
const PI = 3.14159;
CommonJS导出
方式1:单个导出
module.exports.add = add;
module.exports.multiply = multiply;
方式2:对象导出
module.exports = {
add,
multiply,
PI
};
ES Module导出
默认导出(只能有一个)
export default function calculate() {
return '计算器';
}
分别导出
exports {
add,
multiply,
PI
};
导入方式对比
CommonJS导入
默认导入
const math = require('./math.cjs');
const { add, PI } = require('./math.cjs');
const addFunc = require('./math.cjs').add;
条件导入(运行时)
let mathModule;
if (process.env.NODE_ENV === 'production') {
mathModule = require('./math-prod.cjs');
} else {
mathModule = require('./math-dev.cjs');
}
ES Module导入
默认导入
import math from './math.mjs'; // 默认导入
import { add, PI } from './math.mjs'; // 命名导入
import { add as sum } from './math.mjs'; // 重命名导入
import * as mathUtils from './math.mjs'; // 命名空间导入
动态导入(返回Promise)
if (condition) {
const math = await import('./math.mjs');
}
底层机制解析
加载时机对比
CommonJS:运行时加载
const cjsMath = require('./math.cjs'); // 同步加载,阻塞执行
console.log('CJS加载后执行');
ES Module:编译时静态解析
// import语句在代码执行前就被解析
import('./math.mjs').then(esmMath => {
console.log('ESM加载后执行');
});
核心差异
- CommonJS会阻塞代码执行
- ES Module不会阻塞后续代码
值传递方式对比
CommonJS 值传递
// counter.cjs
let count = 0;
function increment() {
count++;
console.log('CJS内部count:', count);
}
module.exports = { count, increment };
// app-cjs.cjs
const counter = require('./counter.cjs');
console.log('初始count:', counter.count); // 0
counter.increment(); // CJS内部count: 1
console.log('外部count:', counter.count); // 0 !!! 值没有变
counter.count = 10; // 修改导出值
console.log('修改后外部count:', counter.count); // 10
counter.increment(); // CJS内部count: 2
console.log('再次检查外部count:', counter.count); // 10
ES Module 值传递
// counter.mjs
export let count = 0;
export function increment() {
count++;
console.log('ESM内部count:', count);
}
// app-esm.mjs
import { count, increment } from './counter.mjs';
console.log('初始count:', count); // 0
increment(); // ESM内部count: 1
console.log('外部count:', count); // 1 !!! 值同步更新
// count = 10; // 错误!不能直接修改导入的值
核心差异
- CommonJS:值拷贝,修改不影响原模块
- ES Module:实时绑定,修改同步更新
- CommonJS可以在导入侧修改导出值(但不会影响原模块)
- ES Module导入的值是只读的(在严格模式下)
循环依赖处理对比
循环依赖:模块A依赖模块B,模块B依赖模块A。
- CommonJS:遇到 require 时立即执行模块,可能拿到不完全的模块
- ES Module:先建立导入导出关系,再执行代码,访问未初始化变量得到 undefined
- 两种方式都能处理循环依赖,但行为不同
性能与优化对比
Tree Shaking能力对比
CommonJS(难以Tree Shaking)
// math-cjs.cjs
exports.add = (a, b) => a + b; // ✓ 被使用
exports.multiply = (a, b) => a * b; // ✗ 未使用
exports.divide = (a, b) => a / b; // ✗ 未使用
exports.PI = 3.14159; // ✓ 被使用
// 使用方
const { add, PI } = require('./math-cjs.cjs');
console.log(add(2, 3), PI);
// 打包结果:通常全部包含,因为难以静态分析
ES Module(支持Tree Shaking)
// math-esm.mjs
export const add = (a, b) => a + b; // ✓ 被使用
export const multiply = (a, b) => a * b; // ✗ 未使用
export const divide = (a, b) => a / b; // ✗ 未使用
export const PI = 3.14159; // ✓ 被使用
// 使用方
import { add, PI } from './math-esm.mjs';
console.log(add(2, 3), PI);
// 打包结果:只包含add和PI,multiply和divide被移除
关键差异:
- ES Module:静态结构,编译时可分析
- CommonJS:动态结构,运行时才能确定
- 现代工具可部分分析CommonJS,但效果有限
优化建议:
- 库开发优先使用ES Module
- 使用lodash-es而不是lodash
- 配置package.json的sideEffects字段
内存使用对比
CommonJS的内存行为
const memoryTestCJS = () => {
const modules = [];
// 多次加载同一模块
for (let i = 0; i < 1000; i++) {
// 清除缓存,强制重新加载
delete require.cache[require.resolve('./memory-module.cjs')];
const module = require('./memory-module.cjs');
modules.push(module);
}
console.log('CJS模块实例数:', modules.length);
// 每个require.cache都会创建一个新的模块实例
};
ES Module的内存行为
const memoryTestESM = async () => {
const modules = [];
// ES Module有模块映射缓存
for (let i = 0; i < 1000; i++) {
// 相同URL会返回缓存的模块
const module = await import('./memory-module.mjs');
modules.push(module);
}
console.log('ESM模块实例数:', modules.length);
// 相同URL只加载一次,共享实例
};
关键差异:
- CommonJS:require.cache可管理,可清除
- ES Module:模块映射不可变,不可清除
- ES Module更节省内存,但灵活性较低
模块系统的未来
趋势1:ES Module成为标准
- Node.js正在逐步转向ES Module优先
- 浏览器原生支持不断完善
趋势2:导入映射(Import Maps)标准化
- 控制模块解析,减少构建工具依赖
趋势3:模块联邦(Module Federation)
- 微前端架构,跨应用共享模块
- Webpack 5+ 原生支持
趋势4:WebAssembly模块集成
- 与JavaScript模块无缝协作
趋势5:边缘计算优化
- CDN级别的模块分发和缓存
趋势6:TypeScript与模块深度集成
- 类型安全的模块导入导出
结语
CommonJS和ES Module代表了JavaScript模块化的两个时代。CommonJS以其实用性成为过去十年的主流,而ES Module以其标准化、静态分析和现代特性代表着未来。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!