在 JavaScript 模块开发中,一个常见的问题是:导入的变量与原模块中的变量是否是同一个引用? 这个问题看似简单,实则涉及模块系统的核心机制。本文将深入探讨 ES Modules (ESM) 和 CommonJS 两种主流模块系统在处理数据共享与隔离时的差异,帮助你避免常见的陷阱。
核心结论
- 不是简单的“把值赋给导入方” 。是否共享同一个对象引用,取决于模块系统以及你是“重新赋值”还是“就地修改”。
- ES Module 导出是“活绑定” :导入方拿到的是同一个绑定,通常对象引用一致;导出方重新赋值会让导入方跟着指向新对象。
- CommonJS 返回的是同一个缓存的导出对象引用:修改这个对象的属性是共享的,但导出模块内部的局部变量与导出对象属性不是同一个绑定。
ES Modules:活绑定机制
ESM 使用“活绑定”(live binding)机制,这意味着导入的变量与导出的变量始终是同一个绑定。
对象引用共享
javascript
复制下载
// mod.mjs
export let obj = { n: 1 };
export function reset() { obj = { n: 0 }; }
// main.mjs
import { obj, reset } from './mod.mjs';
console.log(obj.n); // 1,同一引用
obj.n = 2; // 就地修改,导出方也看到 n=2
reset(); // 导出方重新赋值为新对象
console.log(obj.n); // 0,导入方绑定已指向新对象
在这个例子中:
- 初始时,导入和导出双方共享同一个对象引用
- 在导入方修改对象属性,导出方也能看到变化
- 当导出方执行
reset()重新赋值时,导入方的绑定自动更新为新对象
关键点:导入方不能直接给 obj 重新赋值(绑定是只读的),但可以修改其属性。
CommonJS:缓存对象机制
CommonJS 使用不同的机制:require() 返回的是 module.exports 对象的缓存副本,这个对象在第一次加载后会被缓存。
对象属性共享
javascript
复制下载
// mod.js
let obj = { n: 1 };
module.exports = {
obj,
setObj(v) { obj = v; },
getObj() { return obj; }
};
// main.js
const mod = require('./mod');
console.log(mod.obj.n); // 1,共享同一对象
mod.obj.n = 3; // 就地修改属性,导出方也看到 n=3
mod.setObj({ n: 0 }); // 导出方把内部变量重新赋值为新对象
console.log(mod.obj.n); // 3(module.exports.obj 仍指向旧对象)
console.log(mod.getObj().n); // 0(内部变量已是新对象)
在这个例子中:
module.exports.obj与内部变量obj最初指向同一个对象- 修改
mod.obj.n会影响原始对象 - 但当内部变量
obj被重新赋值时,module.exports.obj并不会自动更新 - 需要通过
getObj()方法才能访问到内部变量当前的值
解构赋值的陷阱
javascript
复制下载
// 导出模块
let counter = 0;
module.exports = {
counter,
increment() { counter++; }
};
// 导入模块
const { counter } = require('./counter-module');
const mod = require('./counter-module');
mod.increment();
console.log(counter); // 0(解构出的变量不会更新)
console.log(mod.counter); // 0(module.exports.counter 也不会更新)
解构赋值会创建变量的副本(对于原始类型)或引用副本(对于对象类型),但这些副本不会自动更新。
对比总结
| 特性 | ES Modules | CommonJS |
|---|---|---|
| 导出机制 | 活绑定 | 缓存的对象引用 |
| 重新赋值 | 导入方绑定会自动更新 | 需要手动更新 module.exports |
| 解构赋值 | 解构出的变量仍然是绑定 | 解构出的是当前值的副本 |
| 原始类型 | 绑定会跟随更新 | 不会自动更新 |
| 对象属性修改 | 双方都可见 | 双方都可见 |
| 循环依赖 | 部分支持,有声明提升 | 可能得到不完整的模块 |
最佳实践
-
ESM 中:
- 使用活绑定的特性来共享状态
- 通过导出的函数来更新状态,而不是直接修改变量
- 注意不要尝试重新分配导入的变量
-
CommonJS 中:
- 避免导出易变的原始类型值
- 通过 getter/setter 函数提供对内部状态的受控访问
- 直接修改
module.exports的属性而不是替换整个对象
-
通用建议:
- 优先使用不可变数据模式
- 对于共享状态,使用对象封装并提供明确的 API
- 在文档中清晰说明哪些导出是可变的
结论
理解模块系统如何处理数据传递是编写可维护、可预测代码的关键。ESM 的活绑定机制提供了更直接的引用共享,而 CommonJS 的缓存机制则更显式但需要更多手动管理。无论使用哪种系统,清晰地定义接口、最小化可变状态、以及通过函数提供状态访问都是良好的实践。
选择哪种模块系统不仅取决于技术特性,还取决于你的项目环境、团队熟悉度和工具链支持。随着 Node.js 对 ESM 的全面支持,ESM 正成为更统一的选择,但理解 CommonJS 的行为仍然对维护现有代码库至关重要。