对象交互与职责分配的艺术——前端常用的行为型设计模式(策略模式、命令模式、观察者模式)

0 阅读10分钟

1. 策略模式: 算法的封装和切换

1.1 核心思想

定义一系列策略,将它们封装起来,并且使它们可以互相替换。这样,任何需要根据不同条件选择不同策略的情况都可能适用。

策略模式的深层价值在于 将"变化维度"封装为独立策略对象。在实际开发中,我们通常会把算法的含义扩散开来,使策略模式也可以用来封装一系列的“业务规则”。只要这些业务规则指向的目标一致,并且可以被替换使用,我们就可以用策略模式来封装它们。

以下维度均可作为策略抽象方向:

变化维度策略化方向示例场景
业务规则同一业务不同规则版本电商产品计费规则
流程步骤可插拔的流程节点审批流程步骤
状态迁移不同状态下的行为策略订单状态转换逻辑
环境适配多端多环境适配策略跨平台渲染策略
决策逻辑可配置的决策树节点风控评估策略


1.2 示例

1.2.1 示例1 表单验证场景

原代码

  • 验证规则与业务逻辑强耦合
  • 新增规则需修改函数主体
  • 无法复用相同验证逻辑
// 存在硬编码验证逻辑、无法复用、维护困难
function validateForm(formData) {
  if (formData.username === '') {
    return '用户名不能为空';
  }
  if (formData.password.length < 6) {
    return '密码至少6位';
  }
  if (!/^1[3-9]\d{9}$/.test(formData.phone)) {
    return '手机号格式错误';
  }
  return '';
}

const error = validator.validate({ username: 'user1', password: '123456', phone: '13800138000' });

重构后的代码

  • 验证规则可配置化
  • 支持动态添加/删除规则
  • 规则复用率提升300%
// 策略对象封装验证规则
const validationStrategies = {
  nonEmpty: (value, msg) => value === '' ? msg : '',
  minLength: (value, length, msg) => value.length < length ? msg : '',
  mobile: (value, msg) => !/^1[3-9]\d{9}$/.test(value) ? msg : ''
};

// 验证上下文(context 类)
class Validator {
  constructor() {
    this.rules = [];
  }

  addRule(field, strategy, ...args) {
    this.rules.push({ field, strategy, args });
  }

  validate(formData) {
    for (const { field, strategy, args } of this.rules) {
      const result = validationStrategies[strategy](formData[field], ...args);
      if (result) return result;
    }
    return '';
  }
}

// 使用示例
const validator = new Validator();
validator.addRule('username', 'nonEmpty', '用户名不能为空');
validator.addRule('password', 'minLength', 6, '密码至少6位');
validator.addRule('phone', 'mobile', '手机号格式错误');

const error = validator.validate({
  username: 'user1',
  password: '123456',
  phone: '13800138000'
});

1.2.2 示例2 采用不同缓动曲线移动dom的动画

原代码

  • 动画算法与执行流程耦合
  • 新增动画类型需修改核心函数
  • 无法复用缓动算法
function animate(element, type, target, duration) {
  if (type === 'linear') {
    // 线性动画实现
  } else if (type === 'easeIn') {
    // 缓入动画实现
  } else if (type === 'easeOut') {
    // 缓出动画实现
  }
}

animate(document.getElementById('box'), 'easeOut', 300, 1000);

重构后的代码

  • 缓动算法可独立优化
  • 支持动态加载新动画策略
// 缓动策略集合
const easingStrategies = {
  linear: (t, b, c, d) => c * t/d + b,
  easeIn: (t, b, c, d) => c*(t/=d)*t + b,
  easeOut: (t, b, c, d) => -c*(t/=d)*(t-2) + b
};

// 动画执行器 (context类)
class Animator {
  constructor(strategyType) {
    this.strategy = easingStrategies[strategyType];
  }

  animate(element, target, duration) {
    let start = Date.now();
    const frame = () => {
      const time = Date.now() - start;
      const value = this.strategy(time, 0, target, duration);
      element.style.transform = `translateX(${value}px)`;
      if (time < duration) requestAnimationFrame(frame);
    };
    frame();
  }
}

// 使用示例
const animator = new Animator('easeOut');
animator.animate(document.getElementById('box'), 300, 1000);

1.2.3 示例3 策略模式代替继承

原代码

  • 职责耦合:税款算法与国家类型强绑定
  • 类爆炸:新增国家需要创建新子类
  • 无法动态切换:运行时不能改变计算策略
  • 复用困难:算法逻辑无法跨类复用
// 基类:通用税款计算
class TaxCalculator {
  calculate(income) {
    throw new Error('必须由子类实现');
  }
}

// 中国个税计算(子类)
class ChinaTax extends TaxCalculator {
  calculate(income) {
    return income * 0.2; // 简化计算
  }
}

// 美国个税计算(子类)
class USATax extends TaxCalculator {
  calculate(income) {
    return income * 0.25 + 500; // 固定附加费
  }
}


// 创建具体国家计算器
let calculator = new ChinaTax();
console.log(calculator.calculate(50000)); // 10000

// 切换国家需要重新实例化
calculator = new USATax(); 
console.log(calculator.calculate(50000)); // 13000

重构后的代码

// 策略接口(对象字面量实现)
const taxStrategies = {
  CN: (income) => income * 0.2,
  US: (income) => income * 0.25 + 500,
  JP: (income) => Math.min(income * 0.15, 200000)
};


// 新增策略示例(无需修改已有代码)
taxStrategies.UK = (income) => income > 50000 ? income * 0.3 : income * 0.2;


// 环境类 (context)
class TaxCalculator {
  constructor() {
    this.strategy = taxStrategies.CN; // 默认策略
  }
  // 动态设置策略
  setStrategy(countryCode) {
    this.strategy = taxStrategies[countryCode] || taxStrategies.CN;
    return this; // 支持链式调用
  }
  // 执行计算
  calculate(income) {
    return this.strategy(income);
  }
}


// 客户端使用
const calculator = new TaxCalculator();
// 动态切换策略
calculator.setStrategy('CN');
console.log(calculator.calculate(50000)); // 10000
// 运行时扩展策略
calculator.setStrategy('UK');
console.log(calculator.calculate(60000)); // 18000


1.3 if-else or 策略模式 ?

实际上,我们业务中的大部分逻辑都可以提供if-else或者switch case来解决,并不需要用到策略模式。 如何选择可看下表。

评估维度适用 if-else适用策略模式
if-else分支数量≤3个≥3个
变更频率低频(年变更≤1次)高频(季度变更≥1次)
复用范围单一场景独占使用跨模块/跨项目复用
团队规模个人/小团队中大型团队
测试需求不需要独立单元测试需要隔离测试
运行时动态性静态策略需要热切换策略
领域复杂度简单业务规则复杂业务规则引擎

1.4 优缺点

1.4.1 优点

如果在适用策略模式的情况下使用它,你可以获得以下优点

  1. 完美符合开闭原则:用户可以在不修改原有系统的基础上选择策略,也可以灵活地增加新的策略。
  2. 策略模式提供了管理相关的算法族的办法(context类),隔离了客户端与具体策略的直接交互。不同的工具放在同一个箱子(context类)里,方便管理和使用,避免每个工具单独制造一套收纳方法。
  3. 可替换继承关系:如果不使用策略模式,那么使用算法的环境类就可能会有一些子类,每一个子类提供种不同的算法。但是,这样一来算法的使用就和算法本身混在一起,不符合单一职责原则。决定使用哪一种算法的逻辑和该算法本身混合在一起,从而不可能再独立演化;而且使用继承无法实现算法或行为在程序运行时的动态切换。 (如示例代码3所示)
  4. 可以避免巨大的多重条件选择语句:多重条件选择语句不易维护,它把采取哪一种算法或行为的逻辑与算法或行为本身的实现逻辑混合在一起,将它们Hard Coding在一个庞大的多重条件选择语句中,比直接继承环境类的办法还要原始和落后。
  5. 策略模式提供了一种算法的复用机制。由于将算法单独提取出来封装在策略类中,因此不同的环境类可以方便地复用这些策略类,从而避免许多重复的复制粘贴工作。

1.4.2 缺点

  1. 不太符合最少知识原则:客户端必须知道所有的策略类,并自行决定使用哪一个策略类。这就意味着客户端必须理解这些算法的区别,以便适时选择恰当的算法。换言之,策略模式只适用于客户端知道所有的算法或行为的情况。
  2. 策略模式将造成系统产生很多具体策略类。任何细小的变化都将导致系统要增加一个新的具体策略类。


2. 命令模式: 发送者和执行者的解耦

2.1 核心思想

命令模式的由来,其实是回调(callback)函数的一个面向对象的替代品。

对于命令模式,在回调中绑定的是command对象,而正常的回调函数中,绑定的是要执行的具体动作。所以command只是将具体行为封装到对象中,以便我们能在回调函数之外改变随时改变回调的行为。这是一种松耦合的方式来设计软件,使得请求发送者与请求接收者能够消除彼此之间的耦合,让对象之间的调用关系更加灵活,可以灵活地指定请求接收者以及被请求的操作。

2.2 示例

2.2.1 示例1 按钮绑定执行的命令

原代码

// 接收者对象保持相同
const MenuBar = {
  refresh() {
    console.log('刷新菜单目录');
  }
};

const SubMenu = {
  add() {
    console.log('增加子菜单');
  },
  del() {
    console.log('删除子菜单');
  }
};

// 直接绑定接收者方法
document.getElementById('button1').onclick = () => MenuBar.refresh();
document.getElementById('button2').onclick = () => SubMenu.add();
document.getElementById('button3').onclick = () => SubMenu.del();

重构后的代码

看起来长了很多,但是也会带来很多好处,后面会讲

// 接收者对象(不变)
// 点击了按钮之后,必须向某些负责具体行为的对象发送请求,这些对象就是请求的接收者。、
// 但是目前并不知道接收者是什么对象,也不知道接收者究竟会做什么。此时我们需要借助命令对象的帮助,以便解开按钮和负责具体行为对象之间的耦合。
const MenuBar = {
  refresh() {
    console.log('刷新菜单目录'); // 动作执行的具体事务
  }
};

const SubMenu = {
  add() {
    console.log('增加子菜单'); // 动作执行的具体事务
  },
  del() {
    console.log('删除子菜单'); // 动作执行的具体事务
  }
};
// 命令基类(可选,用于规范接口)
class Command {
  execute() {
    throw new Error('必须实现execute方法');
  }
}

// 具体命令类
class RefreshMenuBarCommand extends Command {
  constructor(receiver) {
    super();
    this.receiver = receiver;
  }
  execute() {
    this.receiver.refresh();
  }
}

class AddSubMenuCommand extends Command {
  constructor(receiver) {
    super();
    this.receiver = receiver;
  }
  execute() {
    this.receiver.add();
  }
}

class DelSubMenuCommand extends Command {
  constructor(receiver) {
    super();
    this.receiver = receiver;
  }
  execute() {  // 修正原代码中直接写console的问题
    this.receiver.del();
  }
}
// 命令设置函数
const setCommand = (button, command) => {
  button.onclick = () => command.execute();
};

// DOM元素获取
const [button1, button2, button3] = ['button1', 'button2', 'button3']
  .map(id => document.getElementById(id));

// 创建命令实例
const commands = {
  refresh: new RefreshMenuBarCommand(MenuBar),
  add: new AddSubMenuCommand(SubMenu),
  del: new DelSubMenuCommand(SubMenu)
};

// 绑定命令到按钮
setCommand(button1, commands.refresh);
setCommand(button2, commands.add);
setCommand(button3, commands.del);

2.2.2 示例2 界面元素位置修改,支持撤销前一步的操作

原代码

  • 要实现撤销逻辑,需要声明一个层级很高的调用栈变量positionHistory,让外部状态管理与业务逻辑耦合
  • 无法处理复杂撤销逻辑(如格式修改)
  • 扩展新操作需要修改核心函数
const element = document.getElementById("box");
let positionHistory = [];

function moveTo(x, y) {
  positionHistory.push({x: element.offsetLeft, y: element.offsetTop});
  element.style.left = x + "px";
  element.style.top = y + "px";
}

function undoMove() {
  if (positionHistory.length > 0) {
    const pos = positionHistory.pop();
    element.style.left = pos.x + "px";
    element.style.top = pos.y + "px";
  }
}

// 使用示例
moveTo(100, 200);
moveTo(150, 250);
undoMove();  // 回到(100,200)

使用命令模式重构

  • 使用命令模式实现撤销,逻辑内聚得多!优雅!
class MoveCommand {
  constructor(element, newX, newY) {
    this.element = element;
    this.prevX = element.offsetLeft;
    this.prevY = element.offsetTop;
    this.newX = newX;
    this.newY = newY;
  }
  
  execute() {
    this.element.style.left = this.newX + "px";
    this.element.style.top = this.newY + "px";
  }
  
  undo() {
    this.element.style.left = this.prevX + "px";
    this.element.style.top = this.prevY + "px";
  }
}

class CommandManager {
  constructor() {
    this.history = [];
  }
  
  invoke(command) {
    command.execute();
    this.history.push(command);
  }
  
  undo() {
    const cmd = this.history.pop();
    if (cmd) cmd.undo();
  }
}

// 使用示例
const manager = new CommandManager();
const box = document.getElementById("box");

manager.invoke(new MoveCommand(box, 100, 200));
manager.invoke(new MoveCommand(box, 150, 250));
manager.undo();  // 撤销到(100,200)

2.2.3 示例3 批量文件上传

原代码

  • 队列管理与业务逻辑耦合
  • 无法实现暂停/恢复等高级控制
const uploadQueue = [];
let isUploading = false;

function addToQueue(file) {
  uploadQueue.push(() => {
    console.log(`Uploading ${file.name}`);
    return new Promise(resolve => 
      setTimeout(resolve, 1000));
  });
}

async function processQueue() {
  while (uploadQueue.length > 0) {
    isUploading = true;
    const task = uploadQueue.shift();
    await task();
  }
  isUploading = false;
}

// 使用示例
addToQueue({name: "file1"});
addToQueue({name: "file2"});
processQueue();  // 顺序上传文件

重构后的代码

  • 轻松实现暂停/恢复/优先级管理
  • 代码逻辑内聚!优雅!
class UploadCommand {
  constructor(file) {
    this.file = file;
  }
  
  execute() {
    return new Promise(resolve => {
      console.log(`Uploading ${this.file.name}`);
      setTimeout(resolve, 1000);
    });
  }
}

class CommandQueue {
  constructor() {
    this.queue = [];
    this.isProcessing = false;
  }
  
  add(command) {
    this.queue.push(command);
    if (!this.isProcessing) this.process();
  }
  
  async process() {
    this.isProcessing = true;
    while (this.queue.length > 0) {
      const cmd = this.queue.shift();
      await cmd.execute();
    }
    this.isProcessing = false;
  }
  
  pause() {
    this.isProcessing = false;
  }
}

// 使用示例
const queue = new CommandQueue();
queue.add(new UploadCommand({name: "file1"}));
queue.add(new UploadCommand({name: "file2"}));
queue.add(new UploadCommand({name: "file3"}));

// 可随时暂停队列
setTimeout(() => queue.pause(), 1500); 

2.3 回调函数 vs 命令模式

2.3.1 核心机制对比

维度命令模式封装回调函数
封装形式面向对象(类或对象)函数式(闭包/高阶函数)
生命周期管理显式对象生命周期控制依赖垃圾回收机制
状态携带能力内置属性存储执行状态(如prevText需外部变量捕获(闭包作用域)
接口统一性强制实现execute()等标准接口函数签名自由定义
序列化能力天然支持(对象可JSON序列化)需特殊处理(函数无法直接序列化)

2.3.2 适用场景

评估维度命令模式回调函数
指令复杂程度需要原子性操作(如银行转账:扣款+入账必须同时成功,需要组合多个命令,确保整体成功或回滚)简单事件处理(如按钮点击触发API调用)
是否需要进行指令管理支持无限级撤销/重做/保存操作记录(如Photoshop的20步历史记录)一次性操作(如表单校验成功后关闭弹窗)
性能敏感程度低频操作,避免频繁对象创建开销高频操作,直接传递函数指针,性能好

2.4 优缺点

2.4.1 优点

  1. 修改执行函数内部的话可以遵循开闭原则

以示例1代码作为举例。如果一个执行函数被很多回调事件都调用到了。使用命令模式的话,可以让你修改执行函数以后,对所有调用点都生效。

这时候就会有人说了,我用封装函数不一样能解决吗?但是,如果直接使用函数的话,修改执行函数,你就必须修改执行函数内部的逻辑,但是如果使用命令模式,就可以用继承的方式来修改执行函数(利用对象的特性)

  1. 函数 vs 对象:命令对象可以携带状态,而函数在封装时可能需要依赖外部变量或更复杂的状态管理

将命令转化为对象来管理,我认为是命令模式最大的不同,这样就可以在一个命令对象里面实现操作回放/撤销/录制等功能,也可以实现集中式的命令队列的管理。



3.观察者模式(发布订阅模式)

3.1 核心思想

通过 事件驱动 机制实现对象间的 松耦合协作

允许一个对象(发布者)动态维护一组依赖对象(订阅者),在其状态变化时自动通知所有订阅者并触发回调,从而将事件的 生产者(发布者)与 消费者(订阅者)解耦,实现 一对多 的异步消息传递

3.2 示例

示例1 用户登录后触发多系统更新

class LoginService {
  constructor() {
    this.userInfo = new UserInfoUI()
    this.logger = new LoginLogger()
    this.notifier = new EmailNotifier()
  }

  // 登录方法直接耦合所有后续操作
  async login(username, password) {
    const user = await api.login(username, password);
    
    // 必须手动调用每个依赖模块
    this.userInfo.update(user);  // 更新UI
    this.logger.log(user.id);    // 记录日志
    this.notifier.send(user.email); // 发送邮件
    
    return user;
  }
}

// 使用示例
const loginService = new LoginService();
loginService.login('admin', '123456');

  • 紧耦合:登录方法需要显式知晓所有后续操作
  • 难以扩展:新增操作(如添加积分系统)必须修改 login 方法
  • 维护困难:所有逻辑堆积在同一个方法中

使用观察者模式重构

// 事件中心(观察者模式核心)
class EventCenter {
  constructor() {
    this.events = {};
  }

  on(event, callback) {
    this.events[event] = this.events[event] || [];
    this.events[event].push(callback);
  }

  emit(event, data) {
    (this.events[event] || []).forEach(callback => callback(data));
  }
}

// 登录服务(只负责触发事件)
class LoginService {
  constructor(eventCenter) {
    this.eventCenter = eventCenter;
  }

  async login(username, password) {
    const user = await api.login(username, password);
    this.eventCenter.emit('login_success', user); // 发布事件
    return user;
  }
}

// 各模块独立订阅事件
const eventCenter = new EventCenter();

// 用户信息模块(观察者)
eventCenter.on('login_success', user => {
  new UserInfoUI().update(user);
});

// 日志模块(观察者)
eventCenter.on('login_success', user => {
  new LoginLogger().log(user.id);
});

// 通知模块(观察者)
eventCenter.on('login_success', user => {
  new EmailNotifier().send(user.email);
});

// 使用示例
const loginService = new LoginService(eventCenter);
loginService.login('admin', '123456');

  • 解耦架构:登录服务无需知晓具体有哪些后续操作
  • 动态扩展:新增积分系统只需添加新订阅,无需修改登录服务
  • 可维护性:每个模块只需关注自己的职责

3.3 直接调用 vs 发布订阅

3.3.1 核心机制对比

对比维度直接调用函数发布-订阅模式
耦合度高耦合:调用方需直接依赖被调用方的具体实现低耦合:发布者和订阅者仅依赖抽象的事件名称,不直接引用对方
依赖关系显式依赖:调用方必须知道被调用函数的存在隐式依赖:发布者无需知道订阅者的存在,通过事件中心间接通信
执行方式同步执行:立即触发函数逻辑可同步或异步:通过事件循环或消息队列实现灵活调度
扩展性差:新增功能需修改调用方代码强:新增订阅者无需修改发布者代码,符合开闭原则
适用场景简单的一对一调用、流程固定的场景一对多或多对多通信、动态协作场景(如跨模块交互、插件系统)
错误处理集中式:调用方可直接捕获异常分布式:需在订阅者内部处理错误,或通过全局错误事件统一处理
通信方向单向:调用方 → 被调用方多向:支持多发布者与多订阅者的复杂通信拓扑
动态性静态:依赖关系在编码时确定动态:运行时可动态添加/移除订阅关系
性能开销低:直接调用无额外开销中:事件分发、遍历订阅者列表需一定开销(高频场景需优化)
维护性难:调用链路复杂时难以追踪易:通过事件名称和日志可追溯流程

3.3.2 适用场景

评估维度直接调用发布订阅
通信复杂度低复杂度:一对一高复杂度:一对多 or 跨进程/组件通信
是否是高频事件高频事件(大于1000hz)低频事件(过高频内存不可控)
调用是否严格依赖顺序严格依赖顺序不严格依赖顺序
是否需要扩展功能不需要扩展功能需要扩展

解释

  1. 为什么观察者模式不适用于高频事件?

    可见观察者模式的实现方式代码,一个event实例中必然有一个类似于Map的数据结构来保存事件与回调的映射关系。高频事件会放大以下问题

    • 观察者列表膨胀:每个事件触发都需要遍历所有订阅者;
    • 临时对象创建:事件参数对象(如 { data })频繁分配内存,触发垃圾回收(GC)
    • 观察者模式的核心逻辑,是遍历订阅者列表并执行回调。当存在 1000个订阅者 且每秒触发 100次事件 时,每秒需要执行 100,000次回调。这种 O(n) 时间复杂度 在高频场景下呈线性性能劣化
    • 观察者模式默认 广播所有订阅者,但高频场景下可能存在大量无效通知:所有订阅者仍需处理事件,做if判断

3.4 优缺点

3.4.1 优点

  1. 可实现表示层和数据逻辑层的分离。它定义了稳定的消息更新传递机制,并抽象了更新接口,使得可以有各种各样不同的表示层充当具体观察者角色。
  2. 在观察目标和观察者之间建立一个抽象的耦合。观察目标只需要维持一个抽象观察者的集合,无须了解其具体观察者。由于观察目标和观察者没有紧密地耦合在一起,因此它们可以属于不同的抽象化层次。
  3. 支持广播通信。观察目标会向所有已注册的观察者对象发送通知,简化了一对多系统设计的难度。
  4. 观察者模式满足开闭原则的要求,增加新的具体观察者无须修改原有系统代码。在具体观察者与观察目标之间不存在关联关系的情况下,增加新的观察目标也很方便

3.4.2 缺点

  1. 性能问题: 3.3 节已经有概述
  2. 如果在观察者和观察目标之间存在循环依赖,观察目标会触发它们之间进行循环调用,可能导致系统崩溃。
  3. 观察者模式没有相应的机制让观察者知道所观察的目标对象是怎么发生变化的,而仅仅只是知道观察目标发生了变化。
  4. 无法实现各个订阅者执行的回调函数的顺序控制