January 19, 2026 (6mo ago) — last updated June 14, 2026 (1mo ago)

TypeScript 单例模式:实用指南与替代方案

详解 TypeScript 中的单例模式:实现示例、优缺点、测试影响及依赖注入替代方案,含遗留系统重构步骤与实战建议。

← Back to blog
Cover Image for TypeScript 单例模式:实用指南与替代方案

探索单例设计模式在 TypeScript 中的实现、适用场景、常见问题及现代替代方案。本文提供实用示例、重构步骤与测试建议,帮助你在实际项目中做出更稳健的架构决策。

精通 TypeScript 中的单例模式:完整指南

一幅铅笔素描,描绘一位佩戴王冠的皇家书记官,正在桌前认真检查一卷发光的卷轴。

在软件开发中,有些工具非常强大,但必须谨慎使用。单例(Singleton)模式的核心概念很简单:确保某个类只有一个实例,并提供一个全局访问点,这有助于集中管理共享资源和避免重复实例化1。在实际项目中,这类模式通常用于集中配置或日志等必须唯一的服务,但也会带来耦合与测试方面的问题。

什么是单例模式以及何时使用?

把单例想象成中世纪王国中唯一的皇家书记官。只有此人能记录官方法令,这保证了记录的一致性。在软件中,单例负责限制某个类只创建一个对象,让该实例成为该责任的“事实来源”。

单例常见的合适场景包括:日志服务、配置管理、硬件接口等本质上应当唯一的资源。但在决定采用单例前,应权衡长期可维护性与短期便利性,尤其要考虑可测试性和并发安全问题。

核心目的与类比

单例并非只是防止多个对象存在,它更强调集中控制与一致性。就像皇家书记官提供单一访问点,单例实例也为共享资源提供一致的入口,从而避免不同模块出现互相冲突的副本。

数据库连接池是经典示例。让每个组件单独打开连接会浪费资源,单例可以集中管理连接并按需分发,从而提升性能与稳定性。

核心思想:一个类、一个实例、一个全局访问点,保证对特定资源的所有交互通过单一受控的通道进行。

单例模式要点

特性描述
单一实例类被设计为在应用生命周期中只有一个实例,通常通过私有构造函数来强制执行。
全局访问点一个静态方法(例如 getInstance())提供统一访问方式。
惰性初始化实例通常在首次请求时创建,以减少启动开销。
状态管理作为集中状态或配置的存储位置。

实际使用场景

适合使用单例的场景包括:

  • 日志服务:将所有日志统一写入同一文件或流。
  • 配置管理:确保所有模块读取同一配置来源。
  • 硬件接口访问:单一接口可避免命令冲突。

使用单例的优缺点

一只天平,比较结构化、可观察的设计模式与复杂、纠结且实验性的代码。

单例在提供便利的同时,也带来明显代价。下列要点有助于做出权衡。

优点

  • 全局访问点简化跨模块使用。
  • 惰性初始化可减少启动开销与资源消耗。
  • 减少重复创建昂贵资源的风险。

缺点

  • 紧耦合:类可能通过访问全局实例来隐藏依赖。
  • 全局可变状态会导致难以定位的错误。
  • 隐藏副作用,使推理和测试变得困难3

单例尤其会在并发场景中暴露问题,例如竞态条件。考虑一个会被并发请求修改的计数器,若没有同步机制,更新可能丢失或产生不一致状态。

对测试与耦合的影响

单例引入持久且全局的状态,会让单元测试相互影响并变得脆弱。现代实践更倾向于依赖注入(DI),因为 DI 使依赖显式并便于在测试时替换为模拟对象,从而提高可测试性与模块化3

权衡建议

在决定使用单例时,要在短期便利性与长期可维护性之间做权衡。对于遗留代码库,逐步用 DI 替换单例通常是安全且务实的路径。

在 TypeScript 中实现单例

带有挂锁的 ConfigManager 图示,演示在 TypeScript 中使用 getInstance 的单例设计模式。

下面给出一个现代、类型安全的 TypeScript 单例示例。关键在于使用 private 构造函数与 static 方法,确保无法从外部直接实例化,所有访问都通过统一入口。

类型安全的 ConfigManager 示例

class ConfigManager {
  private static instance: ConfigManager;
  private settings: Map<string, any> = new Map();

  private constructor() {
    // 真实应用中可从文件、环境或服务加载配置
    console.log("Initializing ConfigManager instance...");
    this.settings.set("API_URL", "https://api.example.com");
    this.settings.set("TIMEOUT", 5000);
  }

  public static getInstance(): ConfigManager {
    if (!ConfigManager.instance) {
      ConfigManager.instance = new ConfigManager();
    }
    return ConfigManager.instance;
  }

  public get(key: string): any {
    return this.settings.get(key);
  }
}

此模式对简单需求非常直接有效,利用 TypeScript 的访问控制来保证单实例与惰性初始化2

在服务中使用单例

class ApiService {
  private apiUrl: string;

  constructor() {
    const config = ConfigManager.getInstance();
    this.apiUrl = config.get("API_URL");
    console.log(`ApiService initialized with API URL: ${this.apiUrl}`);
  }

  public fetchData(): void {
    console.log(`Fetching data from ${this.apiUrl}...`);
    // 真实的数据请求逻辑
  }
}

// 应用入口示例
console.log("Application starting...");
const service1 = new ApiService();
service1.fetchData();
const service2 = new ApiService();
console.log("Application finished.");

运行上述代码时,你应只会看到一次 ConfigManager 的初始化日志,这证明多个消费者共享同一实例。

为什么单例名声不佳

单例看起来很吸引,因为它简单并提供全局可访问对象。但正是这种全局状态带来隐藏耦合与测试难题。单例使类悄然依赖全局实例,而这些依赖原本应当在构造函数中声明,从而降低了模块的可重用性与可测试性3

并发与全局状态的风险

有状态的单例在并发条件下会导致竞态条件与不可预测的行为。没有适当的同步机制,多个请求可能同时修改单例状态,导致难以重现的错误。

测试负担

单例会让单元测试间发生状态泄漏,并增加模拟成本。通过引入接口并使用依赖注入,可让测试快速且可预测。

尽管如此,单例仍在很多项目中广泛使用,因此在大型代码库中识别、审计并渐进式重构这些单例十分重要。

单例的现代替代方案

依赖注入(DI)是管理共享资源的首选方法。DI 使依赖显式,提高可测试性并降低隐藏耦合。

将单例替换为构造注入的示例

interface IConfigManager {
  get(key: string): any;
}

class ApiService {
  private apiUrl: string;

  constructor(config: IConfigManager) {
    this.apiUrl = config.get("API_URL");
  }
}

现在 ApiService 依赖 IConfigManager 接口,测试时可以注入假的实现,从而让测试更简单且更可靠。

通过反转谁负责创建依赖,组件变得更专注和灵活,这也是依赖倒置原则的核心。

IoC 容器的作用

IoC 容器负责管理对象的创建与注入。像 NestJS 和 Angular 这样的框架内置 DI 容器,或可使用 InversifyJS 等库来为通用项目提供容器功能。这些容器允许你选择对象的生命周期,例如瞬态、作用域或容器管理的单例,从而在保留单一共享实例好处的同时,避免编程式单例带来的隐藏耦合45

在遗留代码库中重构单例的步骤

逐步进行改造,减少风险并保证应用稳定性。

第 1 步:识别并隔离单例

查找所有对 MySingleton.getInstance() 的调用,为单例职责定义清晰接口。

第 2 步:逐步引入依赖注入

逐个重构消费者:

  1. 修改构造函数以接受接口依赖。
  2. 用注入的实例替换直接的 getInstance() 调用。
  3. 在实例化点传入单例实例,直至完成迁移。

第 3 步:用受管理实例替换编程式单例

当消费者都通过接口获取依赖后,可移除静态 getInstance(),将实现改为普通类,在组合根创建实例并注入,或让 DI 容器管理生命周期与作用域。

常见问答 — 简要问答

问:单例总是个坏主意吗?

答:不一定。对于真正唯一且无状态的服务(如集中式记录器或硬件适配器),单例可能是合理的选择。但通常应优先考虑 DI,以获得更好的可测试性与解耦。

问:单例如何影响单元测试?

答:单例会引入全局且持久的状态,导致测试之间可能相互影响。解决方案是通过接口引入依赖注入,并在测试中注入替身来隔离状态。

问:如何从遗留系统中安全移除单例?

答:映射单例使用位置,定义接口,逐个重构消费者以通过构造函数注入依赖,最后在组合根或 DI 容器中创建并注入受管理的实例。

追加:三条简洁问答(用于快速查阅)

Q1: 我应该何时使用单例?

A1: 当资源在应用中本质上唯一且无复杂可变状态时,如集中式日志或硬件适配器。

Q2: 单例会造成哪些最常见的问题?

A2: 隐藏依赖、难测、并发竞态和全局状态泄漏。

Q3: 想减少单例带来的问题,首选方案是什么?

A3: 使用依赖注入并在组合根管理实例生命周期,或使用框架内置的 DI 容器。

后记与行动项

开始讨论团队内是否以及何处确实需要单一共享实例。可以在一个隔离模块中尝试 DI 原型,采用明确的编码规范,并用工具衡量技术债务。结对编程和持续反馈有助于安全高效地推进重构。

记住,没有任何设计模式是万能的。单例在合适场景中有效,但必须谨慎使用,辅以清晰接口与明确的所有权规则。更多关于依赖注入与现代架构实践的内容,请参见我们的依赖注入指南:/guides/dependency-injection 或设计模式合集:/patterns.

1.
Martin Fowler, “Singleton,” Bliki, http://martinfowler.com/bliki/Singleton.html
2.
3.
Mark Seemann, “Singletons Are Pathological Liars,” https://blog.ploeh.dk/2010/07/28/SingletonsArePathologicalLiar/
4.
NestJS Documentation, “Dependency Injection,” https://docs.nestjs.com/fundamentals/injection
5.
InversifyJS Documentation, https://inversify.io/
6.
Stack Overflow, “Developer Survey 2023,” https://survey.stackoverflow.co/2023/
7.
State of JS, “State of JS Survey,” https://stateofjs.com/
← Back to blog
🙋🏻‍♂️

AI编写代码。
您让它持久。

在AI加速的时代,干净代码不仅仅是好的实践 — 它是能够扩展的系统与在自己的重量下崩溃的代码库之间的区别。