做 Web 组态编辑器时,很多功能都长得像“看起来不难,做起来崩溃”。
撤销 / 重做就是其中的典型代表。
Demo 阶段最常见的做法很直接:每次操作后把整个画布 JSON 存一份快照,然后维护两个栈:undoStack 和 redoStack。用户按一次 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 不是简单地“再来一次”
很多实现默认认为:
- 用户撤销两步
- 再点 redo
- 系统恢复到之前状态
这在单线历史里没问题。
但一旦用户撤销后进行了新编辑,历史就分叉了。
A -> B -> C -> D
↑ undo 到 B
B -> E -> F
这时候原来的 C -> D 这条 redo 分支要不要保留?
大部分编辑器会直接清空旧 redo,因为用户已经基于旧世界线创建了新未来。如果你既不清理、又没有真正的历史树模型,最终就会出现“重做到了不该到的状态”。
5)副作用操作很难靠纯快照兜住
组态编辑器里的很多动作并不只是“改一段 JSON”:
- 删除节点后要同步删除关联连线
- 修改组件尺寸后可能触发自动布局
- 改数据源绑定后可能触发预览刷新
- 导入组件包后要补注册资源索引
如果你只存最终快照,确实能恢复结果;但你失去了对“过程”的描述。这样会导致两个问题:
- 很难做操作合并、审计、回放
- 很难对异步副作用做一致性控制
这也是为什么很多成熟编辑器最终都会走向 命令(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);
}
}
这里真正重要的不是代码本身,而是这几个设计点:
- 可合并:拖拽、连续输入、方向键微调应该能合并
- 可裁剪:历史长度必须有限制
- 可解释:每条历史记录都应有 label
- 可分层:不是所有状态都走同一条历史链
- 可恢复: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))),一边怀疑自己为什么要做编辑器。
而且通常就是周五晚上。