恒美微站 Logo 恒美微站
  • 首页
  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心
  • 联系我们

命令模式实战:用双栈实现撤销重做,告别按钮管太宽

  • 首页
  • 资讯中心
  • /
  • 命令模式实战:用双栈实现撤销重做,告别按钮管太宽

相关资讯

从上下文混乱到清晰:Context Mode 显式上下文管理实战 2026/10/18 9:26:46
coco-cn中文多模态数据集实战:跨语言图像描述与检索指南 2026/10/18 9:26:46
AI问诊系统设计方案:架构、知识库与合规落地 2026/10/18 9:21:46

最新资讯

Intel Visual Fortran 安装配置与编译调试避坑指南
基于YOLOv8的交通信号灯识别实战:数据增强与部署优化
YOLO信号灯识别实战:从模型训练到边缘部署全流程解析
GEE批量下载Sentinel-1影像的工程化方案
【Web全栈进阶】前端对接新契约:信封解包 + 统一错误处理
语义寻址与信任证明:为什么必须拆成两个Scheme?

今日推荐

基于GMM的语音识别系统原理与MATLAB实现详解
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

本周热门

基于GMM的语音识别系统原理与MATLAB实现详解
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

本月精选

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)

命令模式实战:用双栈实现撤销重做,告别按钮管太宽

发布时间:2026/10/18 9:26:46
命令模式实战:用双栈实现撤销重做,告别按钮管太宽 最近帮同事收拾一个老项目的烂摊子核心问题就一句话按钮的点击事件里写了太多不该它管的事。界面上七八个按钮每个按钮的点击回调都把校验、数据组装、持久化、UI刷新全揉在一起。产品提了个很常见的要求——给某个操作加撤销功能结果一排查根本无从下手因为操作不是一个独立的东西它只是回调函数里的一串代码。这种情景就是典型的“请求的发送者和接收者耦合太紧”。而解决这个问题最经典、也最实用的招就是命令模式。说白了命令模式就是给每个操作造一个对象让这个对象自己知道怎么执行、怎么撤销。按钮只管按下去具体发生了什么、该怎么补偿全交给命令自己处理。这就像给操作装了个“后悔药”撤回不再是改代码而是把之前的命令对象拿出来反向执行一遍。这篇文章我就带你从头到尾把这个模式讲透它到底在解决什么问题核心的三个角色各自干嘛怎么用双栈实现撤销和重做以及我在真实项目里踩过的坑和积累下来的排查习惯。全程用能直接“抄作业”的代码示例不整虚的。1. 命令模式到底在解决什么问题1.1 三个角色一台戏先抛开术语用生活里的场景帮助理解。你去餐厅吃饭流程是这样的你对着菜单点菜服务员把订单传给后厨厨师按照订单做菜。这里面有三个角色菜单上的菜品描述就是“命令”它把“顾客想要什么”变成了一张可传递的纸服务员就是“调用者”他只负责接收订单、传给后厨不关心菜怎么做厨师就是“接收者”真正干活的人知道怎么把菜做出来。把这三个角色对应到代码里就是命令模式的核心结构Command抽象命令定义执行动作的接口通常包含 execute() 和 undo() 两个方法ConcreteCommand具体命令实现 Command 接口内部持有接收者对象并实现具体的执行和撤销逻辑Receiver接收者真正执行业务逻辑的对象比如文档、数据库、业务服务Invoker调用者持有命令对象并触发执行比如按钮、菜单项、工具栏按钮它完全不关心命令内部做了什么。这里最关键的一点调用者不直接调用接收者的方法而是调用命令对象的 execute()。命令对象就像一个中间信封把“某个时刻对某个对象做某个操作”这件事完整地封装起来了。1.2 设计意图把“请求”变成“头等公民”为什么非得把请求封装成对象因为只有把请求变成对象你才能对这个请求做各种各样的事情存起来、排队、撤销、记录下来、组合起来、甚至通过网络传到别处执行。如果不封装请求就是代码里的一串函数调用你拿它没办法。打个比方函数调用就像你当面让人帮忙做事做完了就完了没有凭据。命令对象则像一张盖了章的工单上面写清楚谁来做、做什么、怎么做。有了这张工单你可以在它执行前审查执行后归档甚至后悔了可以照着工单反着做一遍把事儿撤回。撤销功能的实现逻辑就是这样来的每次执行一个命令就把这个命令对象压入“撤销栈”需要撤销时从栈里弹出最近一次的命令对象调用它的 undo() 方法。所以命令模式的核心价值可以概括成四个字解耦和可追踪。2. 按钮代码是怎么“管太宽”的方案选型要看清需求2.1 没有命令模式的按钮是什么样的我在实际项目里见过无数种“按钮管太宽”的写法简化一下大概是这个画风public void onSaveButtonClick() { // 1. 表单校验 if (!validateForm()) { showError(表单不完整); return; } // 2. 组装数据 SaveRequest request buildRequest(); // 3. 调用业务层 SaveResult result saveService.save(request); // 4. 写日志 logger.info(保存成功: {}, result.getId()); // 5. 刷新界面 refreshPage(); // 6. 发送通知 notifyOtherModule(); }这段代码刚写完时非常爽需求全都满足了。可问题在于这个按钮的回调函数成了“上帝函数”它什么都干而且这些逻辑永远无法被复用、被替换、被撤销。两周后产品来了个需求“保存按钮误点了要能撤销”。你怎么做你没法做。因为保存这个动作不是一个可逆的对象它是函数里六个步骤的“合体”没有一个东西能代表“保存操作”本身。你想撤销只能重新写一遍“反向保存”的逻辑还得小心翼翼地处理各种状态。这就是“按钮管得太宽”的代价按钮本来只应该负责“被点击后触发点什么”结果它连业务逻辑、校验规则、数据组装全包圆了。2.2 到底什么时候该用命令模式命令模式不是银弹别所有地方都上。我一般用这个判断清单来决定是否值得引入判断问题如果答案是“是”这个操作需要支持撤销/重做吗强烈建议用命令模式这个操作需要记录日志、回放、审计吗强烈建议用命令模式这个操作需要进入队列、按顺序调度吗建议用命令模式多个操作需要组合成一个“宏操作”吗建议用命令模式调用方和执行方需要完全解耦吗例如按钮和业务模块分属不同团队建议用命令模式只是一个简单的“点击就执行”的同步调用没有后续需求不需要直接写回调就行回到标题那句话——“别让你的按钮管得太宽”。按钮类、菜单项这类 UI 组件本质上是“触发者”不是“执行者”。如果业务逻辑注定要变、要撤销、要组合那按钮就不应该持有具体业务对象它应该只持有一个命令对象。按钮点击时调用 command.execute()命令内部再去跟接收者打交道。2.3 别把命令模式用错地方命令模式也有被滥用的时候。我见过有人把所有 Service 方法都包一层 Command结果代码里到处是 Command、CommandFactory、CommandManager调试的时候跳来跳去把简单的项目弄得极其复杂。这里要泼一盆冷水如果你的需求只是“点按钮调一个接口不需要撤销、不需要排队、不需要回放”那就老老实实直接调用。命令模式的本质是增加一层间接性带来灵活性的同时也带来了额外的类和跳转收益小于成本的时候就别硬套。好的架构不是为了炫技是为了让改代码的人不骂娘。3. 实操实录给文本编辑器装上撤销和重做3.1 定义命令接口和接收者这个场景我会用一个最经典的例子做一个极简文本编辑器支持插入文本、删除文本并且要能撤销、重做。先定义一个命令接口// 命令接口所有操作都要实现执行和撤销 public interface Command { void execute(); void undo(); }接着定义一个接收者文档对象。它负责管理文本内容并且暴露插入和删除的方法public class Document { private StringBuilder content new StringBuilder(); public Document(String initialContent) { content.append(initialContent); } public void insert(int position, String text) { if (position 0 || position content.length()) { throw new IllegalArgumentException(插入位置越界); } content.insert(position, text); } public void delete(int start, int end) { if (start 0 || end content.length() || start end) { throw new IllegalArgumentException(删除区间不合法); } content.delete(start, end); } public String getContent() { return content.toString(); } }这个 Document 就是真正的“干活的厨师”它只有最基础的能力插入、删除。至于什么时候插入、插入什么内容、插错了怎么撤回这些都不是 Document 关心的。3.2 实现具体命令现在来实现两个具体命令插入命令和删除命令。这是命令模式最见功力的时候——execute() 和 undo() 最好是对称的。插入命令public class InsertTextCommand implements Command { private Document document; private int position; private String text; public InsertTextCommand(Document document, int position, String text) { this.document document; this.position position; this.text text; } Override public void execute() { document.insert(position, text); } Override public void undo() { // 插入的逆向操作把插入的文本删掉 document.delete(position, position text.length()); } }删除命令会稍微复杂一点因为删除之前要先把被删掉的内容备份下来否则撤销时不知道该恢复什么public class DeleteTextCommand implements Command { private Document document; private int start; private int end; private String deletedText; public DeleteTextCommand(Document document, int start, int end) { this.document document; this.start start; this.end end; } Override public void execute() { // 执行前先记录被删除的内容这是撤销的关键 deletedText document.getContent().substring(start, end); document.delete(start, end); } Override public void undo() { // 删除的逆向操作把删掉的内容放回原位 document.insert(start, deletedText); } }这里有一个特别重要的细节我要多说一句删除命令在 execute() 之前必须把被删掉的文本保存下来。这是命令模式里“状态快照”的思想雏形——允许恢复的一个前提是你得留得住历史。3.3 调用者和撤销管理器有了命令接下来需要一个“按钮”来承接用户的点击。按钮类非常简单只认识 Command 接口public class Button { private String name; private Command command; public Button(String name) { this.name name; } public void setCommand(Command command) { this.command command; } public void onClick() { if (command ! null) { command.execute(); } } public String getName() { return name; } }但光有按钮还不够撤销和重做需要一套“栈”机制来管理命令的来龙去脉。这里就是最有实操价值的双栈结构import java.util.ArrayDeque; import java.util.Deque; public class CommandHistory { private DequeCommand undoStack new ArrayDeque(); private DequeCommand redoStack new ArrayDeque(); // 执行一条命令同时清空重做栈 public void execute(Command command) { command.execute(); undoStack.push(command); redoStack.clear(); } public void undo() { if (undoStack.isEmpty()) { return; } Command command undoStack.pop(); command.undo(); redoStack.push(command); } public void redo() { if (redoStack.isEmpty()) { return; } Command command redoStack.pop(); command.execute(); undoStack.push(command); } public boolean canUndo() { return !undoStack.isEmpty(); } public boolean canRedo() { return !redoStack.isEmpty(); } }把整个过程串起来跑一遍public class EditorDemo { public static void main(String[] args) { Document doc new Document(Hello World); CommandHistory history new CommandHistory(); Button insertButton new Button(插入按钮); Button deleteButton new Button(删除按钮); // 在位置5处插入一个逗号 InsertTextCommand insertCmd new InsertTextCommand(doc, 5, ,); insertButton.setCommand(insertCmd); insertButton.onClick(); System.out.println(doc.getContent()); // Hello, World // 删除位置5到6的内容 DeleteTextCommand deleteCmd new DeleteTextCommand(doc, 5, 6); deleteButton.setCommand(deleteCmd); deleteButton.onClick(); System.out.println(doc.getContent()); // Hello World // 撤销删除 - Hello, World history.execute(deleteCmd); // 等一下这里重复执行了 } }注意上面这个示例我故意写了个经常犯的错误history.execute(deleteCmd)会再次执行 deleteCmd然后再把删除命令压栈。在实际项目里正确的流程应该是// 正确的用法所有命令都通过 history 统一执行不能直接调 onClick 后又调 history.execute history.execute(new InsertTextCommand(doc, 5, ,)); history.execute(new DeleteTextCommand(doc, 5, 6)); System.out.println(doc.getContent()); // Hello World history.undo(); // 撤销删除 - Hello, World history.undo(); // 撤销插入 - Hello World history.redo(); // 重做插入 - Hello, World这里有个特别容易踩的坑按钮的 onClick 里如果直接调用了 command.execute()那么跟 CommandHistory 就两套执行入口了命令到底要不要入栈、何时入栈很容易失控。我建议在项目里定死一个规矩所有命令的执行统一走 CommandHistory.execute()按钮只是触发 history.execute(某命令)不直接调命令的 execute()。这样撤销栈才能始终和用户的实际操作保持一致。3.4 新需求加个“插入签名”按钮代码要怎么动命令模式的好处要在需求变化时才能充分体会。假设产品过来说“我要加一个按钮点击后自动在文档末尾追加一行签名--- written by admin”。没有命令模式时你要打开按钮点击事件再加一段业务代码。有了命令模式之后逻辑完全变了public class InsertSignatureCommand implements Command { private Document document; public InsertSignatureCommand(Document document) { this.document document; } Override public void execute() { int position document.getContent().length(); document.insert(position, \n--- written by admin); } Override public void undo() { int position document.getContent().indexOf(\n--- written by admin); if (position 0) { document.delete(position, position \n--- written by admin.length()); } } }然后只需要Button signatureButton new Button(插入签名); signatureButton.setCommand(new InsertSignatureCommand(doc));按钮的实现、命令历史的实现、接收者的实现全都不用改。这就是开闭原则的直观体验新增操作不是去修改现有代码而是新增一个命令类然后注入到调用者里。对已有代码的破坏降到了最低测试范围也很好圈定。4. 从编辑器到业务系统命令模式的进阶用法4.1 宏命令与组合命令按钮功能越来越多用户开始提“一键排版”这种需求先删除多余空格再设置缩进然后加粗标题。如果每个动作都是一个命令那“一键排版”就是一组命令的组合。宏命令应运而生import java.util.ArrayList; import java.util.List; public class MacroCommand implements Command { private ListCommand commands new ArrayList(); public void addCommand(Command command) { commands.add(command); } Override public void execute() { for (Command command : commands) { command.execute(); } } Override public void undo() { // 撤销时务必要逆序执行 for (int i commands.size() - 1; i 0; i--) { commands.get(i).undo(); } } }组合命令有一个特别容易忽略的细节撤销的顺序必须和执行顺序相反。比如先执行 A 再执行 B撤销时就得先撤 B 再撤 A。如果顺序反了结果几乎必错。这个“栈式回溯”的思路在事务系统里到处都能见到。4.2 命令日志与“回放”机制命令对象既然能被存起来自然也能被写进日志。把一个系统的操作记录成一条条命令将来可以被回放。举个最典型的场景某资产管理系统的业务员批量调价操作员执行了 20 次调价操作其中第 15 次调价的参数填错了。但系统只记录了新的价格没记录操作过程想恢复只能靠手工反向调价。如果所有调价操作都是命令对象而且命令携带了完备的执行参数那么系统只要把命令列表持久化就能精确知道哪一条命令出了问题甚至可以通过命令重放把数据库重建到任意时间点的状态。实现思路不算复杂命令接口里加一个序列化方法比如toLog()把命令的关键参数转成可存储的字符串系统启动时读取日志反向恢复出命令对象再按顺序执行。这里要注意命令里不能持有无法序列化的对象比如数据库连接、网络会话。这也是我为什么建议命令的构造参数尽量用简单类型和值对象别一上来就塞个 Service 进去否则回放时你会非常难受。4.3 命令队列与异步调度命令对象还可以被丢到队列里异步执行。比如桌面应用里用户点了“导出报表”这个操作可能耗时十几秒如果直接在 UI 线程里执行界面会卡死。正确的做法是把导出操作封装成命令对象扔进后台线程池执行UI 线程立刻返回显示“导出中”。命令对象之所以适合这种场景正是因为它是一个完整的、自包含的“任务单元”——接收者是谁、参数是什么、如何执行全都打包好了。在这个场景里命令模式是在为“延迟执行”和“任务调度”服务的。你的线程池不需要知道任务是导出报表还是发送邮件它只知道每个任务都实现了 Command 接口拉出来调用 execute() 就行。这一点和 JDK 里的 Runnable、Callable 思想完全一致——把逻辑打包成对象然后交给执行框架去跑。5. 我在实际项目中踩过的坑和排查清单5.1 撤销栈与重做栈的经典雷区第一个坑重做栈没有清空。用户执行了 A、B、C 三个操作然后撤销了 C此时如果你没有清空重做栈用户又执行了新操作 D那重做按钮还留着 C。这时候按重做系统就会把 C 重新执行一遍但是用户已经跳到新分支了界面状态就会很诡异。所以 CommandHistory 里每一处新执行命令的地方都必须redoStack.clear()。我在文章前面给的代码里加了这一行这个就是保命的一行。第二个坑撤销和重做的栈方向搞反。有人用队列实现撤销栈结果撤销时拿的是最旧的操作根本不是最后一次操作整个体验就完全乱了。撤销必须用栈后进先出最后一次执行的操作总是最先被撤销。重做则正好相反是把最近撤销的操作恢复。这两个栈的 push 和 pop 时机最好画个图自己推一遍别想当然。5.2 命令对象的内存与状态问题命令对象不是省油的灯它内部可能持有大量状态。我见过最夸张的写法每条命令都把整个文档快照保存一份用户输入几十个字内存里就堆了几十份全文文档大的时候内存直接爆掉。解决思路有两个方向一是缩小命令粒度把“快照”改成“增量”。插入命令只需要记录插入位置和插入的文本删除命令只需要记录开始位置和删除的文本不需要保存完整状态。这是大多数编辑器撤销功能的做法。二是限制历史栈的容量。业务系统的撤销栈不能无限增长用一个固定容量比如 50超过就丢弃最老的命令。或者对连续同类型的操作做合并——比如用户持续输入文本可以把 1 秒钟内的多次插入合并成一条宏命令这样输入五百个字只占用一条历史记录。第三个状态问题命令共享可变数据时存在线程安全隐患。如果多个线程同时操作同一个 Document 接收者那么命令之间就会互相干扰。解决方案要么给接收者的关键方法加锁要么让所有命令都提交到同一个单线程队列里执行保证同一时刻只有一个命令在操作接收者。5.3 调试命令模式的实用小技巧命令模式把逻辑分散到了很多小类里调试起来比单体函数麻烦。我自己积累了几个小技巧能显著降低排查成本第一给每条命令加上描述字段。把关键的参数信息拼成字符串比如InsertTextCommand[pos5, text,]。这样无论打印日志还是在调试器里观察历史栈你一眼就能看出当前执行的是哪条命令参数是什么。可以说这是我用下来性价比最高的技巧。第二刻意在 undo() 里打印日志。很多时候撤销失败是因为 execute() 和 undo() 不对称参数不一致。把执行和撤销的操作都打出来对比一下两者操作的位置和内容问题往往立刻能定位。第三把 CommandHistory 做成可观察的组件。历史栈变化时派发通知UI 可以根据 canUndo() 和 canRedo() 来自动置灰“撤销”按钮和“重做”按钮。这个不仅提升体验也方便测试时确认状态机的正确性。常见问题排查方向撤销后界面没变检查 undo() 是否真正执行了逆操作确认命令是否真的压入撤销栈撤销一次撤掉了多个操作检查是否把多条逻辑放在同一个命令的 execute() 里重做按钮不可用确认执行新操作时是否误清了 redo 栈执行后历史栈为空检查调用者是否把命令通过 CommandHistory 执行撤销后状态错乱检查命令是否持有了过期的状态或者共享对象被并发修改6. 一路踩坑过来的经验复盘讲到这里命令模式的核心已经被拆得差不多了。最后分享几个我在项目里一直坚持的习惯给正准备下手重构按钮逻辑的同学一点参考。第一命令接口不要一上来就定义一堆方法。最简版本只需要 execute()用到撤销再加 undo()。我曾经见过有人一上来就设计了 five 六个方法后来一大半从没用过。接口的方法越少实现类就越容易写也越容易被复用。第二命令的参数尽量从构造器传入不要用 setter 后来塞。因为命令对象经常要入栈、出栈、排队、序列化参数如果在构造之后还能被随意修改鬼知道执行时用的是哪一份值。不可变命令对象是更好的设计也让并发环境下的安全性有了基础保障。第三关于命名我习惯用“动词 名词 Command”的结构比如 SaveOrderCommand、ApproveLeaveCommand。这样读代码时看到命令列表就像在读操作日志可读性会高很多。老实说命令模式是设计模式里为数不多“学完马上能提高幸福感”的模式。它不是高深莫测的玄学就是把“操作”这东西老老实实地变成一个能传递、能存储、能反悔的对象。如果你现在手上正好有一个按钮回调函数写得比我这篇文章还长不妨挑一个操作试一试把它改造成命令对象再挂到按钮上。你会发现产品再提出“误点了能不能撤回”这种需求时你不再头疼而是能淡定地说一句早给你准备好了。

关于恒美微站

恒美微站专注于为个体商户、工作室提供极简自助建站服务,让每个人都能轻松拥有专业网站。

快速链接

  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心

服务项目

  • 可视化建站
  • 拖拽编辑
  • 主题定制
  • SEO 优化
  • 网站托管

联系方式

  • 📍 地址:北京市朝阳区建国路 88 号
  • 📞 电话:400-888-8888
  • ✉️ 邮箱:info@hmyw.cn
  • 🕐 时间:周一至周日 9:00-18:00

© 2024 恒美微站 hmyw.cn 版权所有 | 京 ICP 备 12345678 号