这道题不要答得太散。真正适合面试的主线其实是:
CJS 和 ESM 的核心区别,抓住两点:
1、标准来源不同,CJS是社区标准,ESM是语言标准。
2、依赖关系的确认时机不同,CJS是运行时,ESM是运行时+编译时。
由此进一步产生语法、加载方式、静态分析能力、运行环境等差异。
一、面试题:CommonJS 和 ECMAScript Modules(ESM)有什么区别?
1. 核心思路(一句话)
CommonJS 是以运行时加载为核心的模块系统,而 ECMAScript Modules(ESM)是语言标准定义的、以静态模块结构为核心的模块系统,因此两者在语法、依赖分析、加载机制和工程化能力上存在差异。
2. 结构化逻辑思维
可以按照这条主线回答:
CommonJS vs ECMAScript Modules(ESM) │ ┌────────┴────────┐ ↓ ↓ 标准来源 依赖确定时机 │ │ ↓ ↓ CommonJS:社区方案 CommonJS:运行时确定 ESM:ECMAScript标准 ESM:静态分析模块结构 │ ┌────────┼────────┐ ↓ ↓ ↓ 语法 加载 工程化 │ │ │ require 同步 Tree Shaking module import 静态分析 exports import() 更容易优化所以面试时不要一上来背十几个区别。
先说“标准来源 + 依赖确定时机”,其他区别都是由这两个核心点展开的。
3. CommonJS 和 ESM 的标准来源有什么区别?
核心思路
CommonJS 是社区提出的模块规范,Node.js 对其进行了广泛采用;ESM 则是 ECMAScript 官方语言标准的一部分。
CommonJS
CommonJS 并不是 ECMAScript 官方标准,而是早期 JavaScript 社区提出的一套模块化规范。Node.js 早期采用并发展了 CommonJS,因此我们在 Node.js 中经常看到:
constfs=require('fs');module.exports={foo:'bar'};它主要通过:
require()module.exports exports这些运行时机制实现模块化。
ESM
ESM 是 ECMAScript 官方标准中的模块系统。
它通过语言层面的模块语法实现:
importfoofrom'./foo.js';exportdefaultfoo;或者:
exportconstname='Tom';exportfunctionsayHello(){}因此:
CommonJS ↓ 社区模块方案 ↓ 主要通过运行时 API 实现 ESM ↓ ECMAScript 官方语言标准 ↓ 通过语言级 import / export 语法实现4. CommonJS 和 ESM 在语法上有什么区别?
核心思路
CommonJS 使用运行时函数和对象完成模块导入导出;ESM 使用语言级的 import/export 语法。
CommonJS
// math.jsfunctionadd(a,b){returna+b;}module.exports={add};导入:
const{add}=require('./math.js');console.log(add(1,2));ESM
// math.jsexportfunctionadd(a,b){returna+b;}导入:
import{add}from'./math.js';console.log(add(1,2));核心区别:
CommonJS ↓ require() module.exports exports ESM ↓ import export5. 为什么 CommonJS 的 require() 是运行时加载,而 ESM 的 import 是静态模块结构?
这是这道题最重要的原理。
核心思路
CommonJS 的 require() 本质上是运行时执行的函数调用,因此依赖关系可以根据运行时条件决定;ESM 的静态 import 属于模块语法,JavaScript 引擎可以在执行模块代码之前分析模块依赖。
CommonJS:运行时确定依赖
例如:
letmoduleName;if(process.env.NODE_ENV==='development'){moduleName='./dev.js';}else{moduleName='./prod.js';}constconfig=require(moduleName);这里require()的参数可以来自变量。
因为:
代码开始执行 ↓ 判断条件 ↓ 得到 moduleName ↓ 执行 require() ↓ 确定真正需要加载的模块所以 CommonJS 的依赖关系可以在运行过程中确定。
甚至可以:
if(condition){constfoo=require('./foo.js');}ESM:静态 import
ESM 的静态导入:
import{foo}from'./foo.js';模块路径必须符合静态模块语法要求,不能这样:
constmoduleName='./foo.js';import{foo}from moduleName;// ❌ 非法也不能这样:
if(condition){import{foo}from'./foo.js';// ❌ 非法}也不能这样:
for(;;){import{foo}from'./foo.js';// ❌ 非法}原因是:
解析模块 ↓ 发现 import ↓ 建立模块依赖关系 ↓ 加载/链接模块 ↓ 执行模块代码也就是说,静态 import 不需要等业务代码真正运行后,才能知道模块之间是什么关系。
6. ESM 不能动态导入模块吗?
能。
这是非常容易被答错的地方。
ESM 有两种导入方式:
ESM ├── 静态 import │ ↓ │ 编译/链接阶段即可分析 │ └── 动态 import() ↓ 运行时异步加载例如:
constmoduleName='./foo.js';constmodule=awaitimport(moduleName);console.log(module);这里的:
import()是动态 import,属于运行时加载,并且返回 Promise。
所以不能简单说:
“ESM 是同步的”或者“ESM 是异步的”。
更准确的是:
ESM 的静态 import 是静态模块依赖声明;动态 import() 则是在运行时异步加载模块。
7. CommonJS 和 ESM 的加载机制有什么区别?
CommonJS
在 Node.js 中,CommonJS 模块通常表现为:
require() ↓ Node.js 模块加载器 ↓ 定位模块 ↓ 读取文件 ↓ 编译模块 ↓ 执行模块 ↓ module.exports ↓ 返回结果并且 CommonJS 模块具有缓存机制。
例如:
consta=require('./foo');constb=require('./foo');console.log(a===b);通常会得到:
true因为同一个模块通常只执行一次,之后从模块缓存中获取结果。
8. 为什么 CommonJS 中可以使用 arguments、module、exports、__filename?
这是理解 CommonJS 底层原理非常重要的一点。Node.js 并不是简单地把 CommonJS 文件原封不动地直接执行。可以近似理解为 Node.js 会把模块代码包装成类似:
(function(exports,require,module,__filename,__dirname){// 你的 CommonJS 模块代码});例如你的代码:
console.log(__filename);module.exports={name:'Tom'};可以近似理解成:
(function(exports,require,module,__filename,__dirname){console.log(__filename);module.exports={name:'Tom'};});所以:
console.log(arguments.length);在 CommonJS 模块中能够存在,并不奇怪。因为模块代码本身处在一个函数包装环境中。
9. CommonJS 和 ESM 的顶层 this 有什么区别?
在 Node.js CommonJS 模块中:
console.log(this);顶层this通常指向:
module.exports而不是全局对象。
例如:
console.log(this===module.exports);通常为:
true而 ESM 中:
console.log(this);结果是:
undefined因此在 Node.js 中可以记成:
CommonJS 顶层 this ↓ module.exports ESM 顶层 this ↓ undefined另外,ESM 并不只能运行在浏览器中。现代 Node.js 同样原生支持 ESM。
10. CommonJS 和 ESM 为什么对 Tree Shaking 的支持能力不同?
核心思路
Tree Shaking 的关键是“运行前静态分析哪些代码真正被使用”,而 ESM 的静态模块结构更容易被构建工具分析;CommonJS 的动态 require() 会增加静态分析难度。
例如 ESM:
// utils.jsexportfunctionadd(a,b){returna+b;}exportfunctionsubtract(a,b){returna-b;}如果:
import{add}from'./utils.js';console.log(add(1,2));构建工具可以比较明确地知道:
utils.js ├── add ← 使用 └── subtract ← 未使用因此可以尝试删除:
subtract()相关代码。这就是 Tree Shaking 的基础之一。
CommonJS 的问题
例如:
constmoduleName=getModuleName();constutils=require(moduleName);在构建阶段:
getModuleName() 到底返回什么? ↓ 运行之前可能不知道 ↓ 到底加载哪个模块? ↓ 静态分析困难因此:
ESM 天生更适合静态分析和 Tree Shaking。
但这里也要注意一个重要边界:
不能简单说:
“CommonJS 完全不能 Tree Shaking。”
现代构建工具可以对部分 CommonJS 代码进行转换和分析,例如通过 CommonJS 转换插件把部分 CommonJS 转换成 ESM。
因此准确表达应该是:
原生 CommonJS 的动态特性天然不利于静态分析,因此 Tree Shaking 对 ESM 的支持更直接、更可靠;CommonJS 通常需要额外转换或受到代码形式限制。
11. 为什么 ESM 的静态 import 有利于工程化优化?
可以从整个流程理解:
ESM ↓ 静态 import / export ↓ 构建工具无需执行完整业务代码 ↓ 分析模块依赖关系 ↓ 构建依赖图 ↓ 判断未使用的导出 ↓ Tree Shaking ↓ 删除无用代码 ↓ 减少最终产物体积因此:
ESM ↓ 静态模块结构 ↓ 更容易分析 ↓ 更容易优化这也是现代前端工程普遍偏向 ESM 的重要原因之一。
12. CommonJS 和 ESM 的循环依赖有什么区别?
核心思路
两者都可以出现循环依赖,但处理机制不同;ESM 的模块链接机制能够处理循环依赖,并通过实时绑定让模块之间形成依赖关系,而 CommonJS 更容易出现“拿到尚未完成初始化的导出值”等问题。
例如:
A ↓ B ↓ A形成:
A → B → ACommonJS
CommonJS 执行模块时,如果发现模块正在加载,可以从缓存中拿到当前模块的部分导出结果。因此循环依赖场景下可能拿到:
{}或者拿到一个尚未完成初始化的导出对象。
例如:
// a.jsconstb=require('./b');module.exports.name='A';console.log(b.name);// b.jsconsta=require('./a');module.exports.name='B';console.log(a.name);这里就可能出现:
A 加载 ↓ A require B ↓ B 加载 ↓ B require A ↓ A 尚未执行完 ↓ B 得到 A 当前已经导出的内容因此 CommonJS 循环依赖要特别注意模块初始化顺序。
ESM
ESM 在模块执行之前,会先建立模块之间的依赖关系。
大致:
解析模块 ↓ 建立模块依赖图 ↓ 模块链接 ↓ 确定导入导出关系 ↓ 按照规范执行模块ESM 的导入通常不是简单地把一个值复制过来,而是建立与导出绑定的关系。因此它能够更系统地处理循环依赖。
但是:
ESM 并不是“有循环依赖就一定没问题”。
如果循环依赖导致模块在变量初始化之前访问它,仍然可能出现:
ReferenceError所以面试中不要说:
“ESM 可以完美解决循环依赖。”
正确说法是:
ESM 有更明确的模块链接和绑定机制,可以规范化处理循环依赖,但如果在模块初始化完成前访问相关绑定,仍然可能报错。
13. CommonJS 和 ESM 的主要区别总结
| 对比维度 | CommonJS | ECMAScript Modules(ESM) |
|---|---|---|
| 标准来源 | 社区规范 | ECMAScript 官方标准 |
| 典型环境 | Node.js | 浏览器、Node.js 等 |
| 导入 | require() | import |
| 导出 | module.exports/exports | export |
| 静态模块结构 | 不属于其核心设计 | 是核心设计 |
| 依赖确定 | 运行时 | 静态 import 可提前分析 |
| 动态加载 | require(variable)等 | import() |
| 动态 import | 本身就是运行时调用 | import()返回 Promise |
| 顶层 this(Node.js) | 通常为module.exports | undefined |
| Tree Shaking | 天然不利于静态分析 | 更适合静态分析 |
| 循环依赖 | 可能拿到未完成初始化的导出 | 有模块链接机制,但初始化顺序仍重要 |
| 模块包装 | Node.js CommonJS 有函数包装 | ESM 不采用 CommonJS 那种函数包装 |
14. 使用场景
CommonJS 适合
① Node.js 旧项目
例如:
constexpress=require('express');大量传统 Node.js 项目仍然存在 CommonJS 代码。
② 需要运行时决定加载模块
例如:
constadapter=require(`./adapters/${type}.js`);但实际工程中如果需要动态加载,现代 ESM 通常更推荐:
constadapter=awaitimport(`./adapters/${type}.js`);ESM 适合
① 现代前端项目
importReactfrom'react';import{createRoot}from'react-dom/client';② 需要 Tree Shaking
import{debounce}from'./utils.js';③ 需要明确模块依赖关系
入口模块 ↓ 模块 A ↓ 模块 B ↓ 模块 C构建工具可以根据静态 import/export 构建完整依赖图。
④ Node.js 新项目
Node.js 现在也支持 ESM,因此不能再简单地认为:
CommonJS = Node.js ESM = 浏览器正确理解应该是:
Node.js ├── CommonJS └── ESM 浏览器 └── ESM15. 边界场景:CommonJS 和 ESM 能不能混用?
可以,但需要注意互操作规则。
例如现代 Node.js 项目中可能同时存在:
package.json ↓ 项目中 ├── a.cjs ├── b.mjs └── package.json常见约定:
.cjs → CommonJS .mjs → ESM或者通过package.json的:
{"type":"module"}影响.js文件的模块解释方式。
因此面试不要说:
“一个项目只能选择 CommonJS 或 ESM。”
实际上两者可以通过 Node.js 的模块互操作机制进行组合,但具体互相导入存在规则和限制。
16. 一个完整示例
CommonJS
math.js
// CommonJS 使用 module.exports 导出模块成员。functionadd(a,b){returna+b;}functionsubtract(a,b){returna-b;}// 将需要暴露给其他模块的内容挂载到 module.exports。module.exports={add,subtract};index.js
// require() 是 CommonJS 的运行时模块加载方式。const{add,subtract}=require('./math.js');console.log(add(10,5));// 15console.log(subtract(10,5));// 5ESM
math.js
// ESM 使用 export 导出模块成员。exportfunctionadd(a,b){returna+b;}exportfunctionsubtract(a,b){returna-b;}index.js
// import 是 ESM 的静态模块导入语法。// 构建工具和 JavaScript 引擎可以提前分析该模块依赖。import{add,subtract}from'./math.js';console.log(add(10,5));// 15console.log(subtract(10,5));// 5ESM 动态导入
如果需要根据运行时条件决定加载哪个模块:
asyncfunctionloadModule(type){// type 的值只有运行时才能确定。constmodulePath=type==='dev'?'./dev.js':'./prod.js';// import() 是动态导入。// 它在运行时加载模块,并返回 Promise。constmodule=awaitimport(modulePath);returnmodule;}loadModule('dev').then(module=>{console.log(module);});这正好体现:
静态 import ↓ 依赖关系可以提前分析 动态 import() ↓ 运行时决定加载哪个模块17. 需要纠正的几个常见错误
错误一:CommonJS 只能在 Node.js
不准确。
更准确:
CommonJS 最典型的运行环境是 Node.js,但其他 JavaScript 运行时和构建工具也可以实现或兼容 CommonJS。
错误二:ESM 只能在浏览器
错误。
现代 Node.js 同样支持 ESM。
浏览器 → ESM Node.js → ESM + CommonJS错误三:require() 是同步机制,所以 import 是异步机制
这个说法过于简单。
应该说:
CommonJS 的 require() 在 Node.js 中通常是同步加载调用;ESM 的静态 import 是模块语法,其核心特征是静态依赖分析,而不是简单的“异步导入”。ESM 的动态 import() 才明确是异步加载并返回 Promise。
错误四:CommonJS 的顶层 this 是全局对象
错误。
Node.js CommonJS 模块中:
this===module.exports通常为true。
ESM 顶层:
this===undefined错误五:ESM 可以解决所有循环依赖问题
错误。
正确:
ESM 有规范化的模块链接和绑定机制,可以处理循环依赖,但如果循环依赖导致在初始化完成之前访问绑定,仍然可能报错。
错误六:Tree Shaking 只能支持 ESM,CommonJS 永远不能
说得太绝对。
正确:
ESM 的静态结构天然适合 Tree Shaking;CommonJS 的动态特性不利于静态分析,但构建工具可以对部分 CommonJS 代码进行转换和分析。
18. 这道题的主要矛盾和次要矛盾
主要矛盾
① 标准来源
CommonJS → 社区模块规范 ESM → ECMAScript 官方标准② 依赖关系什么时候确定
CommonJS → 运行时确定 ESM 静态 import → 可以在执行前分析模块依赖关系这是最核心的区别。
次要矛盾
由上述区别进一步产生:
静态分析能力 ↓ Tree Shaking ↓ 工程化优化 运行时加载 ↓ 动态 require ↓ 更灵活 ESM 模块链接 ↓ 循环依赖处理机制不同 模块系统不同 ↓ 顶层 this、模块包装、互操作规则不同所以面试时不要把所有区别平铺罗列。
先抓主要矛盾,再解释次要矛盾。
19. 最推荐的面试回答结构
如果面试官只问:
“CommonJS 和 ESM 有什么区别?”
建议按照:
第一层:一句话概括 ↓ 第二层:标准来源 ↓ 第三层:依赖确定时机 ↓ 第四层:由此产生的工程化差异 ↓ 第五层:补充动态 import 和使用场景而不是一上来讲:
this 循环依赖 Node.js 浏览器 Tree Shaking 缓存 函数包装 ...这样容易把一道简单题答复杂。
20. 满分答案
CommonJS 和 ESM 最核心的区别有两个:标准来源不同,以及模块依赖关系的确定时机不同。
第一,标准来源不同。CommonJS 是社区提出的模块规范,Node.js 对它进行了广泛采用,主要通过
require()、module.exports和exports实现;ESM 则是 ECMAScript 官方标准,通过import和export实现。第二,依赖确定时机不同。CommonJS 的
require()是运行时调用,所以可以根据运行时条件决定加载哪个模块;ESM 的静态import属于语言级模块语法,JavaScript 引擎和构建工具可以在执行模块代码之前分析依赖关系。这个区别直接带来一个重要结果:ESM 更适合静态分析,因此更容易实现 Tree Shaking 等工程化优化;CommonJS 的动态加载能力更强,但天然不利于静态分析。
另外,ESM 也支持运行时动态加载,可以使用
import(),它返回 Promise。Node.js 现在同时支持 CommonJS 和 ESM,所以不能简单理解成“CommonJS 是 Node.js,ESM 是浏览器”。一句话总结:CommonJS 更偏运行时模块加载,ESM 更偏静态模块结构;真正的核心区别是模块依赖什么时候能够被确定。