一、 核心现象:模块是“天生共享”的
在 ES Modules(ESM)规范下,模块具有单例特性。无论一个模块被导入多少次,它内部的代码只会在第一次被加载时运行一次,结果会被缓存。
1. 代码实验
假设我们有一个状态管理模块:
// state.ts
export let count = 0;
export const dataObj = { name: "Initial" };
export function increment() { count++; }
当我们在不同文件中引用它:
- 文件 A:调用
increment()。 - 文件 B:读取
count。 - 结果:文件 B 看到的
count是1,dataObj的修改也是全局可见的。
2. 底层原理
- 加载与执行:引擎首次遇到
import './state',执行该文件并在内存创建作用域。 - 模块缓存(Module Registry):引擎会将导出结果存入缓存表。
- 引用传递(Live Bindings):后续所有的
import都是从缓存中获取指向该内存地址的引用。
注意:导入的变量是只读的。你不能在外部直接写
count = 10,必须通过模块内部提供的函数(如increment)来修改。
二、 为什么要防止变量共享?
虽然全局共享在做“配置管理”或“全局状态”时很方便,但在以下场景则是灾难:
- 单元测试隔离:测试用例 A 修改了状态,导致测试用例 B 运行失败,产生干扰。
- 多实例需求:例如页面上有三个独立的“计数器组件”,如果共用一个模块变量,它们会同步跳动。
- 服务端渲染(SSR):在 Node.js 中,如果模块变量存储了用户信息,不同用户的请求可能会互相污染,造成严重的隐私泄露。
三、 防止共享的四种高级方案
如果你的目标是让每个引入者拥有“独立的代码副本”,请尝试以下方法:
1. 导出类(Class)而非实例
这是最推荐的 OOP(面向对象)方案。每次调用者 new 一个实例,都会开辟独立的内存空间。
// Counter.ts
export class Counter {
count = 0;
increment() { this.count++; }
}
// 使用:const c1 = new Counter();
2. 使用工厂函数(Factory Function)
函数式编程的最佳实践。通过闭包产生私有作用域,每次执行函数都返回全新对象。
// state.ts
export const createStore = () => {
let count = 0; // 闭包私有变量
return {
add: () => ++count,
get: () => count
};
};
// 使用:const storeA = createStore();
3. 依赖注入与传参
不要在模块顶层存储状态,而是让函数接受状态作为参数。将状态的“生命周期”交给调用者管理。
// logic.ts
export function processData(context: UserContext, data: any) {
context.history.push(data); // 状态由外部传入的 context 决定
}
4. 框架层面的隔离(Context/Scoped)
在 React 或 Vue 中,利用 Context API 或 Provide/Inject。数据不再挂载在模块上,而是挂载在 UI 组件树的节点上,实现“局部单例”。
四、 总结与最佳实践
| 需求场景 | 推荐策略 | 核心优势 |
|---|---|---|
| 全局配置、常量 | 直接导出变量/对象 | 简单、高效、全应用统一 |
| 数据库连接、缓存 | 默认单例共享 | 节省资源,避免重复初始化 |
| 业务组件状态 | 导出 Class 或工厂函数 | 互不干扰,支持多实例 |
| 纯逻辑处理 | 传参/纯函数 | 极易进行单元测试,无副作用 |
💡 独家技巧:
如果你确实需要全局单例,但又想方便测试,记得导出一个 reset 函数:
let state = { ... };
export const resetStateForTest = () => { state = { ... }; };
结语: 理解 TypeScript 模块的共享机制是走向中高级开发的必经之路。记住:默认共享是为了效率,主动隔离是为了安全。 根据业务场景选择合适的导出方式,才能写出既健壮又易于维护的代码。