进阶指南:彻底理解 TypeScript 模块共享与隔离机制

23 阅读3分钟

一、 核心现象:模块是“天生共享”的

在 ES Modules(ESM)规范下,模块具有单例特性。无论一个模块被导入多少次,它内部的代码只会在第一次被加载时运行一次,结果会被缓存。

1. 代码实验

假设我们有一个状态管理模块:

// state.ts
export let count = 0;
export const dataObj = { name: "Initial" };

export function increment() { count++; }

当我们在不同文件中引用它:

  • 文件 A:调用 increment()
  • 文件 B:读取 count
  • 结果:文件 B 看到的 count1dataObj 的修改也是全局可见的。

2. 底层原理

  1. 加载与执行:引擎首次遇到 import './state',执行该文件并在内存创建作用域。
  2. 模块缓存(Module Registry):引擎会将导出结果存入缓存表。
  3. 引用传递(Live Bindings):后续所有的 import 都是从缓存中获取指向该内存地址的引用。

注意:导入的变量是只读的。你不能在外部直接写 count = 10,必须通过模块内部提供的函数(如 increment)来修改。


二、 为什么要防止变量共享?

虽然全局共享在做“配置管理”或“全局状态”时很方便,但在以下场景则是灾难:

  1. 单元测试隔离:测试用例 A 修改了状态,导致测试用例 B 运行失败,产生干扰。
  2. 多实例需求:例如页面上有三个独立的“计数器组件”,如果共用一个模块变量,它们会同步跳动。
  3. 服务端渲染(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 APIProvide/Inject。数据不再挂载在模块上,而是挂载在 UI 组件树的节点上,实现“局部单例”。


四、 总结与最佳实践

需求场景推荐策略核心优势
全局配置、常量直接导出变量/对象简单、高效、全应用统一
数据库连接、缓存默认单例共享节省资源,避免重复初始化
业务组件状态导出 Class 或工厂函数互不干扰,支持多实例
纯逻辑处理传参/纯函数极易进行单元测试,无副作用

💡 独家技巧:

如果你确实需要全局单例,但又想方便测试,记得导出一个 reset 函数:

let state = { ... };
export const resetStateForTest = () => { state = { ... }; };

结语: 理解 TypeScript 模块的共享机制是走向中高级开发的必经之路。记住:默认共享是为了效率,主动隔离是为了安全。 根据业务场景选择合适的导出方式,才能写出既健壮又易于维护的代码。