Appearance
5.4 命令模式 (Command Pattern)
命令模式是一种非常强大的行为型设计模式,它的核心思想是将一个“请求”封装成一个独立的对象。这样做可以将“请求的发起者”和“请求的执行者”彻底解耦。
核心定义:将一个请求封装为一个对象,从而允许你使用不同的请求、队列或者日志来参数化客户端,同时支持可撤销的操作。
一、生活中的场景:餐厅的点餐流程
把请求封装成对象?听起来很抽象。让我们来看一个再熟悉不过的场景:
- 你 (Client / 客户端):走进餐厅,想点一份“宫保鸡丁”。这是请求的发起者。
- 厨师 (Receiver / 接收者):他是真正会做“宫保鸡丁”的人。这是请求的执行者。
- 服务员 (Invoker / 调用者):你把点餐请求告诉服务员。
- 订单 (Command / 命令对象):服务员不会直接跑去厨房对厨师大喊。他会把你“一份宫保鸡丁”的请求,写在一张标准化的订单上,然后把订单贴在厨房的窗口。
这张“订单”就是命令模式的精髓!
- 请求被对象化:你的口头请求被封装成了一个实体——“订单”对象。
- 解耦:
- 服务员和厨师解耦:服务员不需要关心是哪个厨师(张三还是李四)来做这道菜。他只负责把“订单”放在指定位置。
- 你和厨师解耦:厨师也不知道是你点的餐。他只看“订单”,根据订单内容做菜。
- 可传递与排队:订单可以在服务员手中传递,可以在厨房窗口排队。
- 可记录:餐厅可以保存所有订单,用于日后统计和记账。
命令模式,就是要把代码中的“方法调用”,变成一张张可以传来传去、可以排队、可以记录的“订单”。
二、没有命令模式的代码:紧密耦合的 UI 和业务
假设我们正在开发一个简单的文本编辑器,有两个按钮,一个用于“增加粗体”,一个用于“增加斜体”。
javascript
// --- 业务逻辑 (接收者) ---
class TextEditor {
addBold() {
console.log("文本已加粗");
}
addItalic() {
console.log("文本已倾斜");
}
}
// --- UI (调用者) ---
class Button {
constructor(label) {
this.label = label;
}
}
// --- 客户端组装 ---
const editor = new TextEditor();
const boldButton = new Button("Bold");
const italicButton = new Button("Italic");
// 问题所在:UI 事件监听器必须直接知道并依赖具体的业务对象 (editor)
document.getElementById("bold-btn").addEventListener("click", () => {
editor.addBold(); // UI 与业务逻辑紧密耦合
});
document.getElementById("italic-btn").addEventListener("click", () => {
editor.addItalic(); // UI 与业务逻辑紧密耦合
});这种代码非常常见,但它的问题是:UI 事件处理逻辑和具体的业务 TextEditor 类紧紧地绑在了一起。如果未来 TextEditor 的方法名变了,或者我们要让 boldButton 去调用另一个完全不同的业务对象,就必须修改这个事件监听回调函数。
三、使用命令模式重构
我们的目标是:让按钮 (Invoker) 不再知道 TextEditor (Receiver) 的存在,它们之间的桥梁是“命令对象”。
第 1 步:定义命令接口和具体命令
javascript
// 命令接口 (约定)
class Command {
execute() {
throw new Error("子类必须实现 execute 方法");
}
}
// 具体命令:加粗命令
class BoldCommand extends Command {
constructor(receiver) {
super();
this.receiver = receiver; // 持有对接收者(厨师)的引用
}
execute() {
// 封装了真正的业务调用
this.receiver.addBold();
}
}
// 具体命令:斜体命令
class ItalicCommand extends Command {
constructor(receiver) {
super();
this.receiver = receiver;
}
execute() {
this.receiver.addItalic();
}
}第 2 步:改造调用者 (Invoker)
让 Button 接收一个命令对象,并在被点击时执行它。
javascript
class Button {
constructor(command) {
this.command = command; // 按钮只知道自己有一个“命令”
}
// 按钮被点击时,执行命令
onClick() {
this.command.execute();
}
}注意:现在的 Button 类完全不知道 TextEditor 的存在!
第 3 步:客户端组装
javascript
const editor = new TextEditor(); // 我们的厨师
// 创建订单
const boldCommand = new BoldCommand(editor);
const italicCommand = new ItalicCommand(editor);
// 创建服务员,并把订单交给他
const boldButton = new Button(boldCommand);
const italicButton = new Button(italicCommand);
// 绑定事件
document.getElementById("bold-btn").addEventListener("click", () => {
boldButton.onClick(); // 按钮只负责执行命令,不关心命令是什么
});
document.getElementById("italic-btn").addEventListener("click", () => {
italicButton.onClick();
});通过重构,UI (Button) 和业务 (TextEditor) 彻底解耦。我们可以轻易地给 boldButton 换一个完全不同的 Command,而无需修改 Button 类的任何代码。
进阶:实现撤销 (Undo)
命令模式的强大之处在于,封装命令对象可以携带更多的信息和行为,比如“撤销”。
javascript
class BoldCommandWithUndo extends Command {
constructor(receiver) {
super();
this.receiver = receiver;
this.previousState = ""; // 用于保存之前的状态
}
execute() {
// 在执行前,保存当前状态
this.previousState = this.receiver.getTextState();
this.receiver.addBold();
}
undo() {
// 恢复到之前的状态
this.receiver.setTextState(this.previousState);
}
}现在,我们只需要一个命令历史栈,就可以轻松实现撤销/重做功能了。
四、优缺点与适用场景
优点
- 核心优点:解耦。完美地解耦了请求的发起者和执行者。
- 高扩展性:可以非常容易地增加新的命令,而无需修改现有代码,符合开放/封闭原则。
- 支持复合命令:可以将多个命令组合成一个“宏命令”。
- 易于实现撤销/重做:命令对象可以方便地存储状态,用于撤销操作。
- 支持队列和异步:命令对象可以被存储在队列中,用于实现任务排队、日志记录、异步执行等。
缺点
- 类膨胀:可能会导致系统中出现大量的、细粒度的命令类,增加系统的复杂性。
适用场景
- UI 按钮与业务逻辑分离:任何需要将 UI 交互与具体业务操作解耦的场景。
- 实现撤销/重做 (Undo/Redo):文本编辑器、绘图软件等。
- 实现任务队列:将请求封装成命令对象,放入队列中,由工作线程依次取出执行。
- 记录日志和事务:可以将执行过的命令序列化并保存下来,用于恢复或审计。