先记住这个答案
能看到。模块的顶层代码只执行一次,导出的对象在内存里只有一份,所有导入方拿到的都是它的引用。导出方后来改属性、往数组里 push,导入方下次读就是新内容。这靠的是对象引用共享,和普通函数之间传对象是一样的道理。要和它区分开的是「导出方把变量整个赋值成一个新对象」,这时只有 ESM 的实时绑定能让导入方看到新对象。
- 导出对象只有一份,改属性所有导入方都可见
- 这是对象引用共享,和实时绑定是两回事
- 整个重新赋值时,ESM 跟着变,CommonJS 解构的不变
- 导入方也能改对象内部,这往往是 bug 的来源
改属性和整体替换
store.js 导出一个对象,并提供两个函数,一个改属性,一个直接换成新对象。
// store.js
export let state = { list: [] }
export function add(item) {
state.list.push(item) // 改对象内部
}
export function reset() {
state = { list: [] } // 整个换成新对象
}
// main.js
import { state, add, reset } from './store.js'
const saved = state // 存一份当前引用
add('a')
console.log(state.list, saved.list) // [ 'a' ] [ 'a' ],同一个对象
reset()
console.log(state.list, saved.list) // [] [ 'a' ],state 指向了新对象add 之后,state 和 saved 指着同一个对象,都能看到 'a'。reset 之后,导出方的 state 变量指向了新对象,导入方的 state 是实时绑定,也跟着指过去了;saved 只是之前存的一个引用,还指着旧对象。
换成 CommonJS 会怎样
CommonJS 导出的是 module.exports 这个对象,导入方拿到的是它本身,常见写法是 const { state, add, reset } = require("./store")。
- 改属性一样,整体替换不一样
- 改属性的情况和 ESM 完全一样,因为解构出来的
state和导出方指向同一个对象。 整体替换就不一样了。解构发生在require那一刻,state被复制成一个普通局部变量。导出方后来即使执行了module.exports.state = { list: [] },这边的state还是旧对象。想拿到最新值,只能不解构,每次写store.state去读。 所以面试里常说的「ESM 是值的引用,CommonJS 是值的拷贝」,准确意思是:CommonJS 导入时把导出对象上的属性值取了出来,对象类型的值拷贝的是引用,所以改属性依然共享;区别只在重新赋值这一种情况。
容易答错的地方
- 以为 CommonJS 导出对象就完全隔离
- 很多人把「CommonJS 是拷贝」理解成深拷贝,以为导出方改了对象属性导入方看不到。实际拷贝的只是引用,对象内部的修改照样共享。
- 以为导入方改不了导出对象
- 导入绑定只读,指的是不能写
state = {}。但state.list.push("x")完全合法,其它模块都会受影响。这是模块间共享可变状态最常见的坑:某个组件为了方便直接改了导入的配置对象,另一个页面莫名其妙出了问题。
面试官还会怎么问?
怎么防止导入方修改导出的对象?
最直接的是导出前冻结,export const config = Object.freeze({ ... }),注意 Object.freeze 是浅冻结,嵌套对象要递归处理。更好的做法是不直接导出状态,只导出 getState() 返回副本、setXxx() 负责修改,把写操作收口到模块内部。
Vue、React 里导出一个普通对象当全局状态可以吗?
数据能共享,但界面不会自动更新,因为框架感知不到普通对象的属性变化。Vue 里要用 reactive 包一层再导出,React 里要通过状态管理库或 useSyncExternalStore 订阅变化。模块共享解决的是「同一份数据」,响应式解决的是「数据变了通知谁」。
在导出方用 setTimeout 改属性,导入方同步代码能看到吗?
看不到,要等到定时器回调执行之后再读才是新值。能不能看到取决于读的时机,不取决于模块系统。导入方在事件回调、渲染函数这些之后才执行的代码里读,拿到的就是最新内容。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。