LogicFlow 工作流撤销与重做:从「全量快照」到「命令模式」🎯

写在开头

👻 Hello,下午好呀各位!

今是2026年04月05日,此时此刻心情和这天气一样,灰蒙蒙的,果真清明时节雨纷纷,这雨就一直没停过,有点难受。

然后,最近要开始操心家里房子装修的事情,自建房,也算是靠自己双手把儿时的梦想敲出来了,时间定于04月22日,希望开工大吉,顺顺利利。🎉

还有就是虽然天一直下雨,但是也是抽空出门"散散步",就是散得有点远,也就十公里,然后就是现在腿有点一瘸一瘸的,晚上得泡泡脚了。😂

言归正传,本文将分享小编 基于 LogicFlow 的流程图编辑器 里,撤销(Ctrl+Z)与重做(Ctrl+Shift+Z) 从第一版 快照方案命令模式 的演进。

若你也在画布类库里做可撤销编辑,或许能对照着少踩坑,嘿嘿。

需求背景

先把「这个编辑器在干啥」从简单到复杂捋一下,方便后文说为啥撤销/重做会难:

  • 最基础 :画布上可以 创建 / 删除 / 移动 节点。

  • 再往上 :节点与节点之间可以 连线(谁连谁、边增删都会驱动业务逻辑)。

  • 业务以节点为单位:小编的业务是每个节点一般会选一个模型、输入该模型的提示词、配置模型参数等。

    大概交互是这样子:

    连线之后,下游节点可以在提示词等富文本里,用 @ 引用上游节点(例如引用上游产出的图片、文案)。连线断了或删了,这些引用也要跟着更新,不能各干各的。

  • 打组 :多个节点还可以收成一组统一管理(打组的情况可以看看小编写的另一篇文章:传送门)。

  • 再往上:支持从剪贴板批量粘贴文本、图片、视频、音频等到画布(或直接粘贴到节点内);另有批量删除等,这类操作往往是一口气多步,撤销时用户也期望一步就全回去。

以上能力叠在一起,就不只是「画布上多几个方块、几条线」了:还要和后端做数据同步,节点之间还要靠事件总线(例如 MITT)互相通知「谁连上谁了、谁断开了」。所以撤销/重做在工程里其实是两件事要对齐:

  • 画布上 LogicFlow 里的节点和边;
  • 业务里 边列表 / 节点关系等是否与画布一致。

听起来就是「回到上一步」,但只要有一环没跟上,用户就会遇到很直观的问题,例如:

  • 线已经没了 (或节点删了),但下游 富文本里 @ 出来的那一段引用 还在,看起来像还能引用,其实已经不对了;
  • 或者 引用已经在数据里清掉了 ,界面上一闪又对上了 旧内容(常见是异步更新顺序打架);
  • 再或者:代码里为了性能 自己缓存了一份连线关系 ,撤销后 缓存和画布对不上,别的功能读缓存就错了。

所以,选快照还是选命令、撤销栈怎么设计,直接决定了后面要不要到处打补丁,维护成本差很多。

💡 小贴士 :如果你做的画布 只负责展示 、没有后端、没有「连线要驱动别的 UI」这类副作用,lf.render(graphData) 配合快照往往够用。一旦像上面这样绑了同步和事件,就要想清楚:撤销时,谁负责把业务侧一起改回去

快照法(Memento)

第一版用的是最常见的思路:每次操作之后保存一份完整的 graphData 快照 ,撤销时用 LogicFlow 的 lf.render() 把整份数据灌回去。

优点也得承认:实现快,不用为每种操作写反向逻辑,状态长啥样拍个照就行。

但坑很快就来了。

第1️⃣步:认清 render() 的脾气

lf.render() 本质是全量重绘,它不会像用户正常操作那样依次触发 edge:addedge:deletenode:add 这类细粒度事件。

结果呢?所有挂在节点增删、边增删上的业务逻辑------事件总线通知、下游引用列表、本地缓存的连线关系------在撤销/重做时统统缺席。

于是只能在各种 syncBackend、历史回调里手动补副作用,像打补丁一样,越补越心累。😅

第2️⃣步:业务越长,补丁越厚

功能一多,撤销后还要手动 emit 一下这种代码就会扩散到各处。

新人接手时也很难一眼看出:某次 render 到底缺了哪条业务链,全靠口口相传或者出了 bug 才发现。

为啥必须重新换个思路?🤔

一句话:快照记录的是状态是什么,而业务要的是发生了什么

只要全量 render 绕开事件系统,可维护性和可扩展性都会顶在天花板上。

再用大白话记一笔:快照法像给当前状态拍张照;命令模式像记一笔笔操作流水。

落到咱们业务上,后者更好讲清楚、也好测------多一种能力,多半就是多一个命令类,而不是在 render 之后又去补一坨补丁。

这块小编也不是拍脑袋定的。重构前俺翻了不少资料、对照了一些常见编辑器/画布产品在「撤销重做」上的做法。

在可编辑画布、文档编辑、低代码编排这类既要可逆、又要不断加新操作的场景里,命令模式是非常常见、也很成熟的一条路线------当然不是宇宙唯一真理,但对操作语义清楚、反向逻辑能写清楚、后面还要持续扩展的项目,它往往是最省心的选型之一,就是说选它,没错。✅

命令模式(Command Pattern)

新方案的核心就一句话:

不记状态长啥样,而记用户做了哪一步操作,并且每一步自己负责正向执行和反向撤销。

每个命令在 execute() / undo()(必要时还有 redo())里,把三件事打包在一起:

javascript 复制代码
execute() / undo()
  ① 画布操作    → lf.addNode / lf.deleteEdge / ...
  ② 后端同步    → nodeStore / edgeStore / ...
  ③ 业务副作用  → emitter.emit(NODE_CONNECTED / DISCONNECTED) / referenceMap / ...

这样撤销时走的是精确的反向操作,而不是整页重画 + 猜副作用,事件链和业务表现自然能对齐。

这里用了一些项目中的伪代码,可能有点突兀,但核心只要记住: 一个命令的正向和反向,都要把画布操作 + 后端同步 + 业务副作用三件事一起打包 ------这正是命令模式区别于快照法的关键。

第1️⃣步:搭骨架

工程里新增了 commands/ 目录,核心就四块:

  • BaseCommand :抽象基类,约定 execute / undo / redo 三个方法。
  • CommandHistory :维护 undoStack / redoStack,替代旧的快照 historyStore
  • BatchCommand :把多条子命令合成一次原子操作(粘贴一堆节点+边,一次 Ctrl+Z 全部回退)。
  • createCommandContext :把 emitter、各业务 stores 等注入给所有命令,避免到处传参。
  • ...

边、节点、属性、复合操作(打组、拆组、粘贴等)各自拆成独立命令文件,加一种操作 = 加一个类 + 在入口接一下,扩展路径就清晰多了。

第2️⃣步:executerecord 分工

不是所有交互都适合先造命令再执行。

比如拖拽移动,画布已经更新了,历史里只需要 record 这次变化即可。

CommandHistory 里大致这样区分:

方式 典型场景
execute(cmd) 操作还没发生,由命令里真正去调 LF API
record(cmd) 操作已在画布发生,只入栈、不再 execute
undo() / redo() 弹栈并调用命令的反向/正向逻辑

第3️⃣步:和键盘、粘贴等入口对齐

各种交互入口------键盘快捷键、画布事件监听、右键菜单、复制粘贴------逐步从旧快照 + render迁到命令入栈。

像批量删除这类多选场景,用 beginBatch / endBatch 包一层,保证和用户预期一致:一次 Ctrl+Z = 一整批删除一起回滚

核心代码长什么样?

下面几段把小编项目里的实现 抽掉业务细节、保留最能说明命令模式在干啥的骨架,重写成最小可读版。

方法名和执行顺序与真实代码保持一致,只是省略了校验和分支,方便你照着在自己项目里落地。读完这几段,比只看 execute / undo 这种文字描述要直观得多。

快照 vs 命令:两行心智模型

javascript 复制代码
快照:  history.push(cloneDeep(lf.getGraphData())) // 记「整图长啥样」
       撤销 → lf.render(history.pop()) // 整图重灌,事件链容易断

命令:  history.push(cmd) // 记「用户干了啥」
       撤销 → cmd.undo() // 精确反向:画布 + 数据 + 事件

命令基类:只约定三件事

核心就是 正向 execute、反向 undo、默认 redo()execute(),基类约定好这三件事,剩下的交给子类:

javascript 复制代码
export class BaseCommand {
  constructor(context) {
    this.context = context; // getLF、emitter、stores、helpers...
  }
  execute() {
    throw new Error("子类必须实现 execute()");
  }
  undo() {
    throw new Error("子类必须实现 undo()");
  }
  redo() {
    this.execute();
  }
}

一条具体命令:以「加边」为例

以加边为例,把 画布 + 数据 + 事件 三层缩成最小可读版,方便你感受命令内部要做哪几件事:

javascript 复制代码
import { BaseCommand } from "../BaseCommand";
import { cloneDeep } from "lodash-es";

export class AddEdgeCommand extends BaseCommand {
  constructor(context, edgeData) {
    super(context);
    this.edgeData = cloneDeep(edgeData);
  }

  execute() {
    const lf = this.context.lf;
    lf.addEdge(this.edgeData); // ① 画布:加边
    this.context.dataApi.saveEdge(this.edgeData); // ② 数据:落库 / 同步
    this.context.bus.emit("edge:connected", this.edgeData); // ③ 事件:通知下游
  }

  undo() {
    const lf = this.context.lf;
    lf.deleteEdge(this.edgeData.id); // ① 画布:删边
    this.context.dataApi.removeEdge(this.edgeData.id); // ② 数据:移除
    this.context.bus.emit("edge:disconnected", this.edgeData); // ③ 事件:通知断开
  }
}

说明:上面 dataApi / bus 都是占位,换成你项目里实际的数据层和事件总线即可,重点是看出一条命令把三层操作打包在了一起。

关键点 :撤销时走的不是 render(旧图),而是 deleteEdge + 同步删数据 + 发断开事件。

所以监听边增删的业务逻辑和正常用户操作走的是同一条路,不需要在 render 之后再到处打补丁。

历史栈:execute / record / undo

历史栈(通常用一个 store 管理)的主路径可以理解成下面这样,真实代码还会有「正在执行中」的防重入标志、变更通知、批量模式等,这里只保留主线:

javascript 复制代码
function execute(command) {
  command.execute(); // 先真正执行
  undoStack.push(command);
  redoStack.length = 0; // 新分支后重做栈清空
}

function record(command) {
  // 拖拽、resize 等:画布已经变了,只入栈,不再 execute
  undoStack.push(command);
  redoStack.length = 0;
}

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

function redo() {
  const command = redoStack.pop();
  command.redo(); // 默认即再 execute 一遍
  undoStack.push(command);
}

批量:多条命令 = 一次撤销

BatchCommand 把子命令顺序 execute、逆序 undo

配合 beginBatch / endBatch,粘贴多节点+多边时,栈里只有一条BatchCommand,用户一次 Ctrl+Z 全部回滚:

javascript 复制代码
// commands/BatchCommand.js 核心逻辑
export class BatchCommand extends BaseCommand {
  constructor(context, commands = []) {
    super(context);
    this.commands = commands;
  }
  execute() {
    for (let i = 0; i < this.commands.length; i++) this.commands[i].execute();
  }
  undo() {
    for (let i = this.commands.length - 1; i >= 0; i--) this.commands[i].undo();
  }
  redo() {
    for (let i = 0; i < this.commands.length; i++) this.commands[i].redo();
  }
}

使用方式(粘贴等场景):

javascript 复制代码
history.beginBatch();
history.execute(new AddNodeCommand(ctx, node1));
history.execute(new AddEdgeCommand(ctx, edge1));
history.endBatch(); // 内部包装成一个 BatchCommand 入栈,一次 undo 全撤

上面这些片段的目的,是让你不用对照具体业务代码也能先把命令 + 栈 + 批量这条主链跑通,照着改造成自己项目里的命名就行。

真实工程里一定会遇到的几个坑

接下来,将几个踩过的坑,画布 + Vue + 富文本 + 事件总线 叠在一起,绕不开几个时序问题,下面几个是小编印象比较深的。

🍊 删除节点时,引用为啥没清干净?

如果先 deleteNode,再发「断开连接」类通知,下游节点这时已经拿不到上游数据了,引用清不干净。

调整顺序:在删节点前先把相关边的断开事件发干净(在命令里控制时序),让下游处理断开的逻辑先跑完,再真正移除节点。

🍊 富文本里 @ 标签「删了又回来」?

断链后富文本内容已经更新了,但别的模块异步 flush 了一份旧内容,把文本又盖回去了------典型的多路写入顺序问题。

解法之一是 await 富文本更新完成,再跑后续逻辑。时序问题靠 await 收拢,不是玄学。😉

🍊 富文本里 Ctrl+Z 和「工作流撤销」打架?

富文本编辑器(比如 Quill)内部也有自己的撤销栈。

外部把失效标签清掉之后,用户在编辑器里再按 Ctrl+Z,可能把已经无效的标签撤销回来。

可以在 外部同步内容之后 清掉富文本编辑器自己的撤销栈,并在用户输入路径上做 失效引用校验 (每次输入前过滤掉已经失效的 @ 标签)。

🍊 撤销恢复节点后,引用列表空了、@ 面板不见了?

lf.addNode 之后 Vue 组件还没挂载,如果连接事件同步发出,下游的监听都还没挂上,事件就丢了。

把连接事件延迟到 nextTick(比如在「删除节点」命令的 undo 里),让恢复出来的节点先注册监听,再补引用列表。

哪些操作故意不入栈?

设计上也不是啥都进栈,有几类是要主动排除的:

  • 服务端驱动:任务执行 / 停止、任务结果回写这类不可逆操作;
  • 纯 UI 状态:画布缩放平移、选中悬停等不影响数据的展示态;
  • 临时态:临时节点、锚点拖拽中间态等过渡过程。

避免把不可逆或服务端主导的东西和用户编辑混在一个栈里,否则用户会怀疑人生。

总结

粗粗对比一下两种路子:

维度 快照法 命令模式
实现成本 上手快,整图拍照即可 每种操作都要写命令类,前期多写一点字
事件 / 副作用 容易漏通知、靠补丁补 命令内部就把副作用一起写全了
扩展性 补丁越堆越厚 新增命令 + 入口接入,增量清晰
心智模型 整图回到某一帧 一步一步可逆操作,和用户干了啥对得上

从团队维护角度看,这次重构的核心价值是:撤销/重做和业务事件再次对齐,少了很多为什么撤销后引用没更新的玄学问题。

仍有一些复合命令待在业务入口完全接通,以及属性编辑是否进栈等,可作为后续优化------老样子,迭代嘛,虽迟但到,这其实一遍重构心得,或许有些地方没有看过实际的项目情况很难理解,但是...就这样子吧,当做一个记录,也欢迎交流。🎉


至此,本篇文章就写完啦,撒花撒花。

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。

老样子,点赞+评论=你会了,收藏=你精通了。

相关推荐
铁皮饭盒2 小时前
Rust版Bun1.4之前, 盘点Bun1.3新特性
前端·javascript·后端
恋猫de小郭2 小时前
如何让 AI 快速搭建一套生产 Agent ?全面理解 Agent 架构。
前端·人工智能·ai编程
Csvn2 小时前
Vite 构建缓存优化:二次构建从 15s 降到 2s 的实战方案
前端
晓得迷路了2 小时前
栗子前端技术周刊第 135 期 - Vite 8.1、Rspack 2.1、Babel 8.0...
前端·javascript·vite
你听得到113 小时前
用户说 App 卡,但说不清在哪?我把 Flutter 监控 SDK 升级成了链路观测工作台
前端·flutter·性能优化
天渺工作室12 小时前
实现一个adblock/adblock plus等浏览器广告拦截器检测插件
前端·javascript
阳光是sunny12 小时前
Vue 项目怎么做用户行为全链路监控?轻量插件方案详解
前端·面试·架构
ZhengEnCi12 小时前
Q04-Vite禁用CSS代码分割-解决生产环境样式加载顺序混乱问题
前端·vue.js·vite