前端开发中的命令模式——从点餐退单到异步任务调度

命令模式是前端进阶必须掌握的设计模式之一。它不像观察者或策略模式那样随处可见,但在编辑器、可视化搭建、异步任务调度等复杂场景中,几乎没有更优雅的替代方案。本文从一个最朴素的生活例子出发,逐步深入到编辑器的撤销/重做、可取消的异步任务调度、宏命令、播放器控制等真实场景,带你感受前端开发中的命令模式。

一、从一个饭店点餐的故事说起

假设你去饭店吃饭,真正给你做菜的人是厨师。但你总不能直接跑进后厨,挨个问"谁能给我炒个菜"吧?这时候服务员出现了------你告诉服务员你想吃什么,服务员帮你下单给厨师。如果菜还没做,你突然不想要了,也可以通过服务员取消订单。

这一套流程中:

  • 你(调用者) 不需要知道哪个厨师(接收者)会做你的菜。
  • 服务员(Invoker) 负责接收命令、传递命令、记录命令。
  • 菜单/订单(Command) 是请求的凭证,可以被记录、撤销。

这就是命令模式的核心思想:将请求封装成对象,消除调用者和接收者之间的耦合,并支持命令的记录与撤销。

二、命令模式的核心思想

将请求封装成对象,使发起请求和执行请求的双方解耦。每个命令对象都携带了执行和回滚所需的信息,可以被存储、排队、记录日志甚至远程传输。

关键点:

  • 解耦发起与执行:调用方不直接操作接收者,而是通过命令对象间接调用。
  • 可撤销 :命令对象保存执行前的状态或标识,支持 undo
  • 可组合:多个命令可以组合成宏命令批量执行。

如果面试官追问"和策略模式有什么区别",可以回答:策略模式关注"怎么处理"(选择算法),命令模式关注"做什么"(封装行为)。 命令对象可以存储、撤销、排队,策略对象只是可互换的计算逻辑。


三、场景1:一个最简单的命令模式 Demo------点餐系统

让我们用代码来模拟饭店的点餐和退单流程。

html 复制代码
<div id="app">
  <div class="commands-container">
    <h3>菜单列表</h3>
    <ul class="commands">
      <li cmd="xhscjd">西红柿炒鸡蛋</li>
      <li cmd="kgcjd">苦瓜炒鸡蛋</li>
      <li cmd="ljclj">辣椒炒辣椒</li>
      <li cmd="jdcjd">鸡蛋炒鸡蛋</li>
    </ul>
  </div>
  <button id="undo">撤销最后一个订单</button>
</div>
javascript 复制代码
// 服务员类(Invoker)------负责接收命令、执行命令、记录命令用于撤销
class Waiter {
  constructor(menu) {
    this.menu = menu;          // 菜单集合(Receiver)
    this.orderHistory = [];   // 记录已执行的命令,用于撤销
  }

  execute(type) {
    const order = this.menu[type].execute();
    this.orderHistory.push(order.id);
    console.log(`下单成功,订单ID:${order.id}`);
  }

  undo() {
    const id = this.orderHistory.pop();
    if (id) {
      console.log(`已撤销订单,订单ID:${id}`);
    } else {
      console.log('没有可以撤销的订单');
    }
  }
}

// 菜单集合(Receiver)------真正执行操作的对象
const Menu = {
  xhscjd: {
    execute() {
      console.log('厨师正在做:西红柿炒鸡蛋');
      return { id: 'data-' + Date.now() };
    }
  },
  kgcjd: {
    execute() {
      console.log('厨师正在做:苦瓜炒鸡蛋');
      return { id: 'data-' + Date.now() };
    }
  },
  ljclj: {
    execute() {
      console.log('厨师正在做:辣椒炒辣椒');
      return { id: 'data-' + Date.now() };
    }
  },
  jdcjd: {
    execute() {
      console.log('厨师正在做:鸡蛋炒鸡蛋');
      return { id: 'data-' + Date.now() };
    }
  }
};

// 使用:点餐
const waiter = new Waiter(Menu);

document.querySelectorAll('.commands > li').forEach(item => {
  item.addEventListener('click', function () {
    waiter.execute(this.getAttribute('cmd'));
  });
});

document.getElementById('undo').addEventListener('click', () => {
  waiter.undo();
});

这个 Demo 展示了命令模式最朴素的价值

  • 调用者(按钮点击事件)不需要知道厨师(Menu)的存在,只需要通过服务员(Waiter)下单。
  • 服务员记录了每一笔订单,可以随时撤销。
  • 如果要新增一道菜(比如"宫保鸡丁"),只需在 Menu 中加一个方法,完全不改调用方代码。

当然,你可能觉得这个需求用命令模式有点"杀鸡用牛刀"------如果不需要撤销功能,直接把按钮事件绑定到 Menu 的方法上就更简单。命令模式不是在所有场景下都必要的,只有在需要撤销、恢复、排队等操作时,它才真正发挥价值。 这就是设计模式的核心原则:基于需求合理使用,不要为了模式而模式。

四、场景2:编辑器的撤销与重做------命令模式最经典的应用

在富文本编辑器、在线表格、可视化搭建平台中,每一次用户操作都被封装成一个命令对象。Antv/X6 的 @antv/x6-plugin-history 插件就是命令模式的典型实现------它维护了 undoStackredoStack 两个命令栈,每个命令都实现了 executerevert 方法。

javascript 复制代码
// 命令基类
class Command {
  execute() { throw new Error('子类实现'); }
  undo()    { throw new Error('子类实现'); }
}

// 修改样式命令
class SetStyleCommand extends Command {
  constructor(receiver, selector, property, newValue) {
    super();
    this.receiver  = receiver;
    this.selector  = selector;
    this.property  = property;
    this.newValue  = newValue;
    this.oldValue  = null;       // 在 execute 时保存旧值,用于撤销
  }

  execute() {
    this.oldValue = this.receiver.getStyle(this.selector, this.property);
    this.receiver.setStyle(this.selector, this.property, this.newValue);
  }

  undo() {
    this.receiver.setStyle(this.selector, this.property, this.oldValue);
  }
}

// 插入文字命令
class InsertTextCommand extends Command {
  constructor(receiver, position, text) {
    super();
    this.receiver = receiver;
    this.position  = position;
    this.text      = text;
  }

  execute() {
    this.receiver.insert(this.position, this.text);
  }

  undo() {
    this.receiver.delete(this.position, this.text.length);
  }
}

命令历史管理器负责管理两个栈:

javascript 复制代码
class CommandHistory {
  constructor() {
    this.undoStack = [];
    this.redoStack = [];
  }

  execute(command) {
    command.execute();
    this.undoStack.push(command);
    this.redoStack = []; // 新操作清空重做栈
  }

  undo() {
    const command = this.undoStack.pop();
    if (command) {
      command.undo();
      this.redoStack.push(command);
    }
  }

  redo() {
    const command = this.redoStack.pop();
    if (command) {
      command.execute();
      this.undoStack.push(command);
    }
  }
}

使用方式

javascript 复制代码
const history = new CommandHistory();
const editor = new Editor();

// 用户修改样式
const cmd = new SetStyleCommand(editor, 'h1', 'color', 'red');
history.execute(cmd);   // 执行
history.undo();           // 撤销,样式恢复
history.redo();           // 重做,再次变红

核心价值 :新增一种操作类型(比如"锁定图层"),只需要新增一个 Command 子类,完全不影响历史管理逻辑。Antv/X6 的 History 插件正是这样设计的------Command 类定义了算法规范,各个具体的业务 Command 实现并重写它的方法,而 History 类统一管理命令栈。


五、场景3:可取消的异步任务调度------命令模式处理复杂异步

在做音乐审核后台时,需要批量缓存音频文件(每个文件 20 多 MB),用户希望一键缓存所有待审核的资源。这个需求涉及并发控制、优先级调整、中途取消------正是命令模式的绝佳用武之地。

可取消的任务设计

typescript 复制代码
// 取消原因哨兵对象,使得调用方可以明确知道是取消而非错误信息
const CancelledReason = Symbol('Async task has been cancelled');

interface CancellableTask<T> {
  action: (...args: any[]) => Promise<T>;
  cancel: () => void;
}

function createCancellableTask<T>(
  fn: (...args: any[]) => T | Promise<T>,
  ...args: any[]
): CancellableTask<T> {
  let trigger: ((reason?: any) => void) | null = null;
  let isCancel = false;

  const action = () => {
    if (isCancel) return Promise.reject(CancelledReason);
    return new Promise<T>((resolve, reject) => {
      trigger = reject;
      Promise.resolve(fn(...args))
        .then(resolve)
        .catch(reject);
    });
  };

  const cancel = () => {
    isCancel = true;
    typeof trigger === 'function' && trigger(CancelledReason);
  };

  return { action, cancel };
}

异步任务调度器

typescript 复制代码
class AsyncTaskScheduler<T> {
  private runningTask = 0;
  private maxTask = 5;
  private asyncTaskQueue: AsyncTaskNode<T>[] = [];

  add(cancellableTask: CancellableTask<T>, position: 'push' | 'unshift' = 'push') {
    return new Promise((resolve, reject) => {
      if (this.runningTask < this.maxTask) {
        this.runningTask++;
        this.runTask(cancellableTask.action, resolve, reject);
      } else {
        this.asyncTaskQueue[position]({ resolve, reject, cancellableTask });
      }
    });
  }

  addFirst(cancellableTask: CancellableTask<T>) {
    return this.add(cancellableTask, 'unshift');
  }

  private runTask(action, resolve, reject) {
    action()
      .then(res => { this.runningTask--; resolve(res); this.run(); })
      .catch(err => { this.runningTask--; reject(err); this.run(); });
  }

  private run() {
    while (this.asyncTaskQueue.length && this.runningTask < this.maxTask) {
      const task = this.asyncTaskQueue.shift()!;
      this.runningTask++;
      this.runTask(task.cancellableTask.action, task.resolve, task.reject);
    }
  }
}

核心价值:命令模式让异步任务不再是"即发即忘"的调用,而变成了可存储、可取消、可排队的对象。取消操作不会侵入业务代码------业务函数仍然保持原子性,取消只是让调度器不再处理这个任务的结果。

六、场景4:播放器控制------命令模式解耦 UI 与逻辑

在音乐播放器应用中,用户通过按钮控制播放、暂停、上一首、下一首。命令模式将按钮与播放器逻辑解耦:

javascript 复制代码
class PlayCommand {
  constructor(player) { this.player = player; }
  execute() { this.player.play(); }
}

class PauseCommand {
  constructor(player) { this.player = player; }
  execute() { this.player.pause(); }
}

class Button {
  constructor(command) { this.command = command; }
  onClick() { this.command.execute(); }
}

const player = new Player();
const playButton = new Button(new PlayCommand(player));
const pauseButton = new Button(new PauseCommand(player));

playButton.onClick(); // 播放音乐
pauseButton.onClick(); // 暂停音乐

核心价值:按钮不需要知道播放器的具体实现,只需要持有一个命令对象。同一个命令对象可以被多个 UI 入口复用------键盘快捷键、菜单项、工具栏按钮都可以共享同一个命令实例,保持行为一致。

七、什么时候不该用命令模式?

命令模式的核心价值在于可撤销、可排队、可记录。如果没有这些需求,使用命令模式可能就是"把简单的事复杂化"。

比如上面点餐的例子,如果不需要撤销订单,直接把按钮事件绑定到 Menu 的方法上就更简单:

javascript 复制代码
// 简单场景:不需要命令模式
document.querySelectorAll('.commands > li').forEach(item => {
  item.addEventListener('click', function () {
    Menu[this.getAttribute('cmd')].execute();
  });
});

一句话原则:只有当需要撤销、恢复、记录日志、任务排队等功能时,命令模式才真正发挥价值。不要为了模式而模式。

八、总结

命令模式在现代前端中的主要应用场景:

场景 核心价值
图编辑器撤销/重做 每个操作都是独立对象,自带执行和回滚逻辑
Canvas 绘图命令 不同图形 API 封装成命令,统一管理执行与撤销
异步任务调度 支持并发控制、优先级、可取消
宏命令 / 批量操作 多个命令打包成一次操作,统一回滚
播放器/菜单控制 UI 与业务逻辑解耦,同一命令可被多个入口复用
Redux action 将"发生了什么"封装为纯数据对象

命令模式的核心思想是将"行为"封装为"值"------一个命令对象就是一段可以被传递、存储、排队、记录日志的"待执行的行为"。当你需要撤销、恢复、排队等功能时,命令模式是最优雅的解决方案;当你只是想把一个按钮绑定到一个函数上时,直接绑定就好,别多想。

相关推荐
慢功夫1 小时前
💡第四篇:编辑器内核, Monaco model、URI、dirty 状态和 tabs 是怎么协作的?
前端·visual studio code
小马过河R1 小时前
微前端实践:从概念、原理到 qiankun 落地
前端·人工智能·前端框架
用户059540174461 小时前
AI记忆存储一致性踩坑实录:每天手工回归200条对话,直到我用pytest+Redis搭建自动化验证
前端·css
weedsfly1 小时前
前端中的适配器模式 vs 外观模式
前端·javascript·面试
图扑软件1 小时前
其域创新 × 图扑软件|3DGS 高斯泼溅智慧钢厂实景孪生
前端·低代码·3d·编辑器·图形渲染·可视化
咖啡八杯1 小时前
GoF设计模式——责任链模式
设计模式·面试·架构
labixiong1 小时前
写给前端的 Generator 指南:从 yield 到 async/await 的完整进化
前端·javascript·前端框架
asdzx671 小时前
使用 Spire.WordJS 在 React 中实现在线 Word 文档编辑
前端·react.js·word
大大大大晴天1 小时前
聊聊Flink的几种血缘方案
前端·flink