跳到主要内容
预计阅读 27 分钟

创建型模式(上)—— 单例模式、工厂方法、抽象工厂

对象的创建看似简单,一个 new 就搞定了。但当系统复杂到一定程度,“在哪里创建”、“创建哪个”、“怎么保证唯一”这些问题会变成真正的工程难题。创建型模式就是为此而生的。

📋 开篇自测:你已经知道多少?

  1. 在多线程/多异步环境下,如何确保一个类只有一个实例?
  2. “简单工厂”和”工厂方法”的区别是什么?
  3. 你能举出一个需要”一族相关对象”一起创建的实际业务场景吗?

一、单例模式:全局只此一家

1.1 从配置管理说起

假设你正在开发一个在线文档编辑器。整个应用有一个全局配置对象,存储着主题色、字体大小、自动保存间隔等设置。这个配置对象需要在编辑器的各个模块中被访问——工具栏需要读取主题色,编辑区需要读取字体大小,后台同步模块需要读取保存间隔。

如果每个模块都自己 new 一份配置对象,会出什么问题?

// 反面示范:各自创建配置实例
class EditorConfig {
  theme = 'light';
  fontSize = 14;
  autoSaveInterval = 30;
}

// 工具栏模块
const toolbarConfig = new EditorConfig();
toolbarConfig.theme = 'dark';

// 编辑区模块
const editorConfig = new EditorConfig();
console.log(editorConfig.theme); // 'light' —— 工具栏的修改丢了!

用户在工具栏切换了暗色主题,但编辑区完全不知道——因为它们拿到的是两个不同的实例。这就是单例模式要解决的问题:确保一个类在整个应用中只有一个实例,并提供一个全局访问点。

1.2 TypeScript 中的单例实现

方式一:懒汉式(延迟创建)

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

  // 私有构造函数,防止外部 new
  private constructor(
    public theme: string = 'light',
    public fontSize: number = 14,
    public autoSaveInterval: number = 30
  ) {}

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

  updateTheme(theme: string): void {
    this.theme = theme;
  }
}

// 使用
const config1 = EditorConfig.getInstance();
const config2 = EditorConfig.getInstance();
config1.updateTheme('dark');
console.log(config2.theme); // 'dark' —— 同一个实例!
console.log(config1 === config2); // true

方式二:模块级单例(TypeScript/ES Module 天然支持)

在 ES Module 体系中,一个模块只会被执行一次。利用这个特性,我们可以用更简洁的方式实现单例:

// editor-config.ts
class EditorConfig {
  theme = 'light';
  fontSize = 14;
  autoSaveInterval = 30;

  updateTheme(theme: string): void {
    this.theme = theme;
  }
}

// 模块级别的单例——整个应用中 import 多少次都是同一个实例
export const editorConfig = new EditorConfig();

这种方式在现代前端项目中最为常见,代码简洁,且在单线程上下文中天然安全(浏览器主线程和 Node.js 主线程均为单线程)。需要注意两个例外情况:一是在 Node.js 的 Worker Threads 环境中,每个 Worker 有独立的模块实例,模块级单例不会跨线程共享;二是在 SSR(服务端渲染)场景中,模块级单例会被所有请求共享——如果单例内部持有请求相关的状态,可能导致不同用户之间的状态污染,需要格外小心。

1.3 单例的陷阱与应对

单例很好用,但也有几个需要警惕的问题:

陷阱一:全局状态导致测试困难。单例本质上是全局变量,测试时一个用例修改了单例状态,可能影响后续用例。

// 解法:提供 reset 方法(仅在测试环境暴露)
class EditorConfig {
  private static instance: EditorConfig | null = null;
  // ...

  /** @internal 仅供测试使用 */
  static resetInstance(): void {
    EditorConfig.instance = null;
  }
}

陷阱二:隐藏依赖关系。当一个函数内部悄悄调用 EditorConfig.getInstance(),从函数签名上完全看不出它依赖了配置对象,这会让代码难以追踪。

// 更好的方式:通过参数注入,同时保留单例作为默认值
function renderToolbar(config: EditorConfig = EditorConfig.getInstance()) {
  // 使用 config 渲染工具栏
}

🤔 想一想 Vue 的全局状态管理(如 Pinia 的 store)、React 的 Context,它们和单例模式有什么关系?它们是如何解决单例的测试难题的?


二、工厂方法模式:让子类决定创建什么

2.1 从跨平台通知系统说起

你正在开发一个项目管理工具,需要向用户发送通知。最初只有邮件通知:

class ProjectManager {
  notifyTeam(message: string): void {
    const notification = new EmailNotification(); // 直接 new 具体类
    notification.send(message);
  }
}

一个月后,产品经理要求加上站内信通知。再过两周,又要加企业微信通知。每次你都得回来改 notifyTeam 方法。更麻烦的是,不同团队偏好不同的通知方式——有的团队用邮件,有的用企业微信。

问题的根源在于 ProjectManager 直接依赖了具体的通知类。工厂方法模式的核心思想是:定义一个创建对象的接口,但把”具体创建哪个类”的决定权交给子类。

2.2 用工厂方法重构通知系统

// 产品接口:所有通知必须实现的契约
interface Notification {
  send(message: string): Promise<void>;
  getChannel(): string;
}

// 具体产品
class EmailNotification implements Notification {
  async send(message: string): Promise<void> {
    console.log(`[邮件] 发送: ${message}`);
    // 实际调用 SMTP 服务...
  }
  getChannel(): string { return 'email'; }
}

class WechatWorkNotification implements Notification {
  async send(message: string): Promise<void> {
    console.log(`[企业微信] 发送: ${message}`);
    // 实际调用企业微信 API...
  }
  getChannel(): string { return 'wechat-work'; }
}

class InAppNotification implements Notification {
  async send(message: string): Promise<void> {
    console.log(`[站内信] 发送: ${message}`);
    // 实际写入数据库...
  }
  getChannel(): string { return 'in-app'; }
}

// 创建者抽象类
abstract class NotificationCreator {
  // 工厂方法:子类决定创建哪种通知
  abstract createNotification(): Notification;

  // 业务逻辑使用工厂方法获取产品
  async notifyTeam(message: string): Promise<void> {
    const notification = this.createNotification();
    console.log(`通过 ${notification.getChannel()} 渠道发送通知...`);
    await notification.send(message);
  }
}

// 具体创建者
class EmailNotificationCreator extends NotificationCreator {
  createNotification(): Notification {
    return new EmailNotification();
  }
}

class WechatWorkNotificationCreator extends NotificationCreator {
  createNotification(): Notification {
    return new WechatWorkNotification();
  }
}

class InAppNotificationCreator extends NotificationCreator {
  createNotification(): Notification {
    return new InAppNotification();
  }
}

使用时:

// 根据团队配置选择通知方式
function getNotificationCreator(teamPreference: string): NotificationCreator {
  switch (teamPreference) {
    case 'email': return new EmailNotificationCreator();
    case 'wechat-work': return new WechatWorkNotificationCreator();
    case 'in-app': return new InAppNotificationCreator();
    default: return new EmailNotificationCreator();
  }
}

const creator = getNotificationCreator('wechat-work');
await creator.notifyTeam('Sprint 7 已经开始,请查看任务看板');

2.3 工厂方法 vs 简单工厂

很多人会混淆”简单工厂”和”工厂方法”。简单工厂其实不是 GoF 定义的模式,更像是一种编码习惯:

// 简单工厂:一个函数/方法包揽所有创建逻辑
class NotificationFactory {
  static create(type: string): Notification {
    switch (type) {
      case 'email': return new EmailNotification();
      case 'wechat-work': return new WechatWorkNotification();
      case 'in-app': return new InAppNotification();
      default: throw new Error(`未知通知类型: ${type}`);
    }
  }
}

两者的区别在于:

维度简单工厂工厂方法
扩展方式修改工厂函数的 switch-case新增一个 Creator 子类
是否符合开闭原则不符合(每次都要改工厂)符合(新增子类即可)
复杂度较高
适用场景产品种类少且稳定产品种类多且经常扩展

在实际项目中,如果你能预见产品类型不会频繁变化(比如就三种通知渠道),简单工厂完全够用。不要为了”正确”而过度设计。


三、抽象工厂模式:创建一族相关对象

3.1 从 UI 主题系统说起

假设你正在开发一个跨平台的 Dashboard 组件库。这个组件库需要支持两套视觉风格:企业风格(Enterprise)和极简风格(Minimal)。每套风格都包含按钮、输入框、对话框三个组件,它们的外观和行为完全不同——但同一套风格内的组件必须视觉统一。

问题来了:你如何确保使用者不会混搭——比如用企业风格的按钮配上极简风格的对话框?

这就是抽象工厂要解决的场景:提供一个接口,用于创建一系列相关或相互依赖的对象,而无需指定它们的具体类。

3.2 抽象工厂的完整实现

// ===== 抽象产品 =====
interface Button {
  render(): string;
  onClick(handler: () => void): void;
}

interface Input {
  render(): string;
  getValue(): string;
}

interface Dialog {
  render(): string;
  open(): void;
  close(): void;
}

// ===== 企业风格的具体产品 =====
class EnterpriseButton implements Button {
  render(): string {
    return '<button class="btn-enterprise">企业风格按钮</button>';
  }
  onClick(handler: () => void): void {
    console.log('企业按钮:带有确认动画的点击');
    handler();
  }
}

class EnterpriseInput implements Input {
  private value = '';
  render(): string {
    return '<input class="input-enterprise" placeholder="请输入..." />';
  }
  getValue(): string { return this.value; }
}

class EnterpriseDialog implements Dialog {
  private isOpen = false;
  render(): string {
    return '<div class="dialog-enterprise">带阴影和圆角的企业弹窗</div>';
  }
  open(): void { this.isOpen = true; console.log('企业弹窗:带遮罩层打开'); }
  close(): void { this.isOpen = false; console.log('企业弹窗:淡出关闭'); }
}

// ===== 极简风格的具体产品 =====
class MinimalButton implements Button {
  render(): string {
    return '<button class="btn-minimal">极简按钮</button>';
  }
  onClick(handler: () => void): void {
    console.log('极简按钮:无动画,直接触发');
    handler();
  }
}

class MinimalInput implements Input {
  private value = '';
  render(): string {
    return '<input class="input-minimal" placeholder="Type here" />';
  }
  getValue(): string { return this.value; }
}

class MinimalDialog implements Dialog {
  private isOpen = false;
  render(): string {
    return '<div class="dialog-minimal">无边框极简弹窗</div>';
  }
  open(): void { this.isOpen = true; console.log('极简弹窗:滑入打开'); }
  close(): void { this.isOpen = false; console.log('极简弹窗:滑出关闭'); }
}

// ===== 抽象工厂 =====
interface UIComponentFactory {
  createButton(): Button;
  createInput(): Input;
  createDialog(): Dialog;
}

// ===== 具体工厂 =====
class EnterpriseUIFactory implements UIComponentFactory {
  createButton(): Button { return new EnterpriseButton(); }
  createInput(): Input { return new EnterpriseInput(); }
  createDialog(): Dialog { return new EnterpriseDialog(); }
}

class MinimalUIFactory implements UIComponentFactory {
  createButton(): Button { return new MinimalButton(); }
  createInput(): Input { return new MinimalInput(); }
  createDialog(): Dialog { return new MinimalDialog(); }
}

使用时:

// Dashboard 渲染器只依赖抽象工厂
class DashboardRenderer {
  private button: Button;
  private input: Input;
  private dialog: Dialog;

  constructor(factory: UIComponentFactory) {
    // 所有组件都从同一个工厂创建——保证风格统一
    this.button = factory.createButton();
    this.input = factory.createInput();
    this.dialog = factory.createDialog();
  }

  render(): string {
    return [
      this.button.render(),
      this.input.render(),
      this.dialog.render(),
    ].join('\n');
  }
}

// 根据用户设置选择主题
const factory = userPreference === 'minimal'
  ? new MinimalUIFactory()
  : new EnterpriseUIFactory();

const dashboard = new DashboardRenderer(factory);
console.log(dashboard.render());

3.3 抽象工厂的适用条件

抽象工厂是创建型模式中最”重”的一个。使用它需要同时满足两个条件:

  1. 系统中有多个”产品族”——比如企业风格和极简风格。
  2. 同一族的产品需要一起使用——比如企业风格的按钮必须配企业风格的弹窗。

如果你只是想根据类型创建不同的单个对象,工厂方法就够了,不需要搬出抽象工厂。

3.4 三种创建模式的对比

维度单例工厂方法抽象工厂
目的确保唯一实例延迟到子类决定创建哪个产品创建一族相关产品
产品数量1 个1 个多个(一族)
扩展方式不涉及新增 Creator 子类新增一整套工厂 + 产品
复杂度
关键词唯一、全局延迟、多态产品族、一致性

🤔 想一想 很多 UI 组件库(如 Ant Design、Material UI)支持主题切换。它们的主题系统是否用到了抽象工厂的思想?如果是,它们的”工厂”是什么形态——是一个类,还是别的什么东西(比如 CSS 变量、Context Provider)?


四、本章小结

这一章我们学习了创建型模式的前三位成员:

  • 单例模式:解决”全局唯一实例”的需求。TypeScript 中推荐使用模块级单例,简洁且安全。注意全局状态对测试的影响。
  • 工厂方法模式:解决”创建哪个具体类由子类决定”的需求。通过多态消除了 if-else 创建逻辑,符合开闭原则。
  • 抽象工厂模式:解决”一族相关对象必须一起创建”的需求。是工厂方法的升级版,适用于产品族场景。

下一章我们继续创建型模式的后两位——建造者模式和原型模式。前者处理”复杂对象的分步构造”,后者处理”通过复制已有对象来创建新对象”。


📝 结尾自测

  1. 单例模式的两个核心要素是什么?在 TypeScript 中有几种实现方式?
  2. 工厂方法模式中,“Creator”和”Product”分别对应什么角色?
  3. 简单工厂和工厂方法的核心区别是什么?各自适合什么场景?
  4. 抽象工厂模式需要满足哪两个条件才值得使用?
  5. 如果一个电商系统需要支持微信支付、支付宝支付、银联支付三种支付方式,你会选择哪种工厂模式?为什么?

购买课程解锁全部内容

写出优雅代码:10 章掌握 23 种设计模式

¥29.90