January 19, 2026 (3mo ago)

精通单例设计模式:完整开发者指南

探索单例设计模式:何时使用、实用的 TypeScript 示例以及必要的替代方案。

← Back to blog
Cover Image for 精通单例设计模式:完整开发者指南

探索单例设计模式:何时使用、实用的 TypeScript 示例以及必要的替代方案。

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

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

在软件开发的世界里,有些工具非常强大,但必须谨慎使用。单例(Singleton)模式就是其中之一。其核心概念很简单:确保一个类只有一个实例,并提供一个单一的、全局的访问方式。1

把它想象成一个中央配置管理器或整个应用的专用日志服务。你不会希望多个互相冲突的配置对象到处存在,也不想让日志条目被不同的记录器实例分散到不同的文件中。单例通过防止重复创建来带来秩序,节省内存并避免混乱。它是管理对共享资源访问的基础性模式。

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

想象一个中世纪王国,只有一位官方的皇家书记官。只有此人被授权记录皇家的法令。这能确保每条法律和公告都一致、经过正规授权并存储在唯一的权威账册中。如果任何人都可以自称书记官,王国很快就会陷入记录矛盾和大混乱。

单例设计模式在你的软件中就是基于同样的原则。它的主要职责是限制一个类,使得该类只能创建一个对象。这个唯一的实例成为特定任务的事实来源,并且可以从代码库中的任何地方轻松访问。这就是你如何对必须绝不能被复制的资源施加控制的方法。

核心目的与类比

单例不仅仅是阻止人们创建新对象;它在于集中控制。就像皇家书记官为王国的官方记录提供了一个单一的访问点,单例实例也为共享资源提供了一个普遍可用的入口。它阻止应用程序的不同部分创建各自孤立且可能互相冲突的版本。

数据库连接池是一个经典示例。你绝对不希望应用中的每个组件都打开自己的独立数据库连接——那样无疑会耗尽服务器资源并把性能拖垮。相反,单例可以管理一个连接池,并按需高效分发连接。

核心思想简单却强大:一个类、一个实例、一个全局访问点。这种结构保证了对特定资源的所有交互都通过单一、受控的通道进行。

单例模式一览

特性描述与理由
单一实例类被设计为在应用生命周期中只有一个实例,通常通过私有构造函数来强制执行。
全局访问点一个静态方法(例如 getInstance())提供了一个单一、众所周知的方式从代码的任何地方访问该实例。
惰性初始化单一实例通常在首次被请求时创建,而不是在应用启动时创建,这可以提升性能。
状态管理它作为特定全局状态的集中位置,例如应用设置或用户会话。

这张表简明地总结了该模式存在的原因:为本质上唯一的资源强制执行单一且全局可访问的实例。

实际使用场景

尽管单例模式备受批评,但它并非没有正当用途。当你拥有一个在系统中本质上就是唯一的资源时,它最为有效。

以下是一些适合使用单例的场景:

  • 日志服务:单一的记录器实例确保所有事件汇入同一个文件或流。
  • 配置管理:统一的应用设置来源避免模块间不一致。
  • 硬件接口访问:对设备的单一接口可以防止冲突指令。

使用单例的优缺点

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

当你需要对共享资源提供单一访问点时,单例模式会让人觉得是个可靠工具。它为管理像配置对象或日志服务这样的全局资源提供了简明的方式。

单例的优点

  • 全局访问点简化了跨模块的使用。
  • 通过惰性初始化节省资源,可减少启动开销。
  • 减少重复,防止对昂贵资源创建多个冲突实例。

单例的缺点

  • 紧耦合:类可能通过访问全局状态来隐藏其依赖。
  • 全局状态:共享的可变状态会导致难以定位的错误。
  • 隐藏的副作用:依赖单例的方法不会在其签名中暴露这种依赖,使推理和测试变得困难。

对测试与耦合的影响

单例使单元测试变得复杂,因为它们引入了全局且持久的状态。测试有泄漏状态到其他测试的风险,且对单例进行模拟(mock)可能变得尴尬。现代团队通常更偏好依赖注入,因为它使依赖显式并易于在测试中替换。3

权衡平衡

在决定是否使用单例时,要在便利性与长期可维护性和可测试性之间权衡。对于遗留代码库,逐步重构为依赖注入通常是最安全的路径:保留行为的同时减少隐藏耦合并改进可测试性。

单例以全局状态换取简单性,因此应根据团队需求谨慎选择。

关键要点

  • 谨慎使用单例,仅用于确实必须唯一的服务。
  • 更倾向于显式的依赖注入以获得更好的解耦和可测试性。
  • 如果必须使用单例,使其为惰性并注意并发与线程安全问题。
  • 对于遗留系统,通过在组合根引入接口和 DI 来逐步淘汰单例。

如何在 TypeScript 中实现单例模式

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

让我们从抽象转向实操,并在 TypeScript 中构建一个现代的、类型安全的单例。秘密在于 private 构造函数和充当守门人的 static 方法。这个组合确保应用的其他部分无法创建新实例;所有人都通过该单一入口访问实例。2

在我们的实操示例中,我们将创建一个 ConfigManager。该类加载并提供应用设置,保证每个组件都从相同的事实来源读取配置。

构建类型安全的 ConfigManager

// A practical example of the Singleton pattern for configuration management.

class ConfigManager {
  // 1. A private, static property to hold the single instance.
  private static instance: ConfigManager;

  // 2. A place to store our configuration data.
  private settings: Map<string, any> = new Map();

  // 3. The private constructor. This stops `new ConfigManager()` from working anywhere else.
  private constructor() {
    // In a real app, you'd load from a file, environment variables, or a service.
    console.log("Initializing ConfigManager instance...");
    this.settings.set("API_URL", "https://api.example.com");
    this.settings.set("TIMEOUT", 5000);
  }

  // 4. The public, static method that controls access to the single instance.
  public static getInstance(): ConfigManager {
    if (!ConfigManager.instance) {
      ConfigManager.instance = new ConfigManager();
    }
    return ConfigManager.instance;
  }

  // 5. A regular public method to get a specific setting.
  public get(key: string): any {
    return this.settings.get(key);
  }
}

// TypeScript will stop you from doing this:
// const config = new ConfigManager(); // Error: Constructor of class 'ConfigManager' is private.

这个结构使用 TypeScript 的访问控制修饰符来强制单实例类和惰性初始化。private 构造函数和 static 实例模式对许多简单需求来说既直接又有效。

在服务中使用单例

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}...`);
    // Real data-fetching logic would live here.
  }
}

// --- Application Entry Point ---
console.log("Application starting...");

const service1 = new ApiService();
service1.fetchData();

const service2 = new ApiService();
console.log("Application finished.");

当你运行这段代码时,你应该只会看到一次来自 ConfigManager 的初始化消息,这证明两个服务都收到了相同的实例。

为什么单例名声这么差

单例模式看起来很吸引人,因为它简单并为你提供了一个全局可访问的对象。但问题在于隐藏的耦合、全局可变状态和测试噩梦。当一个类悄然依赖全局实例时,它隐藏了本应在构造函数中明确的依赖。这使得系统更难理解,也更难测试。3

并发与全局状态的噩梦

有状态的单例在并发场景中会导致竞态条件。考虑一个 SessionCounter,两个同时的请求同时增加同一个计数器。如果没有同步机制,它们都可能读取相同的初始值并写入冲突的更新。这类错误依赖于时序且难以重现。

测试难题

单例使单元测试变得脆弱,因为状态可能在测试之间泄漏且模拟变得困难。测试可能开始依赖执行顺序,整个测试套件变得不稳定。这就是团队经常采用依赖注入的原因:它使依赖显式并且易于模拟。

尽管存在这些问题,单例在实际项目中仍然普遍。一旦引入,它们往往会在代码库中扩散,因此在改进架构时进行审计和渐进式重构非常重要。

单例模式的现代替代方案

在了解了单例带来的风险之后,你可能会问:“我应该用什么替代?”依赖注入(DI)是管理共享资源的首选方法。DI 使依赖显式,提高了可测试性和模块化。

依赖注入与单例对比

将原先依赖单例的 ApiService 与接受配置管理器作为构造参数的版本进行比较。

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

class ApiService {
  private apiUrl: string;

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

现在 ApiService 只依赖 IConfigManager 接口。在测试时你可以传入假的或模拟的实现,使测试快速且可预测。

通过反转谁创建依赖的控制,组件变得更专注和灵活。这个思想是依赖倒置原则的核心。

IoC 容器的角色

控制反转(IoC)容器负责管理对象的创建与注入。流行的 TypeScript 框架内置了 DI 容器,例如 NestJS 和 Angular,或通用项目可以使用像 InversifyJS 这样的库。45

容器让你选择对象的共享方式:瞬态(transient)、作用域(scoped)或类单例(singleton-like)的生命周期。这为你提供了单一共享实例的好处,而不会带来编程式单例的隐藏耦合。

如何在遗留代码库中重构单例

逐步进行。识别单例的使用位置,定义描述其行为的清晰接口,然后开始将单个消费者改为通过构造函数注入该接口。接着在组合根处绑定具体实例,或让 DI 容器管理它。

第 1 步:识别并隔离单例

查找所有对 MySingleton.getInstance() 的调用,并为单例的职责划定边界。定义一个列出所需公共方法的接口。

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

逐个重构消费者:

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

这在减少隐藏耦合的同时保持应用稳定。

第 3 步:用受管理的实例替换单例

一旦消费者通过接口获取依赖,你就可以移除静态 getInstance(),将实现改为带有公共构造函数的普通类。在组合根创建一个实例并按需传入,或让 DI 容器处理生命周期与作用域。

回答你关于单例的常见疑问

单例总是个坏主意吗?

不一定。对于真正唯一且无状态的服务(如集中式记录器或硬件适配器)它可能是合理的。即便如此,DI 与受控的组合根通常也能提供相同的行为且具有更好的可测试性。

单例如何破坏单元测试?

它们引入了全局且持久的状态,可能在测试之间泄漏并让模拟变得困难。测试可能变得依赖执行顺序且不稳定。使用 DI 可以简化测试,因为可以直接注入模拟对象。

静态类是不是基本上和单例一样?

不是。静态类仅包含静态成员且无法实例化。单例有一个真实的实例,可以实现接口并以对象形式传递。两种方法都可能导致紧耦合,因此为了灵活性更应优先考虑 DI。

你团队的下一步

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

记住,没有任何设计模式是万灵药。单例有其适用场景,但必须谨慎使用,并配以清晰的接口与明确的所有权规则。

常见问答 — 简要问答

问:何时适合使用单例? 答:当某个资源确实唯一且无状态时,如集中式记录器或硬件适配器。尽可能优先使用 DI。

问:我现在如何测试使用单例的代码? 答:引入接口,重构消费者以接受该接口,并注入测试替身(test double)。逐步进行以避免大规模回归。

问:从遗留应用中移除单例的最安全路径是什么? 答:映射使用情况,定义接口,重构消费者以接受依赖,然后在组合根创建并注入单个实例,或使用 DI 容器。


1.
Martin Fowler, “Singleton,” Bliki, https://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/
← Back to blog
🙋🏻‍♂️

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

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