- 文档
- 技术博客
- 教程
【免费下载链接】weekly
前端精读周刊。帮你理解最前沿、实用的技术。
本篇基于前端精读周刊中《设计模式 - Command 命令模式》一文展开。命令模式属于行为型设计模式,核心思想是"把一次请求封装成一个对象",从而获得延迟执行、请求排队、日志记录、撤销重做四大能力。读完本篇,你将理解命令模式的经典四角色结构(Command / ConcreteCommand / Receiver / Invoker),掌握用 TypeScript 手写命令队列的方法,并能在实际前端场景(如浏览器请求排队、编辑器撤销重做、可视化搭建引擎)中判断何时该用、何时不该用。
一、什么是命令模式:意图速览
命令模式(Command Pattern)属于行为型模式,关注的是对象之间如何分配职责与协作。它的经典意图是:
将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化,对请求排队或记录请求日志,以及支持可撤销的操作。
这句话可以拆成四个关键词来理解:
- 参数化:请求变成了对象,就可以作为参数传递、存储,甚至放进集合里管理;
- 排队:对象可以按序入队,由统一调度者按顺序执行,而不是"点击立刻执行";
- 记录日志:因为请求被序列化成了对象,自然可以记录下"发生了什么操作";
- 可撤销:只要在命令对象里同时保存"正向操作"和"反向操作"信息,就能实现撤销(Undo)与重做(Redo)。
二、三个生活中的例子:先建立直觉
设计模式必须落到日常工作中才有意义。原文用三个例子帮你建立直觉,这里逐一展开。
2.1 点菜是命令模式
顾客为什么找服务员点菜,而不是直接冲到后厨盯着厨师做菜?因为做菜慢,必然出现排队;而且有些菜合在一起做效率更高。把"点菜"和"做菜"分离,就比较容易控制整体效率。
映射到编程领域:
- 点菜= 一个个请求;
- 菜单= 将请求生成的对象(记录点了什么、分量多少、口味如何);
- 点菜员= 只负责记录,不需要关心怎么做菜、谁来做;
- 后厨= 统一调度者,按照菜单顺序、依赖关系安排烹饪。
菜单就是"请求快照":顾客点了菜之后,即使过一会儿才轮到做这道菜,后厨依然能根据菜单完整还原顾客的意图——这正是请求被序列化后"延迟执行"的价值。
2.2 大型软件系统的操作菜单
大型软件系统非常复杂、菜单按钮非常多,但按钮本身没有任何业务逻辑。如果让每个按钮直接写死一段业务行为,按钮会和业务逻辑高度耦合,一旦业务变化就要改动大量按钮代码。
命令模式的解法是:按钮点击后生成一个或一系列指令(命令对象),由软件系统的实现部分统一接收并真正执行。按钮只负责"发出指令",不负责"执行指令",实现了 UI 层与业务层的解耦。
2.3 浏览器请求排队
浏览器的请求不仅会排队,还会取消、重试,这是一个典型的命令模式场景。原文指出:
如果不能将
window.fetch序列化为一个个指令放入到队列中,是无法实现请求排队、取消、重试的。
为什么?因为window.fetch一旦发出就是"立即执行、无法回头"的。只有把每个请求先封装成描述性的命令对象(URL、参数、优先级、重试次数……),塞进一个请求队列统一调度,才能在合适的时机发出、在中途取消、在失败后重试。
三、意图解释:从"直接实现"到"序列化请求"
理解了例子,再回看意图就顺畅多了。所谓"请求",指的是来自客户端的一个操作,比如菜单按钮点击。关键在于:点击后并不立即实现逻辑,而是把请求封装为一个对象。
改造前——点击事件里直接写实现逻辑:
function onClick() { // ... balabala 实现逻辑 }改造后——点击事件里生成命令对象并推入队列:
function onClick() { concreteCommand.push({ // ... 描述这个请求 }) // 执行所有命令队列 concreteCommand.executeAll() }表面上看繁琐了一些,但换来了巨大的灵活性:
可以对任何请求进行参数化存储,我们可以在任意时刻调用。
这相当于掌握了执行时机:
- 可以排队:把命令收集起来,等合适的时机(如请求空闲、用户确认、批量合并)再执行;
- 可以记录日志:命令对象天然是"操作记录",可用于审计与回放;
- 可以撤销重做:只要在命令中再记录下反向操作的信息,就可以实现。
四、结构图与四角色拆解
命令模式的标准结构包含四个参与角色(结构图见原文):
- Command(命令接口):所有命令的统一抽象,一般固定有一个
execute方法; - ConcreteCommand(具体命令):命令接口的实现,它会注入具体执行者
Receiver,其execute方法内部实际调用receiver.execute来完成真正的业务动作; - Receiver(接收者):真正干活的对象,知道如何执行与请求相关的操作;
- Invoker(调用者/请求发起者):负责"推入命令"与"触发执行"。前面所有步骤都在推入命令,并没有真正执行;当排队结束、或点击撤销/重做时,才由 Invoker 触发对应 Command 的
execute。
一句话概括调用链:Invoker 持有命令队列 → 触发 execute → 每个 ConcreteCommand 调用其内部 Receiver 的 execute → Receiver 完成真实业务。
五、TypeScript 代码例子:手写一个命令队列
原文用 TypeScript 给出了最小可运行示例,这里在保留原文骨架的基础上补全细节,使其可直接运行。
5.1 最终执行态:先添加命令,再执行命令
const command1 = new Command('balabala1') const command2 = new Command('balabala2') const invoker = new Invoker() invoker.push(command1) invoker.push(command2) invoker.execute()使用模式非常清晰:先 push(入队),再 execute(统一执行)。
5.2 Invoker:用队列维护命令,for 循环执行
class Invoker { commands: Command[] = [] push(command: Command) { // 队列里推入命令 this.commands.push(command) } execute() { this.commands.forEach(command => command.execute()) // 别忘了清空 this.commands this.commands = [] } }两个实现要点:
execute内部其实是for循环依次调用每个command.execute();- 别忘了在 execute 之后清空
this.commands——否则同一次排队被重复执行,会造成命令的重复触发。
5.3 补全 Command 与 Receiver:更贴近标准结构
原文为了简洁直接new Command(...),更贴近四角色标准的写法是让Command持有Receiver:
// Receiver:真正干活的执行者 class Receiver { execute(task: string) { console.log(`执行任务:${task}`) } } // Command:命令接口 interface Command { execute(): void } // ConcreteCommand:命令实现,注入 Receiver class ConcreteCommand implements Command { private receiver: Receiver private task: string constructor(receiver: Receiver, task: string) { this.receiver = receiver this.task = task } execute() { this.receiver.execute(this.task) } } // Invoker:调用者,维护命令队列 class Invoker { private commands: Command[] = [] push(command: Command) { this.commands.push(command) } execute() { this.commands.forEach(command => command.execute()) this.commands = [] } } // 使用:注入同一个 Receiver,排队两条命令 const receiver = new Receiver() const invoker = new Invoker() invoker.push(new ConcreteCommand(receiver, 'balabala1')) invoker.push(new ConcreteCommand(receiver, 'balabala2')) invoker.execute() // 输出: // 执行任务:balabala1 // 执行任务:balabala2对比原文可见:命令模式的核心骨架就是"命令入队 → 统一触发 → 命令内部转交 Receiver",具体的任务描述(task字段)与执行者(Receiver)都可以被参数化注入,这正是"用不同的请求对客户进行参数化"的落地方式。
六、命令模式在前端工程中的真实应用
命令模式不是教科书上的摆设,它在真实前端工程中有大量落地。在本仓库的《数据搭建引擎 bi-designer API-设计器》一文中,就有直接证据:搭建系统的撤销重做功能,正是把"所有值得撤销重做的操作"交给引擎内部的HistoryManager管理,业务侧只需调用undo()、redo()即可:
import { useDesigner } from '@alife/bi-designer' export default () => { const { undo, redo } = useDesigner() // 撤销调用 undo() // 重做调用 redo() }对应的《可视化搭建内置 API》一节也明确了内置 API 的约定:
undo()/redo():类型() => void,用于撤销与重做;- 配套的
canUndo/canRedo内置状态:如果提供了这两个状态,就一定要提供对应的undo()/redo()内置函数。
这段工程实践恰好印证了命令模式的核心价值:引擎内部知道每一个可被撤销/重做的操作(即命令),外部调用方只需要触发统一的入口函数,而不需要了解命令的内部细节——这正是指令被封装成对象后,"记录操作 + 延迟执行 + 可撤销"三合一的最佳注脚。
再结合本仓库对备忘录模式的解读(见《设计模式 - Memoto 备忘录模式》),你会发现:命令模式负责"记录操作本身",备忘录模式负责"保存操作前后的状态快照",两者经常组合使用——前者回答"做了什么操作",后者回答"如何恢复到某个状态"。
七、弊端与取舍:序列化大小与适用场景
命令模式并非银弹,原文明确指出两个需要注意的弊端。
7.1 命令序列化的三种粒度
命令模式需要注意序列化大小,一般分为三种:
- 仅记录操作:只保存"做了什么"(如"移动组件 5px"),体积最小,最精细;
- 记录全量快照:保存整个对象/系统的完整状态,实现简单但体积大;
- 全量快照共享内存:在快照模式下通过共享引用来降低内存开销。
取舍要点:
- 记录操作是较为精细的管理方式,并且可以延伸出协同编辑功能——多人编辑时,只需要同步"操作序列"而不是同步全量状态;
- 记录快照要注意尽量共享内存,防止快照过大(这与 Immutable 数据结构的结构共享思路一致);
- 协同编辑场景下,快照模式无法应用,因为快照无法做冲突处理——两个人同时改动时,全量快照无法合并,只能靠细粒度的操作指令做冲突合并。
7.2 识别没必要使用命令模式的场景
原文的告诫非常务实:
对于没有撤销重做的前端大部分场景来说,都无需改为命令模式。
如果你的页面只是"点击 → 立即执行",既不需要排队、也不需要撤销重做、更不需要记录操作日志,那么引入命令模式只会增加一层无谓的抽象与序列化开销。模式服务于需求,而不是为了用模式而用模式。
八、总结
命令模式本质上就是将操作抽象为可序列化的命令,使操作可以在合适的时间执行。这种"推迟执行"的设计,带来了许多额外好处:
- 高内聚、低耦合:UI 层(Invoker)只负责发命令,业务层(Receiver)只负责执行命令,两者通过 Command 接口解耦,各自独立演进;
- 可维护性提升:新增一种操作,只需新增一个 ConcreteCommand 类,无需改动 Invoker 与既有命令;
- 功能性需求:排队执行、操作日志、撤销重做、协同编辑,都是"命令可序列化"这一前提的自然产物。
一句话记忆:"先记住要做什么,再在合适的时机去做"——这就是命令模式。
本文章节内容基于《设计模式 - Command 命令模式》原文整理扩充;仓库中的设计模式模块还收录了状态模式、策略模式、备忘录模式等行为型模式,可对照阅读,体会不同行为型模式在"解耦与复用"上的不同切分角度。
- 文档
- 技术博客
- 教程
【免费下载链接】weekly
前端精读周刊。帮你理解最前沿、实用的技术。
相关推荐
java-design-patterns 中的 Command 命令模式:请求对象化与可撤销操作实战指南
java design patterns 中的 Command 命令模式:请求对象化与可撤销操作实战指南 导读 本文以 java design patterns
示例工程教程Unity3DTraining 命令模式(Command Pattern)实战解析:请求封装、队列编排与撤销重做
Unity3DTraining 命令模式(Command Pattern)实战解析:请求封装、队列编排与撤销重做 导读 本文以 DesignPatterns/C
示例工程别再手动点了:青龙面板 API 批量运维全流程
别再手动点了:青龙面板 API 批量运维全流程 凌晨两点,一批定时任务要在整点前停掉,你只能打开青龙面板的网页一个个点;想改脚本里的账号配置,又要翻到环境变量页
任务调度后端前端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考