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

初识设计模式 —— 为什么需要设计模式、SOLID 原则、UML 基础

代码能跑不叫本事,代码能长期演进才算功夫。设计模式不是银弹,但它是前人用无数踩坑经验凝练出的”通用解题套路”。学好这套套路,你在面对复杂系统时就不再手足无措。

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

  1. 你能说出”设计模式”这个概念最早来源于哪个领域吗?
  2. SOLID 五大原则中,“L”代表什么?它要解决的核心问题是什么?
  3. UML 类图中实线箭头和虚线箭头分别表示什么关系?

一、设计模式的前世今生

1.1 从建筑学到软件工程

“设计模式”这四个字最早并不属于计算机。1977 年,建筑师 Christopher Alexander 与 Sara Ishikawa、Murray Silverstein 等人在合著的《A Pattern Language》一书中提出了”模式语言”的概念——他发现,那些让人住得舒服的建筑,背后总有一些反复出现的空间组织规律。把这些规律提炼出来,就形成了”模式”。

十几年后,四位软件工程师——Erich Gamma、Richard Helm、Ralph Johnson、John Vlissides——把这套思想移植到了面向对象编程领域。1994 年,他们合著的《Design Patterns: Elements of Reusable Object-Oriented Software》横空出世。因为作者正好四位,业界戏称他们为 Gang of Four(GoF)。这本书收录了 23 种设计模式,至今仍是设计模式领域的”圣经”。

1.2 设计模式到底解决什么问题

先来看一段日常开发中极其常见的代码:

// 一个处理订单的函数,随着需求增长越来越臃肿
function processOrder(order: Order) {
  // 1. 验证订单
  if (!order.items || order.items.length === 0) {
    throw new Error('订单不能为空');
  }
  if (order.totalAmount <= 0) {
    throw new Error('金额异常');
  }

  // 2. 计算折扣——三个月后加了满减、优惠券、会员折扣...
  let discount = 0;
  if (order.couponCode === 'SUMMER20') {
    discount = order.totalAmount * 0.2;
  } else if (order.userLevel === 'vip') {
    discount = order.totalAmount * 0.1;
  } else if (order.totalAmount > 500) {
    discount = 50;
  }
  // 半年后这里已经有 200 行 if-else...

  // 3. 扣库存
  for (const item of order.items) {
    updateInventory(item.skuId, -item.quantity);
  }

  // 4. 发通知——先是邮件,后来又加了短信、站内信、企业微信...
  sendEmail(order.userId, '订单已提交');
  sendSMS(order.userId, '订单已提交');
  sendWechatWork(order.userId, '订单已提交');
}

这段代码有什么问题?功能上它完全正确。但是:

  • 修改折扣规则时,你必须深入这个几百行的函数内部,小心翼翼地改 if-else,改错一个条件全盘皆输。
  • 增加通知渠道时,你又要往函数尾部追加代码,processOrder 变成了一个什么都干的”上帝函数”。
  • 测试困难,因为所有逻辑搅在一起,你没法单独测折扣计算逻辑。

设计模式解决的正是这类问题——它不是让代码能跑,而是让代码能优雅地应对变化。上面这段代码,用策略模式可以解决折扣的 if-else 膨胀,用观察者模式可以解耦通知逻辑,用建造者模式可以规范订单的组装流程。

1.3 设计模式的三大家族

GoF 的 23 种模式按照用途分成三大类:

分类关注点包含模式
创建型(Creational)怎样创建对象单例、工厂方法、抽象工厂、建造者、原型
结构型(Structural)怎样组合类和对象适配器、装饰器、代理、外观、桥接、组合、享元
行为型(Behavioral)对象之间怎样通信协作观察者、策略、模板方法、迭代器、责任链、命令、状态、中介者、备忘录、访问者、解释器

你不需要死记这张表。随着后续章节逐一拆解,这些名字会变成你信手拈来的工具。

🤔 想一想 回忆一下你最近写过的代码,有没有遇到过”改一个需求要动十几个文件”的情况?如果有,你觉得问题出在哪里?


二、SOLID:面向对象设计的五根支柱

设计模式是”招式”,SOLID 原则是”内功”。你可以不用任何设计模式,但只要遵循 SOLID 原则,代码质量就不会太差。反过来,不理解 SOLID 而机械套用模式,往往适得其反。

2.1 S — 单一职责原则(Single Responsibility Principle)

一个类只应该有一个引起它变化的原因。

这是最容易理解、也最容易被违反的原则。来看一个反面教材:

// 反例:一个类同时负责用户数据管理和日志记录
class UserService {
  private users: Map<string, User> = new Map();

  addUser(user: User): void {
    this.users.set(user.id, user);
    // 日志逻辑不应该出现在这里
    const logEntry = `[${new Date().toISOString()}] 新增用户: ${user.name}`;
    fs.appendFileSync('/var/log/app.log', logEntry + '\n');
  }

  getUser(id: string): User | undefined {
    const user = this.users.get(id);
    const logEntry = `[${new Date().toISOString()}] 查询用户: ${id}`;
    fs.appendFileSync('/var/log/app.log', logEntry + '\n');
    return user;
  }
}

问题在哪里?如果某天日志的存储方式从文件改为数据库,你需要修改 UserService。但 UserService 的本职工作是管理用户数据,日志存到哪里关它什么事?

// 正例:职责分离
class Logger {
  log(message: string): void {
    const entry = `[${new Date().toISOString()}] ${message}`;
    // 日志存储方式变化只影响这一个类
    fs.appendFileSync('/var/log/app.log', entry + '\n');
  }
}

class UserService {
  constructor(
    private users: Map<string, User> = new Map(),
    private logger: Logger = new Logger()
  ) {}

  addUser(user: User): void {
    this.users.set(user.id, user);
    this.logger.log(`新增用户: ${user.name}`);
  }

  getUser(id: string): User | undefined {
    const user = this.users.get(id);
    this.logger.log(`查询用户: ${id}`);
    return user;
  }
}

2.2 O — 开闭原则(Open-Closed Principle)

对扩展开放,对修改关闭。

这是 SOLID 中最重要也最抽象的一条。它说的是:当需求变化时,你应该通过新增代码来应对,而不是修改已有代码

回到开头那个折扣计算的例子,每加一种折扣规则就要改原函数,这就违反了开闭原则。正确的做法是定义一个折扣策略接口,每种折扣规则都是一个独立的实现:

interface DiscountStrategy {
  calculate(amount: number, context: OrderContext): number;
}

class CouponDiscount implements DiscountStrategy {
  calculate(amount: number, context: OrderContext): number {
    if (context.couponCode === 'SUMMER20') return amount * 0.2;
    return 0;
  }
}

class VipDiscount implements DiscountStrategy {
  calculate(amount: number, context: OrderContext): number {
    if (context.userLevel === 'vip') return amount * 0.1;
    return 0;
  }
}

// 新增折扣规则?加一个新类就行,不碰已有代码
class FullReductionDiscount implements DiscountStrategy {
  calculate(amount: number, context: OrderContext): number {
    if (amount > 500) return 50;
    return 0;
  }
}

2.3 L — 里氏替换原则(Liskov Substitution Principle)

子类对象必须能够替换其父类对象被使用,而程序行为不发生改变。

这条原则由 Barbara Liskov 在 1987 年提出。通俗地说:如果你的代码期望一个”鸟”类型的参数,那传入”麻雀”或”老鹰”都应该正常工作。如果传入”企鹅”导致 fly() 方法抛出异常,那就违反了里氏替换。

// 反例:违反里氏替换
class Bird {
  fly(): string {
    return '飞行中...';
  }
}

class Penguin extends Bird {
  fly(): string {
    // 企鹅不会飞,但被迫实现了 fly 方法
    throw new Error('企鹅不会飞!');
  }
}

function makeBirdFly(bird: Bird): void {
  console.log(bird.fly()); // 传入 Penguin 会爆炸
}

正确的做法是重新审视继承体系。不是所有鸟都能飞,所以”会飞”不应该是 Bird 的固有能力:

// 正例:合理的抽象层次
interface Flyable {
  fly(): string;
}

class Bird {
  eat(): string {
    return '进食中...';
  }
}

class Sparrow extends Bird implements Flyable {
  fly(): string {
    return '麻雀飞行中...';
  }
}

class Penguin extends Bird {
  swim(): string {
    return '企鹅游泳中...';
  }
}

2.4 I — 接口隔离原则(Interface Segregation Principle)

客户端不应该被迫依赖它不使用的接口。

宁可有多个小而精的接口,也不要一个大而全的”胖接口”。

// 反例:胖接口
interface SmartDevice {
  call(number: string): void;
  takePicture(): void;
  playMusic(track: string): void;
  browseWeb(url: string): void;
  measureHeartRate(): void;
}

// 一个简单的音乐播放器被迫实现一堆不相关的方法
class MusicPlayer implements SmartDevice {
  call() { throw new Error('不支持'); }
  takePicture() { throw new Error('不支持'); }
  playMusic(track: string) { /* 真正的逻辑 */ }
  browseWeb() { throw new Error('不支持'); }
  measureHeartRate() { throw new Error('不支持'); }
}
// 正例:拆成细粒度接口
interface Callable { call(number: string): void; }
interface Camera { takePicture(): void; }
interface MusicCapable { playMusic(track: string): void; }
interface WebBrowsable { browseWeb(url: string): void; }
interface HealthMonitor { measureHeartRate(): void; }

// 各取所需
class MusicPlayer implements MusicCapable {
  playMusic(track: string) { /* 只实现自己需要的 */ }
}

class SmartPhone implements Callable, Camera, MusicCapable, WebBrowsable {
  call(number: string) { /* ... */ }
  takePicture() { /* ... */ }
  playMusic(track: string) { /* ... */ }
  browseWeb(url: string) { /* ... */ }
}

2.5 D — 依赖倒置原则(Dependency Inversion Principle)

高层模块不应该依赖低层模块,两者都应该依赖抽象。

这条原则是实现”可替换性”的关键。想象一下你家的电器——台灯、风扇、冰箱,它们都依赖”插座”这个抽象接口,而不是直接焊接在电线上。正因为依赖插座这个”抽象”,你才能随时换一台不同品牌的电器。

// 反例:高层直接依赖低层具体实现
class MySQLDatabase {
  query(sql: string): any[] { /* MySQL 专属逻辑 */ return []; }
}

class OrderRepository {
  private db = new MySQLDatabase(); // 直接绑死了 MySQL

  findById(id: string): Order | null {
    const rows = this.db.query(`SELECT * FROM orders WHERE id = '${id}'`);
    // ⚠️ 注意:这里用字符串拼接只是为了简化示例,
    // 实际项目中必须使用参数化查询防止 SQL 注入
    return rows[0] ?? null;
  }
}
// 正例:依赖抽象接口
interface Database {
  query(statement: string, params?: any[]): any[];
}

class MySQLDatabase implements Database {
  query(statement: string, params?: any[]): any[] { /* MySQL 实现 */ return []; }
}

class PostgresDatabase implements Database {
  query(statement: string, params?: any[]): any[] { /* Postgres 实现 */ return []; }
}

class OrderRepository {
  constructor(private db: Database) {} // 依赖抽象,不关心具体实现

  findById(id: string): Order | null {
    const rows = this.db.query('SELECT * FROM orders WHERE id = $1', [id]);
    return rows[0] ?? null;
  }
}

// 使用时注入具体实现
const repo = new OrderRepository(new PostgresDatabase());

🤔 想一想 SOLID 五个原则之间有没有内在联系?比如,违反单一职责原则的类,是否也更容易违反开闭原则?试着用自己的话梳理一下它们之间的关系。


三、UML 类图:设计模式的”建筑蓝图”

学设计模式绕不开 UML 类图。但你不需要学全套 UML——对于设计模式来说,掌握类图中最核心的六种关系就够了。

3.1 类的基本表示

在 UML 类图中,一个类用一个矩形表示,分成三格:

┌─────────────────────┐
│     ClassName        │  ← 类名
├─────────────────────┤
│ - privateField: T    │  ← 属性(- 私有, + 公有, # 保护)
│ + publicField: T     │
├─────────────────────┤
│ + publicMethod(): R  │  ← 方法
│ - privateMethod(): R │
└─────────────────────┘

TypeScript 中的接口和抽象类也用类似的方式表示,但类名上方会加上 <<interface>><<abstract>> 标记。

3.2 六种核心关系

继承(Generalization):实线 + 空心三角箭头,指向父类。表示”is-a”关系。

┌──────────┐       ┌──────────┐
│  Animal   │◁──────│   Dog    │
└──────────┘       └──────────┘

对应 TypeScript:class Dog extends Animal {}

实现(Realization):虚线 + 空心三角箭头,指向接口。

┌───────────────┐       ┌──────────┐
│ <<interface>> │◁------│  MyClass │
│   Printable   │       └──────────┘
└───────────────┘

对应 TypeScript:class MyClass implements Printable {}

关联(Association):实线箭头,表示一个类”知道”另一个类。

┌──────────┐       ┌──────────┐
│ Teacher  │──────>│  Course  │
└──────────┘       └──────────┘

对应代码:Teacher 类中有一个 Course 类型的属性。

依赖(Dependency):虚线箭头,表示一个类”临时用到”另一个类(比如方法参数)。

┌──────────┐       ┌──────────┐
│  Driver  │------>│   Car    │
└──────────┘       └──────────┘

对应代码:Driver 的某个方法参数是 Car 类型,但 Driver 本身不持有 Car。

聚合(Aggregation):实线 + 空心菱形,表示”has-a”且整体与部分可以独立存在。

┌──────────┐       ┌──────────┐
│  School  │◇──────│ Teacher  │
└──────────┘       └──────────┘

学校有老师,但老师离开学校仍然是老师。

组合(Composition):实线 + 实心菱形,表示”has-a”且部分的生命周期依赖整体。

┌──────────┐       ┌──────────┐
│  House   │◆──────│   Room   │
└──────────┘       └──────────┘

房子没了,房间也不存在了。

3.3 阅读类图的实战技巧

初学者看类图经常犯一个错误:试图一次看懂整张图。正确的读法是:

  1. 先找核心类——通常是被最多箭头指向的那个。
  2. 看继承和实现关系——了解类的层次结构。
  3. 看关联和依赖——了解类之间如何协作。
  4. 最后看聚合和组合——了解对象的生命周期管理。

在后续章节中,每个设计模式都会附上精简的类图。养成看图的习惯,你理解模式的速度会快很多。

🤔 想一想 TypeScript 中没有”抽象类”的运行时概念(编译后就消失了),这会影响我们在 TypeScript 项目中使用 UML 建模吗?你觉得 TypeScript 的 interface 和 abstract class 分别对应 UML 中的什么?


四、设计模式的正确打开方式

4.1 不要背,要理解场景

很多人学设计模式的姿势是错误的:背定义、背 UML 图、背代码模板。结果面试时能答上来”观察者模式是一对多的依赖关系”,但回到实际开发中完全不知道什么时候该用。

正确的打开方式是:从问题出发。每个设计模式的诞生都是为了解决某一类特定的设计难题。你要理解的是”什么情况下我会遇到这个问题”,而不是”这个模式的 UML 图长什么样”。

4.2 不要滥用,保持克制

学会了锤子,看什么都像钉子——这是设计模式学习中最大的陷阱。一个只需要简单 if-else 就能搞定的逻辑,没必要强行套一个策略模式。一个只有两三个监听者的场景,没必要搞一套完整的事件系统。

判断该不该用模式,有一个简单的标准:如果引入模式之后,代码的复杂度降低了、可读性提高了、未来扩展更容易了,那就用;如果引入之后代码变得更绕、同事看不懂了,那就别用。

4.3 TypeScript 视角下的设计模式

本课程的所有代码示例都使用 TypeScript。相比传统的 Java/C++,TypeScript 有几个特点会影响设计模式的实现方式:

  • 接口是零成本抽象:TypeScript 的 interface 在编译后完全消失,不会增加运行时开销,所以可以放心大胆地使用接口来定义契约。
  • 函数是一等公民:很多在 Java 中需要用类来包装的模式(比如策略、命令),在 TypeScript 中可以直接用函数或箭头函数实现,代码更简洁。
  • 泛型很强大:TypeScript 的泛型系统让很多模式的实现更加类型安全。
  • 装饰器语法原生支持:TypeScript 有 decorator 语法,与装饰器模式天然契合。

我们会在后续章节中充分利用这些语言特性,写出既符合模式精神、又贴合 TypeScript 生态的代码。


五、本章小结与知识地图

这一章我们建立了三个关键认知:

  1. 设计模式是经验的结晶——它不是学术发明,而是从无数真实项目中提炼出的解题套路。23 种经典模式分为创建型、结构型、行为型三大家族。
  2. SOLID 是模式背后的原则——单一职责、开闭、里氏替换、接口隔离、依赖倒置,五大原则构成了面向对象设计的基石。模式是原则的具体化身。
  3. UML 类图是交流的语言——掌握继承、实现、关联、依赖、聚合、组合六种关系,你就能读懂任何设计模式的结构图。

从下一章开始,我们正式进入具体的设计模式学习。第一站:创建型模式——从对象的诞生讲起。


📝 结尾自测

  1. GoF 的 23 种设计模式分为哪三大类?每类各关注什么问题?
  2. 用自己的话解释”开闭原则”,并举一个违反开闭原则的代码场景。
  3. UML 类图中,聚合和组合的区别是什么?分别用什么符号表示?
  4. 为什么说”依赖倒置原则”是实现可替换性的关键?
  5. 在 TypeScript 中,哪些语言特性会让设计模式的实现方式与 Java 有所不同?

购买课程解锁全部内容

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

¥29.90