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

22 阅读11分钟

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

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

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

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

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

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

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

因为它真的简单。

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

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)并不是所有状态都应该被快照

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

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

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. 系统恢复到之前状态

这在单线历史里没问题。

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

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)先把“操作”建模,而不是先存“结果”

比如拖动元素:

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 思路),可以把一次操作表示成:

interface HistoryEntry {
  label: string;
  patches: Patch[];
  inversePatches: Patch[];
  timestamp: number;
  groupId?: string;
}

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

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

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

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

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

也就是混合策略。

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

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

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))),一边怀疑自己为什么要做编辑器。

而且通常就是周五晚上。