深入理解JavaScript设计模式之命令模式
定义
命令模式也是设计模式种相对于变焦简单容易理解的一种设计模式。
在
JavaScript
中,命令模式用于将一个请求或简单操作封装为一个对象。这使得你可以使用不同的请求、队列请求或者记录请求日志、撤销操作等。命令模式通常用于实现诸如撤销/重做功能、事务系统以及在复杂对象间传递请求等场景。
白话说就是:有时候需要向某些对象发送请求,但是并不知道请求的接收者是谁,也不知道被请求的操作是什么。使得请求发送者和请求接收者能够消除彼此之间的耦合关系,命令模式还支持撤销、排队等操作。
简单命令模式
定义很难了解命令模式的用处,举个开灯关灯的命令模式例子,如下,定义了两个LightOnCommand
与LightOffCommand
两个命令分别执行light
对象中的on
与off
方法,在new LightOnCommand
的时候将light作为参数传入并执行LightOnCommand.execute();
的方法实现开灯关灯的操作。
<body>
<button id="btn">按钮</button>
</body>
<script>
/**
* 点击按钮,执行开灯关灯的操作
*/
const light = {
on() {
console.log("开灯");
},
off() {
console.log("关灯");
},
};
class LightOnCommand {
constructor(light) {
this.light = light;
}
execute() {
this.light.on();
}
}
class LightOffCommand {
constructor(light) {
this.light = light;
}
execute() {
this.light.off();
}
}
const onLight = new LightOnCommand(light);
const offLight = new LightOffCommand(light);
let isOn = true;
document.getElementById("btn").addEventListener("click", function () {
(isOn ? onLight : offLight).execute();
isOn = !isOn;
});
</script>
一开始学的时候觉得这就是脱裤子放屁多此一举,但是仔细还差与思考,使用这种方式,可以让代码更加模块化与更容易维护。
- 解耦:调用者和接收者之间解耦,调用者不需要知道接收者的具体实现。
- 扩展性:可以很容易地添加新的命令而不需要修改现有的类。
- 可撤销操作:可以通过记录命令的历史来实现撤销操作。
- 队列请求:可以将命令存储在队列中,按顺序执行。
- 日志记录:可以记录命令的历史,便于调试和回溯。
组合命令模式
第一个简单的例子可以看到点击按钮只执行了一次命令,如果有多条命令,那就可以将多个命令添加到Command
里的一个stack
数组中,最后执行Command.execute
的时候遍历stack
数组中的命令统一遍历执行。
页面中有一个按钮 #btn
,当点击按钮时,依次执行以下三个命令:
- 开灯(LightOnCommand)
- 工人开始工作并停止(WorkerCommand)
- 关灯(LightOffCommand)
这些命令被添加到一个 Command
对象中,并在点击事件发生时统一执行,代码如下:
<body>
<button id="btn">按钮</button>
</body>
<script>
class Command {
constructor() {
this.stack = [];
}
add(command) {
this.stack.push(command);
}
execute() {
this.stack.forEach((command) => command.execute());
}
}
const light = {
on: () => console.log("开灯"),
off: () => console.log("关灯"),
};
const worker = {
do: () => console.log("开始工作"),
stop: () => console.log("停止工作"),
};
class WorkerCommand {
constructor(worker) {
this.worker = worker;
}
execute() {
this.worker.do();
this.worker.stop();
}
}
// 命令拆分
class LightOnCommand {
constructor(light) {
this.light = light;
}
execute() {
this.light.on();
}
}
class LightOffCommand {
constructor(light) {
this.light = light;
}
execute() {
this.light.off();
}
}
const command = new Command();
command.add(new LightOnCommand(light));
command.add(new WorkerCommand(worker));
command.add(new LightOffCommand(light));
document.getElementById("btn").addEventListener("click", () => {
command.execute();
});
</script>
这种写法的优点:
- 解耦调用者与执行者 按钮点击事件(调用者)并不直接调用
light.on()
或worker.do()
,而是交给命令对象去处理。 这样使得界面逻辑和业务逻辑分离,提高了可维护性。- 易于扩展新的命令 如果需要新增功能,比如“打开风扇”或“播放音乐”,只需要定义一个新的命令类并加入命令队列即可,不需要修改已有代码。 符合 开放封闭原则
(OCP)
:对扩展开放,对修改关闭。- 支持组合命令
Command
类中的stack
可以保存多个命令,可以轻松实现宏命令(一组命令的集合),如示例中的一键执行开灯、工作、关灯等操作。 后续也可以支持撤销/重做等功能(只需记录历史栈)。- 便于测试与复用 每个命令是独立的对象,可以单独测试其
execute()
方法。 命令可以在不同上下文中复用,例如在定时器中触发、远程调用等。- 提升代码可读性和结构清晰度 将每个操作抽象为类,有助于理解意图
(Intent)
。 比如看到new LightOnCommand(light)
,就知道这是“开灯”的命令,比直接调用函数更具语义化。
总的来说:通过组合命令模式可以实现良好的职责分离,灵活扩展和统一控制,如果需求遇到了对多个操作进行封装调度记录和撤销的时候,可以使用组合命令实现。
使用命令模式实现文本编辑器
如下举例加深命令模式的使用,如下我想实现一个文本编辑器,其中功能有【清空内容
、转为大写
、转为小写
、撤销
、重做
、指令列表
】
目标
实现一个基于命令模式的文本编辑器,具备【清空内容
、转为大写
、转为小写
、撤销
、重做
、指令列表
,显示每一步操作的命令记录
】
关键类说明
Editor
(接收者):
class Editor {
constructor() {
this.content = "";
}
}
存储当前文本内容,所用命令的实际执行者。
TextChangeCommand
(基础命令)
class TextChangeCommand {
constructor(editor, newText) {
this.editor = editor;
this.newText = newText;
this.previousText = editor.content;
}
execute() {
this.editor.content = this.newText;
}
undo() {
this.editor.content = this.previousText;
}
}
表示每次文本输入变更的操作,记录修改前后的状态,支持撤销。
CommandManager
(扩展命令)
class CommandManager {
constructor() {
this.tack = [];
}
execute(command) {
if (command) {
this.tack.push(command);
command.execute();
updateUI();
}
}
// 清空
redo() {
this.tack = [];
updateUI();
}
// 撤销
undo() {
if (this.tack.length > 0) {
const command = this.tack.pop();
command.undo();
updateUI();
} else {
console.log("没有可撤销的命令");
updateUI();
return;
}
}
// 查看命令列表
getTackList() {
return this.tack;
}
}
使用栈(tack)
保存所有已执行命令,提供 execute()
、undo()
、redo()
、getTackList()
方法,控制整个命令流程。
UpperCaseCommand
(命令管理器)
class UpperCaseCommand {
constructor(editor) {
this.editor = editor;
this.previousText = editor.content;
this.newText = editor.content.toUpperCase();
}
execute() {
this.editor.content = this.newText;
}
undo() {
this.editor.content = this.previousText;
}
}
将文本转为大写的命令,同样支持撤销,可以继续扩展更多命令如 LowerCaseCommand
,ClearCommand
等。
实现的效果
交互逻辑流程
- 初始化
创建Editor
和CommandManager
,设置初始文本为空,绑定DOM
元素(如textarea
、按钮)。 - 用户操作触发命令,输入文字 → 触发
input
事件 → 创建TextChangeCommand
→ 执行并入栈
点击按钮(清空、大写、小写)→ 创建对应命令 → 执行并入栈。 - 撤销 / 重做,“撤销”点击 → 从栈中弹出最后一个命令 → 调用
.undo()
,“重做”点击 → 清空栈(当前简单实现) 当前重做只是清空栈,没有真正实现“恢复撤销”的动作,可进一步改进。
所有代码:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>命令模式文本编辑器</title>
<style>
body {
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: linear-gradient(45deg, #ff6b6b, #c471ad);
font-family: Arial, sans-serif;
}
.editor-container {
width: 300px;
background: white;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.header {
background: #2c3e50;
color: white;
text-align: center;
padding: 10px 0;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
}
.content {
padding: 20px;
height: 150px;
border-bottom: 1px solid #ddd;
}
.buttons {
display: flex;
justify-content: space-around;
padding: 10px;
}
.buttons button {
padding: 8px 15px;
border: none;
border-radius: 3px;
cursor: pointer;
}
.buttons .primary {
background: #3498db;
color: white;
}
.buttons .secondary {
background: #ecf0f1;
color: #333;
}
#contentText {
border: none;
height: 150px;
width: 100%;
}
#tackListView {
border: none;
height: 130px;
width: 100%;
}
</style>
</head>
<body>
<div class="editor-container">
<div class="header">命令模式文本编辑器</div>
<div class="content">
<!-- 文本编辑区域 -->
<textarea id="contentText"></textarea>
</div>
<div class="buttons">
<button class="primary" id="clearBtn">清空内容</button>
<button class="primary" id="upperBtn">转为大写</button>
<button class="primary" id="lowerBtn">转为小写</button>
</div>
<div class="buttons">
<button class="secondary" id="undoBtn">撤销</button>
<button class="secondary" id="redoBtn">重做</button>
<button class="secondary" id="stackList">指令列表</button>
</div>
<div class="content">
命令列表:
<textarea id="tackListView"></textarea>
</div>
</div>
<script>
class TextChangeCommand {
constructor(editor, newText) {
this.editor = editor;
this.newText = newText;
this.previousText = editor.content;
}
execute() {
this.editor.content = this.newText;
}
undo() {
this.editor.content = this.previousText;
}
}
class CommandManager {
constructor() {
this.tack = [];
}
execute(command) {
if (command) {
this.tack.push(command);
command.execute();
updateUI();
}
}
// 清空
redo() {
this.tack = [];
updateUI();
}
// 撤销
undo() {
if (this.tack.length > 0) {
const command = this.tack.pop();
command.undo();
updateUI();
} else {
console.log("没有可撤销的命令");
updateUI();
return;
}
}
// 查看命令列表
getTackList() {
return this.tack;
}
}
class UpperCaseCommand {
constructor(editor) {
this.editor = editor;
this.previousText = editor.content;
this.newText = editor.content.toUpperCase();
}
execute() {
this.editor.content = this.newText;
}
undo() {
this.editor.content = this.previousText;
}
}
// 接收者
class Editor {
constructor() {
this.content = "";
}
}
// 初始化
const editor = new Editor();
const commandManager = new CommandManager();
// DOM元素
const textarea = document.getElementById("contentText");
// 设置初始内容
editor.content = textarea.value;
// 事件监听
textarea.addEventListener("input", function () {
const command = new TextChangeCommand(editor, textarea.value);
commandManager.execute(command);
});
document
.getElementById("clearBtn")
.addEventListener("click", function () {
const command = new TextChangeCommand(editor, "");
commandManager.execute(command);
});
document
.getElementById("upperBtn")
.addEventListener("click", function () {
const command = new UpperCaseCommand(editor);
commandManager.execute(command);
});
document
.getElementById("lowerBtn")
.addEventListener("click", function () {
const command = new TextChangeCommand(
editor,
textarea.value.toLowerCase()
);
commandManager.execute(command);
});
document.getElementById("undoBtn").addEventListener("click", function () {
commandManager.undo();
});
document.getElementById("redoBtn").addEventListener("click", function () {
const command = new TextChangeCommand(editor, "");
commandManager.execute(command);
commandManager.redo();
});
document
.getElementById("stackList")
.addEventListener("click", function () {
console.log(commandManager.getTackList());
});
// 更新UI
function updateUI() {
// 更新主文本区域
textarea.value = editor.content;
// 获取命令列表显示区域
const tackListView = document.getElementById("tackListView");
// 获取当前命令栈
const commands = commandManager.getTackList();
// 格式化命令记录
let logText = "";
for (let i = 0; i < commands.length; i++) {
const cmd = commands[i];
if (cmd instanceof TextChangeCommand) {
logText += `${i + 1}. 文本修改为: ${cmd.newText}\n`;
} else if (cmd instanceof UpperCaseCommand) {
logText += `${i + 1}. 转为大写: ${cmd.newText}\n`;
}
}
// 如果没有命令,显示提示信息
if (commands.length === 0) {
logText = "暂无命令记录";
}
// 更新命令列表显示区域
tackListView.value = logText;
}
// 初始化UI更新
updateUI();
</script>
</body>
</html>
总结
设计模式不是“炫技”,而是"沉淀",希望通过阅读和学习《JavaScript设计模式》和实践中,在显示业务需求开发中写出更具有可维护性,可扩展性的代码。
致敬—— 《JavaScript设计模式》· 曾探