ES Module 和 CommonJS 有什么区别?
简化版
ES Module 使用 import/export,是静态模块系统,支持 Tree Shaking,导出是 live binding;CommonJS 使用 require/module.exports,主要运行时加载,导出更像值拷贝或对象引用。浏览器原生支持 ESM,Node 同时支持两套体系。
详细版
ESM:
import { foo } from './foo.js';
export const bar = 1;
CommonJS:
const foo = require('./foo');
module.exports = { bar: 1 };
关键区别:
- ESM 静态分析,编译阶段能知道依赖关系。
- CommonJS 运行时加载,可以写在条件分支里。
- ESM 导入是只读 live binding。
- CommonJS 默认同步加载,更适合早期 Node。
打包工具能利用 ESM 做 Tree Shaking,删除未使用导出。
完整版教学
一、静态和动态的差别
ESM 的 import 必须在顶层,不能随便写在 if 里:
import { a } from './a.js';
这让工具在代码执行前就能分析模块依赖。CommonJS 的 require 是函数调用:
if (condition) {
require('./a');
}
灵活,但静态优化更困难。
二、live binding 是什么
ESM 导入的不是普通复制值,而是导出变量的实时绑定。如果导出模块内部更新了变量,导入方读到的是新值。
同时导入绑定是只读的,不能在导入方直接改。
三、循环依赖表现不同
ESM 因为有静态结构和 live binding,对循环依赖处理相对更可预测。CommonJS 在循环依赖中可能拿到未执行完模块的半成品 exports。
面试中不必展开太深,但要知道循环依赖是模块系统的重要差异点。
四、工程中的选择
现代前端项目基本使用 ESM,便于浏览器、Vite、Rollup、Webpack 优化。Node 老项目仍有大量 CommonJS,新项目则越来越多使用 ESM。
混用时要注意默认导出、命名导出、文件扩展名、type: module 等兼容问题。
五、面试追问与工程落地
模块化常见追问是“ESM 为什么能 Tree Shaking”。因为 import/export 是静态结构,打包器能在构建阶段分析哪些导出没有被使用,再配合副作用标记删除无用代码。CommonJS 的 require 更动态,静态分析难度更大。
另一个追问是“ESM 导入能不能改”。导入绑定是只读的,不能在导入方重新赋值。但如果导入的是对象,对象内部属性是否能改,要看对象本身是否被冻结。只读的是绑定,不一定是深层数据。
工程里要注意副作用模块,比如只 import 一个 CSS、polyfill 或全局注册逻辑:import './setup'。这种模块即使没有导出也不能随便删除。库作者通常通过 package.json 的 sideEffects 告诉打包器哪些文件有副作用。
六、用链接、求值和缓存解释模块行为
ESM 的静态 import、export 让引擎在执行前建立模块依赖和绑定关系,随后才进入求值阶段;动态 import() 则是在运行期返回 Promise 的独立能力。导入名称是只读的 live binding:导出模块更新该绑定后,导入方下次读取可看到新值,但导入方不能给它重新赋值。
// counter.js
export let count = 0
export const inc = () => count++
// main.js
import { count, inc } from './counter.js'
inc()
console.log(count) // 1
| 维度 | ESM | CommonJS |
|---|---|---|
| 依赖声明 | 静态 import;另有动态 import() | 运行期 require() |
| 导入语义 | 只读 live binding | 获得 module.exports 当前导出值 |
| 常见加载方式 | 支持异步链接和顶层 await | 传统 require 同步执行 |
| 优化基础 | 静态图便于 tree shaking | 动态写法较难静态分析 |
CommonJS 模块首次 require 后通常按解析后的文件名缓存;再次加载常返回缓存中的 module.exports。这不等于“所有导入都是值拷贝”:若导出的是对象,消费者仍能观察同一对象的属性变更;若先解构出一个原始值,则该本地变量不会自动跟随之后的重新赋值。
循环依赖不是由某一种模块系统自动解决的。ESM 可能在初始化前访问绑定而触发暂时性死区错误,CommonJS 则可能拿到尚未执行完的部分导出;降低顶层副作用、把共享协议抽离为第三个模块,通常比依赖执行顺序更可靠。
Tree shaking 是“静态结构让优化成为可能”,不是 ESM 语法的必然结果;打包器还要结合使用情况和副作用信息才能安全删除代码。
七、常见误区与追问
- 误区:ESM 完全不能动态加载模块。 静态 import 需位于模块顶层,但
import()可以按运行条件异步加载。 - 误区:CommonJS 导入的一定是不可变化的值拷贝。
require返回导出值;共享对象的内部变化仍可见,具体表现取决于消费方式。 - 误区:只要改成 ESM 就一定能完成 tree shaking。 动态访问和有副作用的模块都会限制删除,最终效果取决于工具配置与代码形态。
- 追问:live binding 为什么不能在导入方赋值? 导入方获得的是导出绑定的只读视图,所有权仍在导出模块。
- 追问:exports.x 和 module.exports 是什么关系? CommonJS 初始时
exports指向module.exports;给exports整体重新赋值不会自动改变真正的导出对象。 - 追问:模块为什么通常只执行一次? 加载器会缓存已求值的模块实例,后续导入复用它;不同 URL 或解析结果可能形成不同实例。
- 追问:怎样降低循环依赖风险? 抽离共同依赖、减少顶层执行和延迟读取具体值,并用测试覆盖真实加载顺序。
八、加强记忆
ESM 是“静态、可分析、live binding”,CommonJS 是“运行时、同步 require、exports 对象”。前端工程优先 ESM,因为它更利于构建优化。
分析模块题时按解析与链接、求值、缓存三个阶段展开,再讨论 live binding 和循环依赖,因果关系会更清楚。