《深入设计模式》学习(1)—— 深入理解OOP中的6种对象关系

73 阅读8分钟

前言

在前端组件开发中,我发现很多设计问题的根源在于对对象关系理解不够深入

一个组件应该依赖还是关联某个服务?子组件该用聚合还是组合?这些看似简单的选择,直接影响了组件的可维护性和可测试性。

深入理解 OOP 中的 6 种对象关系,能帮助我们:

  • 设计出结构清晰、职责明确的组件
  • 写出易于测试和重构的代码
  • 准确判断何时该解耦、何时该组合
  • 看懂各种设计模式背后的原理

概述

对象关系按照耦合强度从弱到强排列:依赖 < 关联 < 聚合 < 组合 < 实现 < 继承

关系对比总览表如下:

关系类型UML符号耦合强度生命周期关键词前端典型场景
依赖- - ->⭐ 最弱临时使用use函数参数、工具方法调用
关联—>⭐⭐持久引用has-a组件持有service、Store引用
聚合◇—>⭐⭐⭐A包含B,B独立has-a购物车包含商品、播放列表包含歌曲
组合◆—>⭐⭐⭐⭐A包含B,B依赖AcontainsDOM节点包含子节点
实现- - -▷⭐⭐⭐⭐⭐契约关系implementsStorage实现、支付网关实现
继承—▷⭐⭐⭐⭐⭐⭐ 最强代码复用is-a子类extends父类

依赖 (Dependency)

定义

最弱的关系,表示一个类使用另一个类提供的功能/方法。如果B修改可能影响A,则A依赖于B。这是一种临时性、使用性的关系。

UML表示

image.png

关键特征

  • 生命周期:临时关系,使用完即结束

  • 耦合度:最低

  • 代码表现:方法参数、局部变量、静态方法调用

  • 口诀:A"使用"B,用完即走

TypeScript示例

想象你在做菜:

  • 你需要用刀切菜(临时使用刀的“切”的功能)
  • 切完菜,刀就放回去了
  • 你不拥有这把刀,只是临时借用了一下
class Chef {
    // Chef依赖Knife, 临时使用Knife的cut功能
    cookDish (knife: Knife, vegetable: string) { // ✅ 作为方法参数传入, ✅ 方法内创建临时对象
        knife.cut(vegetable); // ✅ 调用静态方法 --- 使用knife的"切"功能
        // ❌ 不作为成员变量存储 --- 方法结束,knife就没用了
    }
}

class Knife {
    cut (item: string) {
        console.log(`切${item}`);
    }
}

const chef = new Chef();
const knife = new Knife();
chef.cookDish(knife, '胡萝卜'); // 切胡萝卜

识别要点

  • ✅ 作为方法参数传入

  • ✅ 方法内创建临时对象

  • ✅ 调用静态方法

  • ❌ 不作为成员变量存储

关联 (Association)

定义

表示类之间有持久的连接。A对象知道B对象并能与之交互,这种关系会长期存在。关联可以视为是一种特殊类型的依赖,即一个对象总是拥有访问与其交互的对象的权限,而简单的依赖关系并不会在对象间建立永久性的联系。

UML表示

image.png

关键特征

  • 生命周期:持久关系,只要对象存在就存在

  • 耦合度:中等

  • 代码表现:成员变量、属性

  • 口诀:A"拥有"B的引用,长期持有

TypeScript示例

以“发送邮件”为例:

  • 需要一个User类(用于存储email)和一个EmailServe类(负责发送邮件)
  • 有三个场景需要发送邮件
    • 用户注册欢迎邮件
    • 用户重置密码通知邮件
    • 普适性的通知邮件

首先回顾下「依赖」关系下我们会怎么写?

// 负责发送邮件的服务类
class EmailService {
    send(email: string, content: string) {
        console.log(`发送邮件给${email}${content}`);
    }
}

// 用户类
class User {
    constructor(public email: string) {}
}

// 依赖举例
class UserControllerWithDependency {
    // 注册用户
    registerUser(user: User, emailService: EmailService) {
        // 每次注册都要传入EmailService实例
        emailService.send(user.email, 'Welcome'); // 使用EmailService发送邮件
    }

    // 重置密码
    resetPassword(user: User, emailService: EmailService) {
        // 这里又传一次
        emailService.send(user.email, 'Reset your password'); // 使用EmailService发送邮件
    }

    notify(user: User, message: string, emailService: EmailService) {
        // 这里再传一次
        emailService.send(user.email, message); // 使用EmailService发送通知
    }
}

const user1 = new User('8888888@163.com');
const emailService1 = new EmailService();
const userController1 = new UserControllerWithDependency();

// 每次都要传emailService,很繁琐
userController1.registerUser(user1, emailService1); // 传1次
userController1.resetPassword(user1, emailService1); // 传2次
userController1.notify(user1, 'Your profile has been updated', emailService1); // 传3次

接下来我们用「关联」改写

class UserController {
    private emailService: EmailService; // ✅ 作为类的成员变量存储

    constructor(emailService: EmailService) {
        this.emailService = new EmailService(); // ✅ 通过构造函数注入
    }

    // 注册用户
    registerUser(user: User) {
        // ✅  对象生命周期内持续使用 --- 直接使用
        this.emailService.send(user.email, 'Welcome'); // 使用EmailService发送邮件
    }

    // 重置密码
    resetPassword(user: User) {
        // 直接使用
        this.emailService.send(user.email, 'Reset your password'); // 使用EmailService发送邮件
    }

    notify(user: User, message: string) {
        // 直接使用
        this.emailService.send(user.email, message); // 使用EmailService发送通知
    }
}

const user = new User('12345678@163.com');
const emailService = new EmailService(); // ❌ 外部创建,不管理被关联对象的生命周期
// 注入一次,userController的所有访问都能访问到emailService
const userController = new UserController(emailService);

userController.registerUser(user); // 注册成功,发送欢迎邮件
userController.resetPassword(user); // 重置密码,发送重置邮件
userController.notify(user, 'Your profile has been updated'); // 发送通知邮件

识别要点

  • ✅ 作为类的成员变量存储

  • ✅ 在构造函数中注入

  • ✅ 对象生命周期内持续使用

  • ❌ 不管理被关联对象的生命周期

理解“不管理被关联对象的生命周期”

const emailService = new EmailService(); // 外部创建
let controller1: UserController | null = new UserController(emailService);
let controller2: UserController | null = new UserController(emailService); // 可以共享

controller1 = null; // controller1销毁
// ✅ emailService依然存在,controller2还能用

简单回顾:你能看出UserController依赖User吗?

聚合 (Aggregation)

定义

"整体-部分"关系,但部分可以独立于整体存在。这是一种松散的包含关系,用于表示多个对象之间的“一对多”、“多对多”或“整体对部分”的关系。

UML表示

image.png

关键特征

  • 生命周期:部分可独立存在,不随整体销毁

  • 耦合度:中等偏强

  • 代码表现:成员变量,但不负责创建/销毁

  • 口诀:A"包含"B,但B可以独立存在

TypeScript示例

想象一个“购物车”场景:

  1. 购物车可以管理着商品
  2. 可以添加商品到购物车,也可以删除购物车里的商品
  3. 还可以清空购物车
// 商品类
class Product {
    constructor(
        public id: string,
        public name: string,
        public price: number
    ) {}
}

class ShoppingCart {
    private products: Product[] = []; // ✅ 整体包含部分 --- 购物车包含商品

    // 添加商品到购物车
    addProduct(product: Product) {
        this.products.push(product);
        console.log(`添加商品${product['name']}到购物车`);
    }

    // 删除商品从购物车
    removeProduct(productId: string) {
        this.products = this.products.filter(p => p['id'] !== productId);
        console.log(`从购物车删除商品ID为${productId}的商品`);
    }

    // 清空购物车,但商品对象依然存在
    clearCart() {
        this.products = [];
        console.log('购物车已清空');
    }
}


// 使用
const product1 = new Product('1', 'Laptop', 999); // ✅ 部分可以独立创建
const product2 = new Product('2', 'Mouse', 29);

const cart = new ShoppingCart();
let cart2:ShoppingCart | null  = new ShoppingCart();
cart.addProduct(product1);
cart.addProduct(product2);

cart2.addProduct(product1); // ✅ 部分可以属于多个整体
cart2 = null; // ✅ 整体销毁不影响部分 --- 购物车没了,但商品依然存在

cart.clearCart(); // ❌ 整体不负责部分的创建和销毁 --- 购物车清空,但product1和product2依然存在
console.log(product1.name); // 依然可访问

识别要点

  • ✅ 整体包含部分

  • ✅ 部分可以独立创建

  • ✅ 部分可以属于多个整体

  • ✅ 整体销毁不影响部分

  • ❌ 整体不负责部分的创建和销毁

组合 (Composition)

定义

强"整体-部分"关系,部分的生命周期完全由整体管理。部分不能独立存在,整体销毁时部分也会销毁。

UML表示

image.png

关键特征

  • 生命周期:部分依赖整体,随整体创建和销毁

  • 耦合度:强

  • 代码表现:整体负责部分的创建和销毁

  • 口诀:A"拥有"B,B的生死由A决定

TypeScript示例

class Engine {
    constructor(public horsepower: number) {}

    start() {
        console.log(`引擎启动,马力为${this.horsepower}hp`);
    }
}

class Car {
    // ✅ 部分是private,外部无法访问 ❌ 部分不能被多个整体共享 ❌ 部分不能独立于整体存在 
    private engine: Engine;

    // ✅ 整体在构造函数中创建部分 --- 在构造函数中创建引擎,引擎生命周期由Car管理
    constructor(engineHorsepower: number) {
        this.engine = new Engine(engineHorsepower);
    }

    startCar() {
        this.engine.start();
        console.log('汽车启动');
    }

    // ✅ 整体销毁时会主动销毁部分 --- 当汽车被销毁时,引擎也随之销毁
}

// 使用
const myCar = new Car(300);
myCar.startCar(); // 引擎启动,马力为300hp 汽车启动
// 无法直接访问engine实例,它的生命周期完全由car控制

识别要点

  • ✅ 整体在构造函数中创建部分

  • ✅ 部分是private,外部无法访问

  • ✅ 整体销毁时会主动销毁部分

  • ❌ 部分不能被多个整体共享

  • ❌ 部分不能独立于整体存在

简单回顾:根据案例你能判断什么时候用「聚合」,什么时候用「组合」吗?

  • 聚合:整体和部分可以分开,部分可以独立存在
  • 组合:整体和部分不能分开,部分依赖整体而存在

实现 (Realization / Implementation)

定义

类实现接口或抽象类,表示契约关系。实现类必须提供接口中声明的所有方法。

UML表示

image.png

关键特征

  • 生命周期:编译时确定的契约

  • 耦合度:强(必须实现所有方法)

  • 代码表现implements关键字

  • 口诀:A"实现"接口B,A必须遵守B的契约

TypeScript示例

// 定义一个PaymentGateWay接口
// ✅ 接口定义行为规范 ❌ 接口不包含实现代码
interface PaymentGateWay {
    processPayment(amount: number): Promise<boolean>;
    refund(orderId: string): Promise<void>;
    getBalance(): Promise<number>;
}

// 实现一个具体的支付网关类,例如PayPal
// ✅ 使用`implements`关键字 ✅ 必须实现接口的所有方法
class PayPalPaymentGateWay implements PaymentGateWay {
    async processPayment(amount: number): Promise<boolean> {
        console.log(`通过PayPal处理支付,金额:${amount}`);
        // 模拟支付处理逻辑
        return true;
    }

    refund(orderId: string): Promise<void> {
        console.log(`通过PayPal处理退款,订单ID:${orderId}`);
        // 模拟退款处理逻辑
        return Promise.resolve();
    }

    getBalance(): Promise<number> {
        console.log('获取PayPal账户余额');
        // 模拟获取余额逻辑
        return Promise.resolve(1000);
    }
}

// 定义一个alipay特殊的花呗支付接口
interface HuabeiPaymentGateWay {
    huabeiPay(amount: number): Promise<boolean>;
}

// 实现一个具体的支付网关类,例如Alipay
// ✅ 可以实现多个接口 --- 用,分割,比如alipay即需要实现PaymentGateWay,同时需要实现HuabeiPaymentGateWay
class AlipayPaymentGateWay implements PaymentGateWay, HuabeiPaymentGateWay {
    async processPayment(amount: number): Promise<boolean> {
        console.log(`通过Alipay处理支付,金额:${amount}`);
        // 模拟支付处理逻辑
        return true;
    }

    refund(orderId: string): Promise<void> {
        console.log(`通过Alipay处理退款,订单ID:${orderId}`);
        // 模拟退款处理逻辑
        return Promise.resolve();
    }

    getBalance(): Promise<number> {
        console.log('获取Alipay账户余额');
        // 模拟获取余额逻辑
        return Promise.resolve(2000);
    }

    async huabeiPay(amount: number): Promise<boolean> {
        console.log(`通过Alipay花呗支付,金额:${amount}`);
        // 模拟花呗支付处理逻辑
        return true;
    }
}

const paypalGateWay = new PayPalPaymentGateWay();
const alipayGateWay = new AlipayPaymentGateWay();

paypalGateWay.processPayment(150); // 通过PayPal处理支付,金额:150
alipayGateWay.processPayment(250); // 通过Alipay处理支付,金额:250
alipayGateWay.huabeiPay(300); // 通过Alipay花呗支付,金额:300

识别要点

  • ✅ 使用implements关键字

  • ✅ 必须实现接口的所有方法

  • ✅ 可以实现多个接口

  • ✅ 接口定义行为规范

  • ❌ 接口不包含实现代码

总结来说,实现(Realization)  就是:

  • 接口定义"要做什么"(方法签名/契约)
  • 负责"怎么做"(具体实现)

继承 (Inheritance)

定义

最强的关系,子类继承父类的属性和方法,表示**"is-a"关系**。子类可以重写父类方法,也可以添加新功能。

UML表示

image.png

关键特征

  • 生命周期:编译时确定的继承结构

  • 耦合度:最强(子类完全依赖父类)

  • 代码表现extends关键字

  • 口诀:A"是一种"B,A继承B的一切

TypeScript示例

class Animal {
    constructor(protected name: string) {}

    move (distance: number) {
        console.log(`${this.name} 移动了 ${distance} 米`);
    }

    makeSound () {
        console.log(`${this.name} 发出声音`);
    }
}

// ✅ 使用`extends`关键字 ✅ 只能继承一个父类(单继承)
class Dog extends Animal {
    constructor(name: string, private breed: string) {
        // ✅ 使用`super`调用父类方法
        super(name); // 调用父类构造函数
    }

    // ✅ 可以重写父类方法
    // 重写 父类 makeSound 方法
    makeSound() {
        console.log(`${this.name} 说: 汪汪汪`);
    }

    // 新增方法
    fetch(item: string) {
        console.log(`${this.name} 捡回了 ${item}`);
    }
}

class Cat extends Animal {
    // 重写 父类 makeSound 方法
    makeSound() {
        console.log(`${this.name} 说: 喵喵喵`);
    }

    // 新增方法
    scratch() {
        console.log(`${this.name} 抓挠家具`);
    }
}

// 使用
const dog = new Dog('Buddy', 'Golden Retriever');
dog.move(10);      // 继承自Animal
dog.makeSound();   // 重写的方法
dog.fetch('ball');       // Dog独有方法

const cat = new Cat('Whiskers');
cat.move(5);
cat.makeSound();
cat.scratch();

识别要点

  • ✅ 使用extends关键字

  • ✅ 子类自动拥有父类所有public/protected成员(属性和方法)

  • ✅ 只能继承一个父类(单继承)

  • ✅ 可以重写父类方法

  • ✅ 使用super调用父类方法

  • ❌ 耦合度最高,需谨慎使用

补充知识点:访问修饰符(public/protected/private)

修饰符自己能用子类能用外部能用说明
public完全公开,谁都能访问
protected只有自己和子类能访问
private只有自己能访问,子类都不行

继承时,子类会自动获得父类的 public 和 protected 成员,不需要重新写代码!

// 父类
class Animal {
  public name: string;           // public - 谁都能访问
  protected age: number;         // protected - 自己和子类能访问
  private id: string;            // private - 只有自己能访问
  
  constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
    this.id = Math.random().toString(); // 随机ID
  }
  
  public move() {
    console.log(`${this.name} is moving`);
  }
  
  protected sleep() {
    console.log(`${this.name} is sleeping`);
  }
  
  private breathe() {
    console.log("Breathing...");
  }
}

// 子类
class Dog extends Animal {
  constructor(name: string, age: number) {
    super(name, age);
  }
  
  bark() {
    // ✅ 可以访问 public 成员
    console.log(`${this.name} says Woof!`);
    
    // ✅ 可以访问 protected 成员
    console.log(`Dog age: ${this.age}`);
    
    // ❌ 不能访问 private 成员
    // console.log(this.id);  // 报错!id是private的
    
    // ✅ 可以调用 public 方法
    this.move();
    
    // ✅ 可以调用 protected 方法
    this.sleep();
    
    // ❌ 不能调用 private 方法
    // this.breathe();  // 报错!breathe是private的
  }
}

// 使用
const dog = new Dog('Buddy', 3);

// 外部可以访问 public 成员
console.log(dog.name);  // ✅ 'Buddy'
dog.move();             // ✅ 'Buddy is moving'

// 外部不能访问 protected 成员
// console.log(dog.age);   // ❌ 报错!age是protected的
// dog.sleep();            // ❌ 报错!sleep是protected的

// 外部不能访问 private 成员
// console.log(dog.id);    // ❌ 报错!id是private的
// dog.breathe();          // ❌ 报错!breathe是private的

核心要点及最佳实践

核心要点图示总结

image.png

快速判断

  1. 代码层面

    • 有关键字吗?(implements → 实现,extends → 继承)

    • 是成员变量还是临时变量?(成员 → 关联/聚合/组合,临时 → 依赖)

    • 谁负责创建对象?(整体创建 → 组合,外部创建 → 聚合)

  2. 生命周期

    • 整体销毁时部分如何?(同时销毁 → 组合,依然存在 → 聚合)

    • 关系持续多久?(临时 → 依赖,持久 → 关联/聚合/组合)

  3. 业务语义

    • 是什么关系?(is-a → 继承,has-a → 关联/聚合/组合,use → 依赖)

    • 部分能否独立存在?(能 → 聚合,不能 → 组合)

常见混淆辨析

1. 关联 vs 聚合 vs 组合

// 关联:只是持有引用
class Controller {
  constructor(private service: ApiService) {} // 外部传入
}

// 聚合:包含但不管理生命周期
class Playlist {
  private songs: Song[] = [];
  addSong(song: Song) { this.songs.push(song); } // 外部创建的Song
}

// 组合:创建并管理生命周期
class Form {
  private input = new Input();  // Form内部创建
  private button = new Button(); // Form销毁时一起销毁
}

2. 依赖 vs 关联

// 依赖:临时使用
class UserService {
  createUser(validator: Validator) { // 方法参数
    validator.validate();
  }
}

// 关联:持久持有
class UserService {
  constructor(private validator: Validator) {} // 成员变量
}

判断技巧:看是否作为成员变量存储

3. 实现 vs 继承

// 实现:契约关系,只定义规范
interface Drawable {
  draw(): void;
}
class Circle implements Drawable {
  draw() { /* 自己实现 */ }
}

// 继承:代码复用,继承实现
class Shape {
  draw() { /* 父类实现 */ }
}
class Circle extends Shape {
  // 可以重写或直接使用父类实现
}

前端典型场景映射

场景推荐关系示例说明
组件与 Service关联UserComponent 持有 ApiService组件调用服务时,服务需要外部定义好传入
组件与子组件组合Dialog 包含 Header, Content, FooterDialog组件内部定义子组件,当Dialog销毁时,子组件全部销毁
列表与项目聚合TodoList 包含多个 TodoItem一个list由多个item聚合,list销毁时,item依然能存在
Hooks 使用依赖组件调用 useState, useEffect组件只是临时调用hooks,不持有引用
组件继承继承Button extends BaseComponentButton拥有BaseComponent的所有属性和方法,并可以改写,如重新onClick方法
实现接口实现LocalStorage implements StorageInterfaceLocalStorage需要实现StorageInterface定义的所有方法

常见陷阱与解决方案

1. 过度使用继承

错误示例

// 为了复用代码而继承
class UserService extends HttpClient {
  getUser() { return this.get('/user'); }
}

问题

  1. 违反了"is-a"关系:UserService 不是一个 HttpClient
  2. 耦合度过高:继承了HttpClient的所有public/protected方法,暴露了get(),post()的接口
  3. 难以替换:无法切换到其他http库(如axios -> fetch)
  4. 违反单一职责原则:UserService 现在有两个职责:1. 业务逻辑(管理用户);2. http通信
  5. 无法多重继承:如果还想要日志功能,缓存功能该如何处理?

正确做法:使用聚合(依赖注入)

class UserService {
  constructor(private http: HttpClient) {}  // 通过构造函数注入
  getUser() { return this.http.get('/user'); }
}

说明:这里使用的是聚合而非组合:

  • HttpClient 从外部创建并注入
  • UserService 不管理 HttpClient 的生命周期
  • 多个 Service 可以共享同一个 HttpClient 实例
  • 便于测试时注入 mock 对象

原因:聚合比继承更灵活,降低耦合度。依赖注入是现代前端框架的标准做法。

2. 混淆聚合与组合

错误示例

// 想要聚合却写成了组合
class Team {
  private members = [new Employee(), new Employee()];
}

正确做法

class Team {
  private members: Employee[] = [];
  addMember(employee: Employee) {
    this.members.push(employee);
  }
}

原因:员工应该独立创建,可以属于多个团队

3. 依赖导致的重复创建

错误示例

class UserComponent {
  loadData() {
    const api = new ApiService(); // 每次都创建
    api.getUsers();
  }
}

正确做法

class UserComponent {
  constructor(private api: ApiService) {} // 关联
  loadData() {
    this.api.getUsers();
  }
}

原因:频繁使用的对象应该作为关联持有

4. 循环依赖

错误示例

class A {
  constructor(private b: B) {}
}
class B {
  constructor(private a: A) {} // 循环依赖
}

正确做法:引入中介者或事件系统

class EventBus {
  emit(event: string, data: any) {}
  on(event: string, handler: Function) {}
}

class A {
  constructor(private eventBus: EventBus) {
    eventBus.on('b-event', this.handleBEvent);
  }
}

class B {
  constructor(private eventBus: EventBus) {
    eventBus.on('a-event', this.handleAEvent);
  }
}

写在最后

理解 OOP 中的6种对象关系不是一蹴而就的,需要在实践中不断体会和应用。

"优秀的设计不是一开始就完美的,而是在不断重构中演化出来的。"