探索单例设计模式在 TypeScript 中的实现、适用场景、常见问题及现代替代方案。本文提供实用示例、重构步骤与测试建议,帮助你在实际项目中做出更稳健的架构决策。
January 19, 2026 (6mo ago) — last updated June 14, 2026 (1mo ago)
TypeScript 单例模式:实用指南与替代方案
详解 TypeScript 中的单例模式:实现示例、优缺点、测试影响及依赖注入替代方案,含遗留系统重构步骤与实战建议。
← Back to blog
精通 TypeScript 中的单例模式:完整指南

在软件开发中,有些工具非常强大,但必须谨慎使用。单例(Singleton)模式的核心概念很简单:确保某个类只有一个实例,并提供一个全局访问点,这有助于集中管理共享资源和避免重复实例化1。在实际项目中,这类模式通常用于集中配置或日志等必须唯一的服务,但也会带来耦合与测试方面的问题。
什么是单例模式以及何时使用?
把单例想象成中世纪王国中唯一的皇家书记官。只有此人能记录官方法令,这保证了记录的一致性。在软件中,单例负责限制某个类只创建一个对象,让该实例成为该责任的“事实来源”。
单例常见的合适场景包括:日志服务、配置管理、硬件接口等本质上应当唯一的资源。但在决定采用单例前,应权衡长期可维护性与短期便利性,尤其要考虑可测试性和并发安全问题。
核心目的与类比
单例并非只是防止多个对象存在,它更强调集中控制与一致性。就像皇家书记官提供单一访问点,单例实例也为共享资源提供一致的入口,从而避免不同模块出现互相冲突的副本。
数据库连接池是经典示例。让每个组件单独打开连接会浪费资源,单例可以集中管理连接并按需分发,从而提升性能与稳定性。
核心思想:一个类、一个实例、一个全局访问点,保证对特定资源的所有交互通过单一受控的通道进行。
单例模式要点
| 特性 | 描述 |
|---|---|
| 单一实例 | 类被设计为在应用生命周期中只有一个实例,通常通过私有构造函数来强制执行。 |
| 全局访问点 | 一个静态方法(例如 getInstance())提供统一访问方式。 |
| 惰性初始化 | 实例通常在首次请求时创建,以减少启动开销。 |
| 状态管理 | 作为集中状态或配置的存储位置。 |
实际使用场景
适合使用单例的场景包括:
- 日志服务:将所有日志统一写入同一文件或流。
- 配置管理:确保所有模块读取同一配置来源。
- 硬件接口访问:单一接口可避免命令冲突。
使用单例的优缺点

单例在提供便利的同时,也带来明显代价。下列要点有助于做出权衡。
优点
- 全局访问点简化跨模块使用。
- 惰性初始化可减少启动开销与资源消耗。
- 减少重复创建昂贵资源的风险。
缺点
- 紧耦合:类可能通过访问全局实例来隐藏依赖。
- 全局可变状态会导致难以定位的错误。
- 隐藏副作用,使推理和测试变得困难3。
单例尤其会在并发场景中暴露问题,例如竞态条件。考虑一个会被并发请求修改的计数器,若没有同步机制,更新可能丢失或产生不一致状态。
对测试与耦合的影响
单例引入持久且全局的状态,会让单元测试相互影响并变得脆弱。现代实践更倾向于依赖注入(DI),因为 DI 使依赖显式并便于在测试时替换为模拟对象,从而提高可测试性与模块化3。
权衡建议
在决定使用单例时,要在短期便利性与长期可维护性之间做权衡。对于遗留代码库,逐步用 DI 替换单例通常是安全且务实的路径。
在 TypeScript 中实现单例

下面给出一个现代、类型安全的 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 步:逐步引入依赖注入
逐个重构消费者:
- 修改构造函数以接受接口依赖。
- 用注入的实例替换直接的 getInstance() 调用。
- 在实例化点传入单例实例,直至完成迁移。
第 3 步:用受管理实例替换编程式单例
当消费者都通过接口获取依赖后,可移除静态 getInstance(),将实现改为普通类,在组合根创建实例并注入,或让 DI 容器管理生命周期与作用域。
常见问答 — 简要问答
问:单例总是个坏主意吗?
答:不一定。对于真正唯一且无状态的服务(如集中式记录器或硬件适配器),单例可能是合理的选择。但通常应优先考虑 DI,以获得更好的可测试性与解耦。
问:单例如何影响单元测试?
答:单例会引入全局且持久的状态,导致测试之间可能相互影响。解决方案是通过接口引入依赖注入,并在测试中注入替身来隔离状态。
问:如何从遗留系统中安全移除单例?
答:映射单例使用位置,定义接口,逐个重构消费者以通过构造函数注入依赖,最后在组合根或 DI 容器中创建并注入受管理的实例。
追加:三条简洁问答(用于快速查阅)
Q1: 我应该何时使用单例?
A1: 当资源在应用中本质上唯一且无复杂可变状态时,如集中式日志或硬件适配器。
Q2: 单例会造成哪些最常见的问题?
A2: 隐藏依赖、难测、并发竞态和全局状态泄漏。
Q3: 想减少单例带来的问题,首选方案是什么?
A3: 使用依赖注入并在组合根管理实例生命周期,或使用框架内置的 DI 容器。
后记与行动项
开始讨论团队内是否以及何处确实需要单一共享实例。可以在一个隔离模块中尝试 DI 原型,采用明确的编码规范,并用工具衡量技术债务。结对编程和持续反馈有助于安全高效地推进重构。
记住,没有任何设计模式是万能的。单例在合适场景中有效,但必须谨慎使用,辅以清晰接口与明确的所有权规则。更多关于依赖注入与现代架构实践的内容,请参见我们的依赖注入指南:/guides/dependency-injection 或设计模式合集:/patterns.
AI编写代码。您让它持久。
在AI加速的时代,干净代码不仅仅是好的实践 — 它是能够扩展的系统与在自己的重量下崩溃的代码库之间的区别。