Web组态编辑器的撤销重做架构设计

做 Web 组态编辑器时,很多功能都长得像"看起来不难,做起来崩溃"。

撤销 / 重做就是其中的典型代表。

Demo 阶段最常见的做法很直接:每次操作后把整个画布 JSON 存一份快照 ,然后维护两个栈:undoStackredoStack。用户按一次 Ctrl+Z,就把当前状态弹出去,再把上一份快照还原回来。刚写完时你会觉得:这不挺优雅的吗?

但只要项目进入生产,你很快就会碰到下面这些问题:

  • 一个拖拽动作产生几十次历史记录,按一次撤销只能退 1 像素
  • 画布节点一多,快照体积爆炸,内存一路飙升
  • 某些操作可以回退数据,却回退不了视口、选中态、辅助线状态
  • 撤销后再编辑,原来的 redo 分支到底该不该保留?
  • 异步资源加载、自动吸附、联动脚本执行后,历史记录开始变得不可信

这篇文章想聊的,不是"怎么做一个能用的 undo/redo",而是:为什么简单快照法在组态编辑器里几乎一定会撞墙,以及更稳妥的生产级设计应该长什么样。

一、为什么快照法在 Demo 里总是显得很好用

因为它真的简单。

你可能会先写出这样一个版本:

ini 复制代码
interface EditorState {
  pages: Page[];
  activePageId: string;
  viewport: { x: number; y: number; scale: number };
  selection: string[];
}
​
const undoStack: EditorState[] = [];
const redoStack: EditorState[] = [];
​
function commit(state: EditorState) {
  undoStack.push(structuredClone(state));
  redoStack.length = 0;
}
​
function undo(current: EditorState) {
  if (undoStack.length <= 1) return current;
  const present = undoStack.pop()!;
  redoStack.push(structuredClone(present));
  return structuredClone(undoStack[undoStack.length - 1]);
}

这个方案在以下场景确实没毛病:

  • 节点数量少
  • 操作频率低
  • 没有多人协作
  • 没有复杂联动
  • 只验证产品原型

问题在于,组态编辑器不是普通表单页。它是高频交互、状态复杂、对象数量大、且经常带有"派生副作用"的前端应用。快照法在这里输得非常快。

二、它为什么会在生产里翻车

1)历史记录粒度失控

用户"拖动一个设备"这件事,对业务来说是一次操作; 但对浏览器事件流来说,可能是:

  • pointerdown
  • 40 次 pointermove
  • 吸附修正
  • 对齐线更新
  • pointerup

如果你在每次状态变化时都直接入栈,最后就会得到一长串毫无意义的历史记录。用户按一次 Ctrl+Z,发现元素只是从 (401, 212) 回到了 (400, 212),心态会当场裂开。

根因不是栈设计错了,而是"用户意图"没有被建模。

生产级系统里,历史记录应该对应"意图操作",而不是"每一次中间状态变化"。

2)内存和序列化成本过高

组态编辑器的画布数据通常不小:

  • 节点树
  • 连线关系
  • 动画配置
  • 数据源绑定
  • 样式属性
  • 事件脚本
  • 图层 / 页面配置

假设一份场景 JSON 是 1.5MB,不算夸张。保留 100 步历史就是 150MB;再加上 redo、临时对象、渲染缓存,浏览器内存直接开始表演。

更麻烦的是,快照法通常伴随频繁深拷贝和序列化,这会带来:

  • 主线程卡顿
  • GC 压力增大
  • 高频操作掉帧

也就是说,它不只是"占空间",而是会直接影响编辑体验。

3)并不是所有状态都应该被快照

这是很多人第一次做编辑器时最容易忽略的问题。

编辑器状态其实通常分成三层:

css 复制代码
interface EditorState {
  // 业务文档状态:应该进历史
  document: DocumentSchema;
​
  // 界面临时状态:通常不该进历史
  ui: {
    hoverId?: string;
    guidelineVisible: boolean;
    contextMenuOpen: boolean;
  };
​
  // 会话状态:有些要进,有些不要
  session: {
    selection: string[];
    viewport: { x: number; y: number; scale: number };
  };
}

如果你把所有状态一锅端:

  • 撤销后菜单也跟着神奇复活
  • 鼠标 hover 态被存进历史
  • 某些弹窗状态来回闪

如果你什么都不存:

  • 撤销后对象回去了,但视口没回去
  • 多选框状态丢失,用户完全不知道刚撤销了谁

所以真正的问题不是"要不要快照",而是:哪些状态需要纳入历史语义,哪些状态必须排除。

4)Redo 不是简单地"再来一次"

很多实现默认认为:

  1. 用户撤销两步
  2. 再点 redo
  3. 系统恢复到之前状态

这在单线历史里没问题。

但一旦用户撤销后进行了新编辑,历史就分叉了。

rust 复制代码
A -> B -> C -> D
          ↑ undo 到 B
B -> E -> F

这时候原来的 C -> D 这条 redo 分支要不要保留?

大部分编辑器会直接清空旧 redo,因为用户已经基于旧世界线创建了新未来。如果你既不清理、又没有真正的历史树模型,最终就会出现"重做到了不该到的状态"。

5)副作用操作很难靠纯快照兜住

组态编辑器里的很多动作并不只是"改一段 JSON":

  • 删除节点后要同步删除关联连线
  • 修改组件尺寸后可能触发自动布局
  • 改数据源绑定后可能触发预览刷新
  • 导入组件包后要补注册资源索引

如果你只存最终快照,确实能恢复结果;但你失去了对"过程"的描述。这样会导致两个问题:

  1. 很难做操作合并、审计、回放
  2. 很难对异步副作用做一致性控制

这也是为什么很多成熟编辑器最终都会走向 命令(Command)/ 事务(Transaction)/ Patch 体系,而不是无限堆快照。

三、生产环境更稳的思路:命令 + 补丁 + 分层历史

我现在更倾向的一种结构是:

  • 用户意图层:一次拖拽、一次批量对齐、一次复制粘贴
  • 变更表达层:Patch / inversePatch,或者 command.do / command.undo
  • 历史管理层:负责合并、分组、裁剪、分支处理
  • 状态存储层:文档状态与 UI 状态分离

1)先把"操作"建模,而不是先存"结果"

比如拖动元素:

typescript 复制代码
interface Command {
  label: string;
  do(): void;
  undo(): void;
  canMerge?(next: Command): boolean;
  merge?(next: Command): Command;
}
​
class MoveNodesCommand implements Command {
  label = '移动节点';
​
  constructor(
    private ids: string[],
    private before: Record<string, { x: number; y: number }>,
    private after: Record<string, { x: number; y: number }>
  ) {}
​
  do() {
    applyPositions(this.after);
  }
​
  undo() {
    applyPositions(this.before);
  }
​
  canMerge(next: Command) {
    return next instanceof MoveNodesCommand && sameIds(this.ids, next.ids);
  }
​
  merge(next: MoveNodesCommand) {
    return new MoveNodesCommand(this.ids, this.before, next.after);
  }
}

好处很直接:

  • 拖拽过程中可以把几十次 move 合并成一次历史记录
  • undo / redo 语义明确
  • 可以做操作名称展示:撤销"移动节点"
  • 可以按命令类型做权限、埋点、回放

2)文档状态和 UI 状态分层

我会建议至少分成:

  • Document State:节点、连线、页面、样式、数据绑定
  • Derived Runtime State:选中框、辅助线、hover、高亮、拖拽框
  • Session State:缩放、视口、当前页、面板展开态

其中真正进入历史主链的,应该优先是 Document State

selection / viewport 这类状态,要按体验决定是否以"伴随信息"方式写入历史,而不是粗暴混进主状态对象。

3)用 Patch 比整页快照更经济

如果你的状态管理支持 immutable 或 patch(比如自己实现 diff,或者借助 Immer 的 patch 思路),可以把一次操作表示成:

ini 复制代码
interface HistoryEntry {
  label: string;
  patches: Patch[];
  inversePatches: Patch[];
  timestamp: number;
  groupId?: string;
}

执行时应用 patches,撤销时应用 inversePatches

相比整页快照,它的优势是:

  • 存储量通常更小
  • 更容易知道"改了什么"
  • 更适合调试和日志记录
  • 可以和协同编辑的变更模型更自然地接轨

当然,Patch 也不是银弹。对于大范围结构变更、复杂批处理,它仍然可能膨胀。所以实战里往往会采用:

"Patch 为主,关键节点定期快照"

也就是混合策略。

四、一个更像生产系统的撤销管理器

下面这个简化版结构,比"双数组塞快照"更接近可扩展实现:

kotlin 复制代码
class HistoryManager {
  private undoStack: HistoryEntry[] = [];
  private redoStack: HistoryEntry[] = [];
  private maxSteps = 100;
​
  push(entry: HistoryEntry) {
    const last = this.undoStack[this.undoStack.length - 1];
​
    if (last && canMerge(last, entry)) {
      this.undoStack[this.undoStack.length - 1] = mergeEntry(last, entry);
    } else {
      this.undoStack.push(entry);
    }
​
    if (this.undoStack.length > this.maxSteps) {
      this.undoStack.shift();
    }
​
    this.redoStack.length = 0;
  }
​
  undo() {
    const entry = this.undoStack.pop();
    if (!entry) return;
    applyPatches(entry.inversePatches);
    this.redoStack.push(entry);
  }
​
  redo() {
    const entry = this.redoStack.pop();
    if (!entry) return;
    applyPatches(entry.patches);
    this.undoStack.push(entry);
  }
}

这里真正重要的不是代码本身,而是这几个设计点:

  1. 可合并:拖拽、连续输入、方向键微调应该能合并
  2. 可裁剪:历史长度必须有限制
  3. 可解释:每条历史记录都应有 label
  4. 可分层:不是所有状态都走同一条历史链
  5. 可恢复:undo / redo 都应该是幂等、稳定的

五、我踩过的几个坑,基本都很典型

坑 1:把鼠标移动也记进历史

结果就是一次拖拽生成 80 条记录。解决方式不是 debounce,而是事务化

  • pointerdown 开启事务
  • pointermove 只更新临时状态
  • pointerup 统一提交一条命令

坑 2:撤销能回去,但选中态丢了

用户刚撤销完一个元素位置变化,结果这个元素没选中了,视觉上像"没生效"。

这类问题本质上是文档状态恢复了,但交互上下文没恢复。我的经验是:对 selection 这类强体验相关状态,可以作为 history entry 的 metadata 一起恢复,但不要让它和文档状态完全耦死。

坑 3:自动对齐和吸附把历史语义搞乱

用户拖到某个位置,系统因为吸附把它修正到另一条网格线。那撤销时到底应该回到"手指移动到的位置",还是"最终吸附后的位置"?

生产里通常应该以提交结果为准,也就是用户最终看到的那个结果,否则体验会非常怪。

坑 4:导入 / 粘贴大对象时卡顿明显

这类操作即使用 patch,也可能很重。比较实用的办法是:

  • 历史入栈前先做结构压缩
  • 大对象资源引用化,不要重复存 blob/base64
  • 必要时把部分 diff 计算下沉到 Worker

六、如果未来要做协同编辑,撤销系统更不能随便写

单机 undo/redo 已经不简单了; 一旦叠加多人协作,复杂度直接升级。

原因很简单:你的"上一步"不再只是你自己的上一步。

这也是为什么很多成熟编辑器或协同框架,会把"本地历史"和"共享文档变更"严格区分,甚至做 selective undo。像 ProseMirror 的 history 设计思路,就不是简单地回到某个旧快照,而是围绕 transaction 和可逆变更来组织历史。

对组态编辑器来说,这意味着至少要提前留出两个扩展点:

  • 本地命令历史
  • 远端协同变更映射

如果一开始就把 undo/redo 写死成"恢复第 N 份 JSON",后面几乎必然重构。

七、一个实用结论:别把撤销/重做当附属功能

很多团队会把它当成编辑器收尾阶段补上的"小功能"。

但实际情况恰恰相反:

撤销/重做是编辑器状态架构是否健康的试金石。

因为它会逼着你回答这些真正关键的问题:

  • 什么才算一次用户操作?
  • 状态边界在哪里?
  • 哪些是文档,哪些是 UI?
  • 变更能否被描述、回放、合并、逆转?
  • 副作用发生时,系统还能保持一致吗?

这些问题答不清,撤销功能只是最先炸的那个点而已。

八、我会怎么给一个新项目落地

如果现在从 0 开始做一个 Web 组态编辑器,我会按这个优先级落地:

第 1 阶段:先建立最小可用历史系统

  • 命令模型或 patch 模型
  • undo / redo 双栈
  • 历史长度限制
  • 拖拽 / 输入 / 批量操作的合并规则

第 2 阶段:处理复杂交互

  • selection / viewport 的伴随恢复
  • 删除节点时的级联撤销
  • 粘贴 / 对齐 / 分组等复合命令

第 3 阶段:为协同和性能留口子

  • 事务 ID
  • patch 压缩
  • 周期性基线快照
  • Worker 化 diff
  • 本地历史与远端操作分离

这样做的好处是:前期不会过度设计,后期也不至于全盘推倒。

结尾

撤销 / 重做这个功能,最迷惑人的地方就在于:

它太像一个"加两个按钮就行"的需求了。

但只要场景进入组态编辑器、低代码搭建器、富交互画布,问题就会瞬间从"栈怎么写"升级成"状态系统怎么设计"。

所以我的建议很简单:

  • Demo 阶段可以用快照法快速验证
  • 一旦准备进生产,就尽快转向命令 / patch / 事务化设计
  • 把历史记录当作架构问题,而不是组件功能问题

不然你迟早会在某个深夜,一边盯着 undoStack.push(JSON.parse(JSON.stringify(state))),一边怀疑自己为什么要做编辑器。

而且通常就是周五晚上。

相关推荐
掘金安东尼2 小时前
本周前端与 AI 技术情报|前端下一步 #462
前端·javascript·面试
赵庆明老师2 小时前
vben开发入门5:vite.config.ts
前端·html·vue3·vben
qq_12084093712 小时前
Three.js 工程向:实例化渲染 InstancedMesh 的批量优化
前端·javascript
起这个名字3 小时前
LangGraphJs 核心概念、工作流程理解及应用
前端·人工智能
小赵同学WoW3 小时前
vue组件基础知识
前端
牛奶3 小时前
浏览器藏了这么多神器,你居然不知道?
前端·chrome·api
WebInfra3 小时前
Rspack 2.0 正式发布!
前端·javascript·前端框架
极速蜗牛3 小时前
Cursor最近变傻了?
前端
码字小学妹3 小时前
Claude Opus 4.7 接入指南(2026):国内配置 + xhigh 推理 + 成本计算
前端