跳到主要内容

AMD、CommonJS、ES Module:前端模块化到底是怎么演进的?

前端面试里问“说说 AMD、CommonJS、ES Module 的区别”,本质不是让你背 API,而是看你是否理解:

  • 为什么早期前端需要 AMD
  • 为什么 Node 选择了 CommonJS
  • 为什么 ES Module 最终成为标准
  • 三者在加载时机、依赖分析、运行环境、循环依赖、Tree Shaking 上到底差在哪

如果你只记一句话:

  • AMD 解决的是浏览器里“脚本异步加载和依赖声明”的问题
  • CommonJS 解决的是服务端里“模块同步加载和导出复用”的问题
  • ES Module(ESM) 是语言标准层面的模块系统,支持静态分析,是现代前端和 Node 的主流方案

一、面试速答(30 秒版)

答:

AMD、CommonJS、ES Module 都是模块化方案,但适用场景和设计目标不同。

  • AMD 主要用于早期浏览器,代表实现是 RequireJS。它把依赖前置声明,模块通过异步加载来避免阻塞页面。
  • CommonJS 主要用于 Node.js,使用 requiremodule.exports,特点是同步加载,适合本地文件系统环境。
  • ES Module 是 JavaScript 官方标准,使用 import / export。它的核心优势是静态结构,编译阶段就能分析依赖,因此支持 Tree Shaking、按需优化,也逐渐统一了浏览器和 Node 的模块体系。

如果追问区别,重点答 4 个点:

  1. 加载方式:AMD 异步,CommonJS 同步,ESM 支持静态导入与异步加载配合。
  2. 语法层级:AMD/CommonJS 是社区规范,ESM 是语言标准。
  3. 依赖分析时机:CommonJS 运行时,ESM 编译时。
  4. 导出语义:CommonJS 更像导出一个对象快照容器;ESM 是实时绑定(live binding)

二、为什么模块化会经历 AMD → CommonJS → ESM

没有模块化时,前端代码通常是多个 <script> 直接堆在页面里:

  • 全局变量互相污染
  • 文件顺序强依赖,谁先加载很关键
  • 依赖关系靠人脑维护
  • 代码复用和拆分都很痛苦

模块化的核心目标一直没变:

  1. 隔离作用域
  2. 显式声明依赖
  3. 复用导出能力
  4. 支持更大的工程化代码组织

但不同时代的运行环境不同,所以方案也不同:

  • 浏览器网络请求慢、脚本会阻塞页面,于是 AMD 强调“异步加载”
  • Node 主要读本地磁盘,模块文件就在机器上,于是 CommonJS 选择“同步加载”换简单直观
  • 当 JavaScript 语言本身需要标准模块能力时,就有了 ESM

演进关系可以用这张图理解:


三、AMD 是什么:为“浏览器异步加载脚本”而生

AMD 全称是 Asynchronous Module Definition,核心思想是:

  • 先声明依赖
  • 依赖加载完成后再执行工厂函数
  • 整体围绕浏览器里的异步脚本加载设计

典型写法:

define(["./math"], function (math) {
function addTen(num) {
return math.add(num, 10);
}

return {
addTen,
};
});

3.1 AMD 的优点

  • 适合浏览器环境,不会像同步脚本那样强阻塞依赖解析
  • 依赖关系写在前面,可读性比“全局变量拼装”好很多
  • 在打包工具普及前,确实解决了大型前端项目的模块组织问题

3.2 AMD 的问题

  • 语法啰嗦,业务代码被 define / 回调包裹
  • 依赖前置虽然清晰,但写起来不自然
  • 本质仍是社区方案,不是语言级能力
  • 后来有了打包工具和 ESM,AMD 的必要性明显下降

3.3 AMD 适合理解成什么

面试里可以这么表述:

AMD 是早期浏览器模块化的工程化折中方案,重点不是“模块语法多优雅”,而是“如何在浏览器里异步、安全地加载依赖”。


四、CommonJS 是什么:为 Node 的同步文件系统而生

CommonJS 是 Node 早期采用的模块规范,最经典的语法是:

const math = require("./math");

function addTen(num) {
return math.add(num, 10);
}

module.exports = {
addTen,
};

4.1 CommonJS 为什么用同步加载

因为 Node 的典型运行场景是服务端:

  • 模块文件通常就在本地磁盘
  • 启动阶段同步读取和执行模块,成本可接受
  • 同步 API 写法简单,开发体验直接

所以 require() 的特点是:

  • 运行时执行
  • 加载到哪里,执行到哪里
  • 返回当前模块导出的对象

4.2 CommonJS 的优点

  • 语法简单,容易理解
  • 很符合服务端编程直觉
  • Node 生态长期围绕它构建,历史包袱和兼容性都很强

4.3 CommonJS 的问题

  • 同步加载 不适合浏览器直接使用
  • require() 是运行时调用,依赖关系难以做彻底的静态分析
  • 对 Tree Shaking 不友好
  • 在循环依赖下,拿到的可能是“尚未执行完”的部分导出

五、ES Module 为什么是终局方案

ES Module(ESM)是 JavaScript 官方标准模块系统,语法是:

// math.js
export function add(a, b) {
return a + b;
}

// index.js
import { add } from "./math.js";

console.log(add(1, 2));

它和 AMD、CommonJS 的最大差别,不只是语法更现代,而是:
模块关系是静态的,import / export 必须在顶层,编译阶段就能确定依赖图。

5.1 ESM 的核心优势

1. 静态分析友好

因为导入导出结构是固定的,构建工具在编译阶段就能知道:

  • 依赖了哪些模块
  • 哪些导出被实际使用
  • 哪些代码可以删除

这就是 Tree Shaking 的基础。

2. 浏览器和 Node 都在统一到 ESM

现代浏览器支持原生:

<script type="module" src="./main.js"></script>

Node 也支持 ESM,只是历史上同时兼容 CommonJS,所以会有 .mjspackage.jsontype 字段、互操作等问题。

3. 支持动态导入

静态导入之外,ESM 也支持:

const module = await import("./math.js");

这让它既保留了“静态结构”的优势,又能做懒加载和代码分割。


六、三者最核心的区别

6.1 总表对比

维度AMDCommonJSES Module
主要场景早期浏览器Node.js / 服务端浏览器 + Node + 构建工具
代表语法define / requirerequire / module.exportsimport / export
加载方式异步同步静态导入为主,配合异步加载
依赖分析运行前声明,但仍偏工程方案运行时编译时静态分析
是否语言标准
Tree Shaking
浏览器原生支持
导出语义工厂函数返回值module.exports 对象live binding

6.2 一个最常被问的点:为什么 ESM 更利于 Tree Shaking

因为 Tree Shaking 的前提是:构建时能确定“哪些导出用了,哪些没用”。

CommonJS 做不到彻底静态分析,原因在于:

const mod = require(getModulePath());

这里依赖路径可能是动态计算的,构建工具很难在编译阶段完全确定。

而 ESM 通常要求:

import { foo } from "./foo.js";

依赖路径和导入成员都是静态可分析的,因此优化空间更大。


七、module.exportsexport default 是一回事吗?

不是一回事。

这是面试里非常高频的误区。

7.1 CommonJS

module.exports = {
foo: 1,
};

本质是给当前模块导出对象赋值。

7.2 ESM

export default {
foo: 1,
};

本质是定义一个“默认导出(default export)”。

虽然很多打包工具会帮你做互转,但语义层面不同:

  • CommonJS 是“导出一个对象值”
  • ESM 是“导出一个模块绑定”

7.3 实战里为什么会混乱

因为 Babel、Webpack、TypeScript 等工具会做兼容层处理,例如生成:

exports.default = ...

这会让很多人误以为两者天然等价。
正确说法是:

它们可以互操作,但不是同一个规范里的同一个概念。


八、循环依赖:CommonJS 和 ESM 的表现差异

循环依赖指的是:

  • A 依赖 B
  • B 又依赖 A

8.1 CommonJS:拿到“执行到一半的导出”

因为 CommonJS 是运行时、同步执行模块代码,所以如果发生循环依赖,某个模块可能还没完全执行完,另一个模块就已经 require 它了。

这时拿到的是:

  • 已经赋值完成的那部分导出
  • 还没初始化完的部分可能是 undefined

8.2 ESM:支持 live binding,但仍要注意 TDZ 和初始化顺序

ESM 的依赖关系会先建立绑定关系,再执行模块。
因此循环依赖通常比 CommonJS 更“可控”,但并不代表你可以随便写。

如果你在变量真正初始化前访问它,仍可能触发:

  • Temporal Dead Zone(暂时性死区)
  • ReferenceError

面试里建议这样回答:

ESM 对循环依赖的处理机制比 CommonJS 更规范,因为它先建立导入导出的绑定关系;但如果代码依赖错误的初始化时机,依然会出问题。


九、浏览器、Node、打包工具分别怎么看这三种模块

9.1 浏览器

  • 早期浏览器没有原生模块机制,所以才出现 AMD
  • 现代浏览器支持原生 ESM
  • 但浏览器直接加载大量 ESM 文件时,请求数量、缓存策略、兼容性仍要结合工程工具处理

9.2 Node

  • Node 历史包袱很重,CommonJS 生态庞大
  • Node 现在同时支持 CommonJS 和 ESM
  • 真实项目里经常会遇到两套模块系统混用

9.3 打包工具

Webpack、Rollup、Vite 这类工具的一个重要职责,就是:

  • 解析各种模块语法
  • 建依赖图
  • 转成目标环境可运行的产物

为什么 Rollup 特别强调 ESM?

  • 因为 ESM 的静态结构特别适合做 Tree Shaking
  • 这也是现代库构建偏好 ESM 输出的重要原因

十、真实项目里怎么选

10.1 前端应用

默认优先:

  • 源码层面使用 ESM
  • 交给 Vite / Webpack / Rollup 处理兼容和打包

原因很简单:

  • 生态主流
  • 优化能力最好
  • 和浏览器原生方向一致

10.2 Node 项目

如果是老项目:

  • 很可能仍以 CommonJS 为主

如果是新项目:

  • 可以优先考虑 ESM
  • 但要确认依赖库、运行环境、测试工具链是否兼容

10.3 库开发

很多库会同时产出:

  • esm 版本
  • cjs 版本

原因不是“重复劳动”,而是为了兼容不同消费方:

  • 构建工具偏好 ESM
  • 部分 Node / 老工具链仍依赖 CJS

十一、典型面试题与标准答法

Q1:AMD、CommonJS、ES Module 的区别?

答: AMD 面向浏览器异步加载,CommonJS 面向 Node 同步加载,ES Module 是 JavaScript 官方标准模块系统。AMD 和 CommonJS 都是社区规范,ESM 是语言级能力。最大的本质差异在于:CommonJS 是运行时加载,ESM 是静态结构,可在编译阶段分析依赖,因此更利于 Tree Shaking 和现代工程优化。

Q2:为什么浏览器里早期要 AMD,而不是 CommonJS?

答: 因为浏览器加载模块依赖网络请求,若像 CommonJS 那样同步加载会严重阻塞页面。AMD 的设计目标就是在浏览器环境中异步加载依赖,等依赖都就绪后再执行模块工厂函数。

Q3:为什么说 ESM 更适合 Tree Shaking?

答: 因为 ESM 的 import / export 是静态声明,构建工具编译时就能确定依赖图和导出使用情况,所以能安全删除未使用代码;而 CommonJS 的 require 是运行时调用,很多场景无法彻底静态分析。

Q4:CommonJS 和 ESM 的导出有什么语义差异?

答: CommonJS 更像导出一个对象容器,require 拿到的是模块导出结果;ESM 导出的是模块绑定,导入方拿到的是 live binding,因此导出值变化时,导入方读取到的是最新绑定结果。

Q5:现在还需要了解 AMD 吗?

答: 需要,但更多是为了理解模块化演进和维护老项目。现代前端开发实际主流已经是 ESM + 打包工具,AMD 在新项目里基本不是首选。


十二、常见误区

12.1 误区一:export default 就等于 module.exports

错。它们只是常被工具链做了兼容转换,底层语义并不相同。

12.2 误区二:ESM 一定是异步加载,CommonJS 一定是同步加载

这句话不够严谨。

  • CommonJS 的 require 语义是同步的
  • ESM 的模块结构分析是静态的
  • 至于资源文件最终怎么被加载,还和宿主环境、浏览器、打包工具有关

更准确的说法是:

ESM 是静态模块系统,不等于“天然异步执行模型”;它只是更容易和现代异步加载、代码分割结合。

12.3 误区三:用了 Webpack/Vite,就不需要理解模块规范

也错。
工具只是帮你“转译和打包”,但很多现象仍然直接来自模块语义,比如:

  • 为什么 Tree Shaking 对 CJS 效果差
  • 为什么循环依赖行为不同
  • 为什么某些库默认导出/命名导出在互操作时会出坑

十三、速记要点

  • AMD:浏览器优先,异步依赖加载,代表是 RequireJS。
  • CommonJS:Node 优先,require 同步加载,module.exports 导出。
  • ESM:官方标准,import / export,静态结构,支持 Tree Shaking。
  • AMD 解决浏览器异步加载问题,CommonJS 解决服务端模块组织问题,ESM 解决语言标准统一和静态分析问题。
  • CommonJS 偏运行时,ESM 偏编译时。
  • 现代前端默认优先 ESM;理解 AMD 主要为了历史和面试,理解 CommonJS 主要为了 Node 兼容与旧生态。