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

前言

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

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

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

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

概述

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

关系对比总览表如下:

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

依赖 (Dependency)

定义

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

UML表示

关键特征

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

  • 耦合度:最低

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

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

TypeScript示例

想象你在做菜:

  • 你需要用刀切菜(临时使用刀的"切"的功能)
  • 切完菜,刀就放回去了
  • 你不拥有这把刀,只是临时借用了一下
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表示

关键特征

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

  • 耦合度:中等

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

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

TypeScript示例

以"发送邮件"为例:

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

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

typescript 复制代码
// 负责发送邮件的服务类
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次

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

typescript 复制代码
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'); // 发送通知邮件

识别要点

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

  • ✅ 在构造函数中注入

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

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

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

typescript 复制代码
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表示

关键特征

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

  • 耦合度:中等偏强

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

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

TypeScript示例

想象一个"购物车"场景:

  1. 购物车可以管理着商品
  2. 可以添加商品到购物车,也可以删除购物车里的商品
  3. 还可以清空购物车
typescript 复制代码
// 商品类
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表示

关键特征

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

  • 耦合度:强

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

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

TypeScript示例

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表示

关键特征

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

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

  • 代码表现implements关键字

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

TypeScript示例

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表示

关键特征

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

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

  • 代码表现extends关键字

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

TypeScript示例

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 成员,不需要重新写代码!

typescript 复制代码
// 父类
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的

核心要点及最佳实践

核心要点图示总结

快速判断

  1. 代码层面

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

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

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

  2. 生命周期

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

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

  3. 业务语义

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

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

常见混淆辨析

1. 关联 vs 聚合 vs 组合

typescript 复制代码
// 关联:只是持有引用
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 关联

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

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

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

3. 实现 vs 继承

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

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

前端典型场景映射

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

常见陷阱与解决方案

1. 过度使用继承

错误示例

typescript 复制代码
// 为了复用代码而继承
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. 无法多重继承:如果还想要日志功能,缓存功能该如何处理?

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

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

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

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

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

2. 混淆聚合与组合

错误示例

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

正确做法

typescript 复制代码
class Team {
  private members: Employee[] = [];
  addMember(employee: Employee) {
    this.members.push(employee);
  }
}

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

3. 依赖导致的重复创建

错误示例

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

正确做法

typescript 复制代码
class UserComponent {
  constructor(private api: ApiService) {} // 关联
  loadData() {
    this.api.getUsers();
  }
}

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

4. 循环依赖

错误示例

typescript 复制代码
class A {
  constructor(private b: B) {}
}
class B {
  constructor(private a: A) {} // 循环依赖
}

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

typescript 复制代码
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种对象关系不是一蹴而就的,需要在实践中不断体会和应用。

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

相关推荐
syt_10132 小时前
设计模式之-装饰器模式
设计模式·装饰器模式
看见繁华3 小时前
C++ 设计模式&设计原则
java·c++·设计模式
雨中飘荡的记忆7 小时前
观察者模式:从理论到生产实践
java·设计模式
阿波罗尼亚8 小时前
Head First设计模式(十二) 设计原则 复合模式
设计模式
老朱佩琪!9 小时前
Unity原型模式
开发语言·经验分享·unity·设计模式·原型模式
拾忆,想起9 小时前
设计模式三大分类完全解析:构建高质量软件的基石
xml·微服务·设计模式·性能优化·服务发现
老朱佩琪!9 小时前
Unity装饰器设计模式
unity·设计模式
syt_10139 小时前
设计模式之-策略模式
设计模式·bash·策略模式
老鼠只爱大米10 小时前
Java设计模式之代理模式(Proxy)深度解析
java·设计模式·代理模式·proxy pattern·java设计模式·proxypattern
老朱佩琪!10 小时前
Unity适配器模式
unity·设计模式·游戏引擎·适配器模式