Skip to content

好的,ESM(ECMAScript Modules)和 CJS(CommonJS)是 JavaScript 中两种最主要的模块系统。它们在设计哲学、语法和运行方式上有根本性的区别。

以下是它们之间的核心区别,我用一个对比表格来清晰展示,然后进行详细解释。

核心区别对比表

特性ESM (ECMAScript Modules)CJS (CommonJS)
来源与标准语言标准 (ES6 / ES2015)社区规范 (主要用于 Node.js 环境)
语法 - 导出export default myComponent; <br> export const foo = 'bar';module.exports = myComponent; <br> exports.foo = 'bar';
语法 - 导入import myComponent from './module.js'; <br> import { foo } from './module.js';const myComponent = require('./module');
加载方式静态 (编译时)动态 (运行时)
加载时机异步加载,支持 top-level await同步加载
运行环境现代浏览器现代 Node.jsNode.js (传统)、打包工具 (如 Webpack)
Tree Shaking原生支持,打包器可轻松剔除未使用代码不支持,需要依赖打包工具静态分析
值引用动态映射 (导入的是值的只读引用)值拷贝 (导入的是导出值的拷贝)
文件后缀通常为 .js, .mjs通常为 .js, .cjs
顶层的 thisundefined指向 module.exports

详细解释

1. 来源与历史

  • CJS (CommonJS):诞生于2009年,旨在为浏览器之外的 JavaScript(主要是 Node.js)提供模块系统。它不是一个语言标准,而是一个社区规范。Node.js 的早期版本完全基于 CJS。
  • ESM (ECMAScript Modules):是 JavaScript 语言在 ES2015 (ES6) 标准中正式引入的模块系统。它的目标是为 JavaScript 提供一个统一的、官方的模块方案,无论是在浏览器还是其他环境中。

2. 语法

这是最直观的区别。

  • CJS 使用 require()module.exports/exports:

    javascript
    // 导出
    module.exports = {
      functionA,
      variableB
    };
    // 或者
    exports.functionA = functionA;
    
    // 导入
    const myModule = require('./my-module');
    const { functionA } = require('./my-module');
  • ESM 使用 importexport:

    javascript
    // 导出
    export default functionA;
    export const variableB = 'Hello';
    
    // 导入
    import myModule from './my-module.js';
    import { variableB } from './my-module.js';
    // 或者混合导入
    import myDefault, { namedExport } from './my-module.js';

3. 加载方式:静态 vs. 动态

这是最关键的区别,它导致了其他所有差异。

  • ESM 是静态的

    • import 命令必须在模块的顶层作用域使用,不能写在条件判断或函数内。
    • 这意味着模块的依赖关系在代码编译阶段(编译时) 就已经确定下来了。
    • 优势:这使得打包工具(如 Webpack、Rollup)可以轻松地进行静态分析,从而实现 Tree Shaking(摇树优化),剔除那些从未被导出和导入的代码,极大减小最终打包体积。
  • CJS 是动态的

    • require() 是一个函数,可以在代码的任何地方被调用,包括条件语句和函数内部。
    • 这意味着模块的依赖关系要到代码执行阶段(运行时) 才能确定。
    • 劣势:无法在打包前静态分析出所有依赖,因此难以实现完美的 Tree Shaking。

4. 加载时机:同步 vs. 异步

  • CJS 是同步加载的require() 语句会同步地阻塞代码执行,直到模块被加载并执行完毕,才会继续执行后面的代码。这在服务器端(Node.js)读写本地文件是可行的。
  • ESM 是异步加载的:模块的加载过程是异步的。浏览器不会因为加载一个模块而阻塞页面渲染。它还会建立一个模块依赖图,按依赖顺序执行。ESM 还支持 top-level await,允许在模块顶层使用 await 关键字。

5. 值引用 vs. 值拷贝

这个区别在处理循环依赖时尤为明显。

  • ESM 是值的动态只读引用

    • 导入和导出两个模块指向的是同一个内存地址
    • 当导出的模块修改了一个值,导入模块中看到的值也会随之改变。
    • 导入的值是只读的,你不能直接修改它(除非导出的是一个对象,你可以修改其属性)。
  • CJS 是值的拷贝

    • require() 导入的是导出值的一个副本(浅拷贝)。
    • 即使导出的模块后来改变了这个值,导入模块中看到的仍然是最初导入的副本,不会变化。

示例:

javascript
// counter.js (ESM)
export let count = 0;
export const increment = () =&gt; { count++; };

// main.js (ESM)
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1 (值被改变了,因为是对同一内存地址的引用)

// counter.js (CJS)
let count = 0;
exports.increment = () =&gt; { count++; };
exports.getCount = () =&gt; count; // 需要一个getter来获取最新值

// main.js (CJS)
const { increment, getCount } = require('./counter');
let count = getCount();
console.log(count); // 0
increment();
console.log(getCount()); // 1
console.log(count); // 0 (原始的count变量仍是拷贝时的值,未变)

6. 运行环境

  • CJS原生支持于 Node.js。在浏览器中直接使用需要打包工具处理。
  • ESM
    • 现代浏览器 已原生支持 (&lt;script type="module"&gt;)。
    • 现代 Node.js (v12+) 也已原生支持,通常通过 .mjs 文件扩展名或 package.json 中设置 "type": "module" 来启用。

总结与如何选择

  • 新项目毫无疑问应该使用 ESM。它是语言标准,支持 Tree Shaking,并且是未来的方向。
  • Node.js 开发
    • 新项目建议在 package.json 中设置 "type": "module" 来使用 ESM。
    • 旧的库和项目可能仍然使用 CJS。
  • 旧项目/库:如果是一个旧的 CJS 项目,除非有充分理由,否则迁移到 ESM 的成本可能较高。但如果要发布新的库,最好提供 ESM 格式的版本,以便用户能进行 Tree Shaking。
  • 在 Node.js 中互操作
    • 在 ESM 模块中可以使用 import() 动态导入 CJS 模块。
    • 在 CJS 模块中无法使用 require() 加载 ESM 模块,因为 ESM 是异步的。

简单来说,ESM 是现代化开发的标配,而 CJS 是历史遗留和特定环境(如传统 Node.js)下的产物