先记住这个答案
ESM 在模块实例化阶段为每个导出名称建立绑定,该绑定关联模块内部的变量(如 let 声明的变量),导入方引用该绑定,因此读取时获取模块内部的实时值。CommonJS 在模块执行时通过 module.exports 赋值,加载方拿到的是 exports 对象(引用)或基础类型值的拷贝;若导出的是基础类型,模块内部后续对其重新赋值不影响已有导入;若导出对象,则其属性变化通过同一引用可见。实时绑定是 ESM 的语言特性,CommonJS 是运行时对象赋值机制,决定了行为差异。
- ESM 变量绑定与模块内部直接关联
- CommonJS 导出的对象或值是被复制或持引用
- 实时绑定依赖模块内部变量不是对象属性
为什么 ESM 绑定天然实时
ESM 模块在实例化阶段递归解析依赖,先构建模块记录(Module Record),为每个导出名称创建绑定。绑定指向模块环境的内部槽([[BindingName]]),对应模块顶层声明。当模块代码执行时,声明被实例化,绑定的值会随模块内变量更新。import 声明的标识符在整个模块作用域内是只读的,但读取它时实际上是从绑定的模块环境取当前值,因此具有实时性。
CommonJS 的模块是一个普通函数包装,模块内 module.exports 被赋值。加载方调用 require 时,拿到的是 module.exports 的当前引用,并且该引用被固定。如果模块内部重新赋值 module.exports 为新对象,已缓存的 require 仍指向旧对象。若通过 module.exports 导出一个 let 变量(其实是变量值),则 require 得到的是导出的那一刻的拷贝,因为导出值是赋给 exports 对象的属性,属性值是当前原始值快照。
计数器模块的实时访问变化
有两个模块 counter.js,分别用 ESM 和 CommonJS 实现,都导出一个 increment 函数和一个计数器 count。ESM 用 export let count = 0; export function increment(){ count++; }。CommonJS 用 let count = 0; module.exports = { count, increment(){ count++; } }。主模块每秒读取 count 一次,一共读两次并调用 increment 后打印。
在 ESM 下先引入 import { count, increment } from './counter.mjs',由于绑定存在,调用 increment() 后再次读取 count 显示为 1,是实时的。在 CommonJS 下,由于 count 是解构赋值得到的拷贝,调用 increment() 后 count 始终为 0,因为原始模块里变量变了但拷贝没变。如果改为 require('...').count 却访问的是模块对象的属性,该属性值也是初始拷贝,不会更新。只有将 count 改为对象属性如 module.exports.obj.count 才能间接看到更新。这个场景是演示实时绑定差异的经典例子。
当对象属性掩盖实时绑定
ESM 的实时绑定只对模块顶层声明的变量有效。若导出语句写的是 export default { count: 0 },导入方拿到的是默认导出的对象引用,该对象属性可以被修改且能看到,但修改模块内部 count 变量并不会改变该对象属性,因为它们是不同的存储位置。同理,export { obj } 且 obj 对象的属性改变能看到,但把 obj 整个重新赋值,导入方看到的 obj 仍指向旧对象,因为绑定指向变量本身,不是变量内容。
实际编码时,如果想让外部观察到源模块内部的状态变化,应导出顶层变量(let)或者提供 getter 函数,或者导出对象并在内部修改对象属性。如果因为性能或接口设计导出的是值语义的数据,则依赖拷贝行为。在迁移旧的 CommonJS 模块到 ESM 时,需检查内部是否有延迟赋值或动态更新,否则代码逻辑可能变化。这也是为什么许多模块同时提供函数而不是公开可变状态。
容易答错的地方
- 误认为 exports 对象本身实时
module.exports只会被求值一次,之后require返回缓存的对象引用。模块内部给module.exports重新赋值,不会影响已经缓存的对象。只有在模块内部通过修改已导出对象的属性时,外部才能看到变化。- 误以为 ESM 导入的对象属性也是绑定
- ESM 的实时绑定只针对变量标识符,如果导出的是对象,绑定的是对象引用,对象属性变化能否看到取决于属性访问,但绑定本身不会随内部重新赋值对象而更新。这与通过该对象 API 操作不同。
面试官还会怎么问?
在 ESM 中给导入的变量重新赋值会发生什么?
会抛出 TypeError,因为导入的绑定是只读的,赋值操作在运行时失败,与严格模式无关。
Node.js 中同时使用 require 和 import 是否都满足实时绑定?
只有 require 对 CommonJS 是拷贝,而 import 对 ESM 是绑定。但 Node.js 允许 import 引入 CommonJS 模块,此时导入的通常是 module.exports 的整体对象,不具备实时绑定,当作默认导入或具名导入(通过静态分析得到),行为与 require 类似。
如果 CommonJS 模块导出的是一个 object,它的属性修改能被外部看到吗?
可以。因为 module.exports 被赋值后,只要模块内部通过该对象修改属性,外部访问同一对象就能看到变化。但若直接给 module.exports 重新赋值另一个对象,外部仍指向旧对象,除非重新 require。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。