先记住这个答案
ESM 的 import 建立的是一个对源模块导出变量的实时引用。源模块把 count 从 0 改成 1,导入方读 count 立刻就是 1。CommonJS 的 require() 不是这样——它在执行 require 的那一刻,把 module.exports 的值复制了一份。如果导出的是数字、字符串这类基本类型,之后源端怎么改,导入方手上的还是旧值。
- ESM
import是实时绑定,随时读到源模块的最新值 - CJS
require()对基本类型是值拷贝,之后的修改看不到 - CJS 导出对象时两边共享同一个引用,但这不是实时绑定
- 实时绑定是单向只读的,导入方不能赋值
用代码看清两者的区别
直接看一个 ESM 的例子。源模块导出一个 let 变量,稍后改变它的值,导入方能看到变化。
// counter.mjs
export let count = 0
export function increment() {
count++
}
// main.mjs
// import { count, increment } from './counter.mjs'
// console.log(count) // 0
// increment()
// console.log(count) // 1 —— 实时读到了源模块修改后的值导入方没有自己的 count 副本,count 始终指向源模块的那个变量。每次读取都是去源模块那边拿当前值。
CommonJS 就不一样了。require() 在执行时把 module.exports 的值复制过来。如果导出的是基本类型,复制的是值本身,后面源端的修改和导入方无关。
为什么两套系统的行为不一样
根本原因在于两者的设计目标不同。
CommonJS 是 Node.js 早期的模块系统,设计上就是同步执行的脚本拼接。require() 就像一个函数调用,执行到这行时同步运行目标模块的代码,然后把 module.exports 的值返回给调用方。返回的是值,不是绑定关系。
ESM 是语言层面的模块标准。引擎在代码执行之前就完成了模块图的解析和链接。import { count } 不是"把 count 的值拷过来",而是"建立一个绑定,指向源模块的 count 变量"。这个绑定在整个生命周期里一直有效。
一个直观的类比:CJS 的 require 像拍照——拍的那一刻是什么就是什么。ESM 的 import 像装了一个监控摄像头——随时看到最新画面。
CJS 导出对象时的特殊情况
CJS 里如果导出的是一个对象,require() 拿到的是对象的引用(不是深拷贝)。两边操作的是同一个对象,所以修改对象的属性确实两边都能看到。
但这和 ESM 的实时绑定有本质区别。CJS 里如果源模块执行 module.exports.data = newObj,把 data 属性指向了一个新对象,之前已经解构出 const { data } = require('./a') 的导入方看到的还是旧对象。因为 data 是在 require 那一刻从对象上拿出来的值(引用),后面属性指向变了,已经拿到手的引用不会自动更新。
ESM 的实时绑定不存在这个问题。只要源模块用 let 声明并修改了导出变量,不管是基本类型还是对象,导入方每次读取都是最新值。
容易答错的地方
- 以为 CJS 导出对象就等于实时绑定
- CJS 导出对象时,两边确实共享同一个引用,但这只是 JavaScript 对象传引用的基本机制,不是实时绑定。实时绑定的意思是"变量本身"的重新赋值也能被感知到,CJS 做不到这一点。
- 以为 ESM 实时绑定意味着导入方可以写入
- ESM 的
import绑定是只读的。导入方尝试给count赋值会直接报TypeError。只有源模块自己能修改导出的变量,导入方只能读。这是 ESM 的设计约束——保证数据流的方向是单向的。
面试官还会怎么问?
循环依赖时实时绑定有什么表现?
ESM 处理循环依赖时,被循环引用的模块可能还没执行完,导出的变量处于未初始化状态(TDZ)。如果在那个时间点读取该变量,会报 ReferenceError。但一旦源模块执行完,实时绑定就能正常读到值了。CJS 的循环依赖则是拿到部分完成的 module.exports,不会报错但可能拿到 undefined。
Webpack 打包后还有实时绑定吗?
有。Webpack 会把 ESM 的实时绑定转换成 getter 函数来模拟。比如 import { count } 在打包后变成通过一个 getter 去读源模块变量的当前值,效果和原生 ESM 的实时绑定一致。
CJS 里有没有办法模拟实时绑定?
可以用 getter 实现。不导出值本身,而是导出一个有 getter 的对象,比如 Object.defineProperty(module.exports, 'count', { get() { return count } })。这样导入方每次读 count 都会调用 getter 拿到最新值。不过这种写法很少见,如果需要实时绑定,更好的做法是迁移到 ESM。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。