创建型模式(上)—— 单例模式、工厂方法、抽象工厂
对象的创建看似简单,一个
new就搞定了。但当系统复杂到一定程度,“在哪里创建”、“创建哪个”、“怎么保证唯一”这些问题会变成真正的工程难题。创建型模式就是为此而生的。
📋 开篇自测:你已经知道多少?
- 在多线程/多异步环境下,如何确保一个类只有一个实例?
- “简单工厂”和”工厂方法”的区别是什么?
- 你能举出一个需要”一族相关对象”一起创建的实际业务场景吗?
一、单例模式:全局只此一家
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 抽象工厂的适用条件
抽象工厂是创建型模式中最”重”的一个。使用它需要同时满足两个条件:
- 系统中有多个”产品族”——比如企业风格和极简风格。
- 同一族的产品需要一起使用——比如企业风格的按钮必须配企业风格的弹窗。
如果你只是想根据类型创建不同的单个对象,工厂方法就够了,不需要搬出抽象工厂。
3.4 三种创建模式的对比
| 维度 | 单例 | 工厂方法 | 抽象工厂 |
|---|---|---|---|
| 目的 | 确保唯一实例 | 延迟到子类决定创建哪个产品 | 创建一族相关产品 |
| 产品数量 | 1 个 | 1 个 | 多个(一族) |
| 扩展方式 | 不涉及 | 新增 Creator 子类 | 新增一整套工厂 + 产品 |
| 复杂度 | 低 | 中 | 高 |
| 关键词 | 唯一、全局 | 延迟、多态 | 产品族、一致性 |
🤔 想一想 很多 UI 组件库(如 Ant Design、Material UI)支持主题切换。它们的主题系统是否用到了抽象工厂的思想?如果是,它们的”工厂”是什么形态——是一个类,还是别的什么东西(比如 CSS 变量、Context Provider)?
四、本章小结
这一章我们学习了创建型模式的前三位成员:
- 单例模式:解决”全局唯一实例”的需求。TypeScript 中推荐使用模块级单例,简洁且安全。注意全局状态对测试的影响。
- 工厂方法模式:解决”创建哪个具体类由子类决定”的需求。通过多态消除了 if-else 创建逻辑,符合开闭原则。
- 抽象工厂模式:解决”一族相关对象必须一起创建”的需求。是工厂方法的升级版,适用于产品族场景。
下一章我们继续创建型模式的后两位——建造者模式和原型模式。前者处理”复杂对象的分步构造”,后者处理”通过复制已有对象来创建新对象”。
📝 结尾自测
- 单例模式的两个核心要素是什么?在 TypeScript 中有几种实现方式?
- 工厂方法模式中,“Creator”和”Product”分别对应什么角色?
- 简单工厂和工厂方法的核心区别是什么?各自适合什么场景?
- 抽象工厂模式需要满足哪两个条件才值得使用?
- 如果一个电商系统需要支持微信支付、支付宝支付、银联支付三种支付方式,你会选择哪种工厂模式?为什么?
购买课程解锁全部内容
写出优雅代码:10 章掌握 23 种设计模式
¥29.90