CommonJS vs ES Module:现代JavaScript模块化对决

15 阅读5分钟

在当今的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));

这两种写法看似相似,实则天差地别!在实际开发中,我们应该选择哪一种?为什么?何时使用?这些问题的答案决定了项目的架构质量。

核心差异全景对比

语法差异对比表

特性CommonJSES Module
导出语法module.exports = value
exports.name = value
export const name = value
export default value
导入语法const module = require(path)import module from 'path'
默认导出module.exports = valueexport default value
命名导出exports.name = valueexport 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加载后执行');
});

核心差异

  1. CommonJS会阻塞代码执行
  2. 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; // 错误!不能直接修改导入的值

核心差异

  1. CommonJS:值拷贝,修改不影响原模块
  2. ES Module:实时绑定,修改同步更新
  3. CommonJS可以在导入侧修改导出值(但不会影响原模块)
  4. ES Module导入的值是只读的(在严格模式下)

循环依赖处理对比

循环依赖:模块A依赖模块B,模块B依赖模块A。

  1. CommonJS:遇到 require 时立即执行模块,可能拿到不完全的模块
  2. ES Module:先建立导入导出关系,再执行代码,访问未初始化变量得到 undefined
  3. 两种方式都能处理循环依赖,但行为不同

性能与优化对比

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被移除

关键差异:

  1. ES Module:静态结构,编译时可分析
  2. CommonJS:动态结构,运行时才能确定
  3. 现代工具可部分分析CommonJS,但效果有限

优化建议:

  1. 库开发优先使用ES Module
  2. 使用lodash-es而不是lodash
  3. 配置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只加载一次,共享实例
};

关键差异:

  1. CommonJS:require.cache可管理,可清除
  2. ES Module:模块映射不可变,不可清除
  3. 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以其标准化、静态分析和现代特性代表着未来。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!