面试题:ES Module 与 CommonJS 模块方案有什么异同?

ES Module (ESM) 和 CommonJS (CJS) 是 JavaScript 中两种主流的模块化方案。它们都旨在解决代码组织、依赖管理和命名空间污染的问题,但在设计理念、语法和行为上存在显著差异。


一、 核心相同点

  1. 模块化目标:两者都允许将代码分割成独立的模块(文件),每个模块拥有自己的作用域。
  2. 导出与导入:都提供了明确的语法来导出export / module.exports)模块内的变量、函数或类,并在其他模块中导入import / require)使用。
  3. 避免全局污染:通过模块化,避免了将所有变量和函数都挂载到全局对象上。

二、 主要差异

特性ES Module (ESM)CommonJS (CJS)
标准ES6 (ECMAScript 2015) 正式引入,是 官方语言标准Node.js 社区早期采用的事实标准,非官方语言标准。
语法静态声明:
import { foo } from './module.js';
export const bar = 42;
动态调用:
const foo = require('./module');
module.exports = bar;
加载方式静态编译时加载 (Static).
在代码执行前就分析依赖关系。
动态运行时加载 (Dynamic).
在代码执行到 require 时才同步加载并执行模块。
加载时机编译时确定依赖运行时确定依赖
加载性质异步 (在浏览器环境中,通过 <script type="module">)。同步
this 指向在模块顶层,thisundefined在模块顶层,this 指向 module.exports
循环依赖处理能更好地处理,通过“活绑定”暴露一个代理。处理较差,可能导致返回 undefined 或不完整的对象。
导出类型导出的是 绑定 (live bindings)
导入的值会随原模块更新而更新(对于 const 等原始值,其“值”不变,但引用的对象/数组内容变仍可见)。
导出的是 值的拷贝 (value copy)
一旦导出,后续修改 module.exports 不影响已导入的地方。
默认导出支持 export default,一个模块只能有一个。通过 module.exports = value 实现,默认导出整个对象。
命名导出支持多个 export 命名导出。需要给 module.exports 对象添加属性来实现。
重命名import { foo as bar } from './module';const { foo: bar } = require('./module'); (需解构)
环境支持浏览器原生支持,Node.js 从 v8.5.0+ 支持 (.mjs"type": "module").Node.js 的原生模块系统。

三、 关键差异详解

1. 静态 vs 动态 (import vs require)

这是最根本的区别。

  • ESM (import)
    • 静态import 语句必须位于模块的顶层,不能在条件语句或函数内部。这使得工具(如打包器、IDE)可以在不执行代码的情况下分析整个应用的依赖树,进行静态分析死代码消除(Tree Shaking)等优化。
  • CJS (require)
    • 动态require 是一个函数调用,可以出现在代码的任何地方,甚至可以根据条件动态加载不同的模块。

2. 活绑定 vs 值拷贝

  • ESM – 活绑定 (Live Bindings)
    // --- math.js ---
    export let counter = 0;
    export function increment() {
      counter++;
    }
    
    // --- main.js ---
    import { counter, increment } from './math.js';
    console.log(counter); // 0
    increment();
    console.log(counter); // 1 (值已更新!)
    也会反映这个变化(因为导入的是一个“只读视图”或“绑定”)。
  • CJS – 值拷贝
    // --- math.js ---
    let counter = 0;
    function increment() {
      counter++;
    }
    module.exports = { counter, increment };
    
    // --- main.js ---
    const { counter, increment } = require('./math.js');
    console.log(counter); // 0
    increment(); // 这里改变了 math.js 内部的 counter,但 exports 对象的 counter 属性仍是初始值 0
    console.log(counter); // 0 (仍然是 0!)
    require 返回的是 module.exports 对象的一个快照。即使 increment 函数内部改变了 countermain.js 中解构出来的 counter 变量仍然是最初的值 0。要看到变化,需要直接访问导出的对象:
    const math = require('./math.js');
    console.log(math.counter); // 0
    math.increment();
    console.log(math.counter); // 1

3. 循环依赖

  • ESM:通过创建一个“模块记录”和“活绑定”,能更优雅地处理循环依赖。它会先初始化模块,暴露一个代理,允许对方模块先拿到一个“占位符”,后续再填充实际值。
  • CJS:容易出现问题。如果 A require B,B 又 require A,而此时 A 还未执行完,module.exports 可能还是空对象或部分定义的状态,导致 B 拿到一个不完整或 undefined 的依赖。

四、 总结

方面ES Module (ESM)CommonJS (CJS)
定位未来的标准,设计更先进,支持静态分析和 Tree Shaking。历史事实标准,在 Node.js 生态中根深蒂固。
适用场景现代前端开发 (React, Vue 等),需要打包优化的应用。Node.js 后端开发(尤其旧项目),需要动态 require 的场景。
趋势主流和推荐方向。Node.js 和浏览器都在积极支持。逐渐被 ESM 取代,但在可预见的未来仍会共存。

简单来说

  • 你想用 import/export,并且希望有更好的性能优化(Tree Shaking)、更清晰的依赖关系,就用 ESM
  • 你在写 Node.js 代码,且需要动态加载模块,或者依赖大量只支持 CJS 的旧包,目前可能还得用 CJS

现代项目通常会使用打包工具(如 Webpack, Vite)或 Node.js 的 ESM 支持,优先选择 ES Module。

THE END
喜欢就支持一下吧
点赞11 分享