讲解

一个城市的供水总闸只有一个,谁家用水都从它来——装两个总闸不是更方便,而是灾难(水压互相干扰)。软件里也有这样的角色:全局配置、连接池、日志聚合器,它们的语义就是「全系统独一份」,多实例不仅浪费,还可能引发状态不一致。单例模式就是保证「一个类只有一个实例,并提供全局访问点」的模式。

GoF 经典实现有三个要素:私有构造函数(堵住外部 new)、私有静态字段保存唯一实例、公开的静态方法 getInstance() 负责惰性创建(第一次调用时才 new)。惰性初始化(lazy initialization)的意思是实例在第一次被需要时才创建,避免程序启动时白白构造。

但用 TypeScript/Node.js 写代码时必须知道一个更现代的观点:ES 模块本身就是单例。一个模块在进程里只被加载执行一次,导出的对象天然全进程唯一。所以很多场景里,直接 export const config = { ... } 就是单例,根本不需要类。GoF 式写法仍有价值的地方是:需要惰性、需要在创建时做复杂初始化、或需要把「可替换实现」留给测试的时候。

单例最大的争议在于它和依赖注入的冲突。单例本质上是全局状态:谁都能在任何角落摸到它,依赖关系从代码里隐身了;写单元测试时,一个测试改动的全局状态会泄漏到下一个测试。工程上的共识是:把单例藏在系统入口(composition root),业务代码一律通过构造函数接收依赖——也就是说,「实例只有一个」这个事实应该由入口的装配方式保证,而不是由类自己锁死。

示例

经典的惰性单例,以及与之对比的「模块即单例」写法:

import assert from 'node:assert/strict';

class AppConfig {
  private static instance: AppConfig | null = null;

  readonly env = 'production';
  readonly retries = 3;

  // 私有构造函数:外部无法 new
  private constructor() {}

  // 惰性初始化:第一次调用才真正创建
  static getInstance(): AppConfig {
    if (AppConfig.instance === null) {
      AppConfig.instance = new AppConfig();
    }
    return AppConfig.instance;
  }
}

// const c = new AppConfig(); // 若取消注释:编译错误,构造函数是 private
const a = AppConfig.getInstance();
const b = AppConfig.getInstance();
assert.equal(a, b); // 严格相等:同一个对象
assert.equal(a.env, 'production');

// 对比:ES 模块只加载一次,导出的对象天然是单例
const config = { env: 'production', retries: 3 };
function useConfig(): string {
  return config.env + '/' + String(config.retries);
}
assert.equal(useConfig(), 'production/3');

console.log('两次 getInstance 是同一实例:' + String(a === b) + ',env=' + a.env);

assert.equal(a, b) 用的是严格相等(引用比较),证明两次拿到的确实是同一个对象。private constructor 是 TypeScript 编译期的硬约束——试图 new AppConfig() 连编译都过不去,这比运行时才报错的实现更省心。

常见坑

  • 把单例当成「全局变量的合法化外衣」:任何够得着 getInstance 的地方都能改它,依赖关系彻底隐身——这是单例被骂得最多的原因。
  • 测试之间状态泄漏:上一个用例改了单例的字段,下一个用例拿到的是脏状态。对策是业务代码依赖注入,只在测试里注入新实例。
  • 多线程/多进程误以为仍唯一:Node.js 的 worker_threads、多实例部署下每个进程各有一份「单例」;需要跨进程唯一性时单例模式帮不上忙。
  • 该用模块导出却套类层级:TypeScript 项目里 export 一个对象往往就够了,GoF 式类写法要给出「需要惰性/可替换」的理由。
  • 在构造函数里做重活还自称惰性:getInstance 首次调用发生在不可控的时机,重初始化应该显式化(如 init() 方法),别赌调用时机。

小结

单例保证唯一实例 + 全局访问点,TypeScript 里 private constructor + 静态 getInstance 即可实现;但 ES 模块导出往往已够用,且业务代码应靠依赖注入拿依赖而非主动够单例。下一章:当对象构造参数多、步骤长时,用建造者模式把装配过程摊开。