ES Module 与 CommonJS 的全面对比:加载机制、变量作用域、顶层 `this`、异步加载与优势详解

32 阅读5分钟

在现代 JavaScript 开发中,ES Module(ESM)CommonJS(CJS) 是两种主流的模块化规范。它们在加载机制、变量作用域、语法结构和执行行为上存在显著差异。本文将从多个维度深入解析这两种模块系统的区别,帮助开发者理解其原理、使用场景以及迁移策略。


一、基本概念

✅ ES Module(ESM)

  • 是 ECMAScript 官方原生支持的模块系统。
  • ECMAScript 2015(ES6) 引入,逐渐成为现代前端开发的标准。
  • 支持浏览器和 Node.js 环境(Node.js v12+ 起支持)。

✅ CommonJS(CJS)

  • 是 Node.js 最早采用的模块系统。
  • 主要用于服务器端编程,但也可通过打包工具用于浏览器环境。
  • 使用 require() 加载模块,module.exports 导出内容。

二、加载机制的区别

✅ ESM:编译时加载(静态导入)

  • 模块依赖在代码执行前就已经解析。
  • import 必须写在文件顶部,不能放在函数或条件语句中。
  • 便于构建工具进行优化(如 Tree Shaking)。
// ✅ 正确
import { foo } from './module.js';

// ❌ 错误
if (condition) {
  import('./module.js'); // SyntaxError
}

✅ CJS:运行时加载(动态导入)

  • 模块在运行时按需加载。
  • require() 可以出现在任意位置(函数内部、条件判断等)。
  • 不适合静态分析,难以进行 Tree Shaking。
if (condition) {
  const module = require('./module');
}

✅ ESM 支持异步加载(Dynamic Import)

虽然默认是同步加载,但 ESM 提供了 import() 函数实现异步加载:

button.addEventListener('click', async () => {
  const module = await import('./lazyModule.js');
  module.doSomething();
});

import() 返回一个 Promise,适用于懒加载、按需加载、路由级代码分割等场景。


三、变量作用域的区别

✅ ESM 中的变量作用域

  • 每个模块都有独立的作用域。
  • 顶级作用域不是全局作用域,不会污染 windowglobalThis
  • 使用 export 显式导出变量。
  • 导入的是“绑定”(Live Binding),即引用而非值拷贝。
// a.js
export let count = 0;
setTimeout(() => count++, 1000);

// b.js
import { count } from './a.js';
console.log(count); // 初始为 0
setTimeout(() => console.log(count), 1500); // 输出 1

✅ CJS 中的变量作用域

  • 模块也有独立作用域。
  • 使用 exports.xxxmodule.exports 导出变量。
  • 导入的是值的“快照”,不会随原始模块更新而变化。
// a.js
let count = 0;
setTimeout(() => count++, 1000);
exports.count = count;

// b.js
const { count } = require('./a');
console.log(count); // 0
setTimeout(() => console.log(count), 1500); // 仍然是 0

四、顶层 this 的区别

特性ES ModuleCommonJS
是否运行在严格模式✅ 是(默认)❌ 否
顶层 this 的值undefinedmodule.exports

✅ ESM 中的 this

  • 所有模块默认运行在严格模式下。
  • 在顶层代码中使用 this,其值为 undefined
// a.mjs
console.log(this); // undefined

✅ CJS 中的 this

  • 顶层 this 等价于 module.exports
  • 可以通过 this.xxx = 'xxx' 来导出变量。
// a.cjs
this.config = { debug: true };
console.log(module.exports); // { config: { debug: true } }

⚠️ 迁移注意:如果项目中有大量使用 this 导出变量的方式,在迁移到 ESM 时会报错。


五、循环依赖的处理

✅ ESM 更安全地处理循环依赖

  • 即使两个模块互相导入,也能通过“引用”获取部分完成的导出内容。
  • 但仍建议避免复杂的循环依赖结构。
// a.js
import { bar } from './b.js';
export let foo = 'foo';

// b.js
import { foo } from './a.js';
export let bar = 'bar';

✅ CJS 处理循环依赖较脆弱

  • 如果模块 A 在初始化过程中 require() B,而 B 又 require() A,A 将返回未完全初始化的 exports
  • 很容易导致某些变量为 undefined
// a.js
const b = require('./b');
exports.foo = 'foo';

// b.js
const a = require('./a');
exports.bar = 'bar';

六、是否支持 Top-level await

特性ESMCJS
是否支持顶层 await✅ 是❌ 否

ESM 支持在模块文件中直接使用 await,无需包裹在 async 函数中:

// config.js
const response = await fetch('https://api.example.com/config');
export const config = await response.json();

此特性对需要预加载数据或配置的模块非常有用。


七、语法和导出方式

特性ESMCJS
导出方式export, export defaultmodule.exports, exports.xxx
导入方式import ... from '...'require('...')
是否支持默认导出✅ 是⚠️ 支持但需手动设置
是否支持命名导出✅ 是✅ 是
是否支持混合导出✅ 是✅ 是

示例:

// ESM
export const name = 'Alice';
export default class User {}

// CJS
exports.name = 'Alice';
module.exports = class User {};

八、Node.js 环境下的模块类型识别

文件后缀模块类型
.mjsESM
.cjsCJS
默认(无指定)package.json 中的 "type" 字段决定
{
  "type": "module"
}

九、ES Module 的优势总结

特性描述
原生支持浏览器和 Node.js 都原生支持
Tree Shaking支持静态分析,可剔除未使用代码
异步加载支持 import() 实现按需加载
实时绑定导入变量是引用,非值拷贝
支持 Top-level await可在模块中直接使用 await
模块缓存模块只执行一次,结果被缓存
更好的封装性模块间作用域隔离更清晰
适合现代开发生态React/Vue/Angular 等框架推荐使用
代码分割支持构建工具可自动做 Code Splitting

十、何时选择 ESM 或 CJS?

场景推荐模块类型
新的前端项目(React/Vue)✅ ESM
新的 Node.js 项目(v14+)✅ ESM
需要懒加载、按需加载✅ ESM(配合 import()
老项目维护(兼容性优先)⚠️ CJS
浏览器端模块化✅ 必须使用 ESM
动态路径加载模块✅ ESM(import())或 CJS(require()
需要 Top-level await✅ ESM
需要简单快速调试✅ CJS(Node.js 默认)

十一、结语

随着 JavaScript 生态的发展,ES Module 已成为主流标准,其设计更符合现代开发需求,尤其在性能优化、模块组织、异步加载等方面具有明显优势。尽管 CommonJS 在旧项目中仍有广泛使用,但在新项目中,尤其是在使用现代构建工具(如 Vite/Webpack/Rollup)的情况下,强烈建议优先使用 ESM


📚 推荐阅读: