富文本编辑器的撤销记录总是占内存,怎么优化?
在开发富文本编辑器时,用数组存每次修改的快照,但发现撤销多次后内存飙升。
试过只存最近20步,但用户频繁修改时还是卡顿。比如选中段落改颜色,每次操作都深拷贝整个DOM结构,这样会不会太笨重?有没有更轻量的记录方式?
// 当前保存方式
historyStack.push({
html: editor.innerHTML,
selection: window.getSelection().toString()
});
// 撤销时直接替换整个内容
editor.innerHTML = historyStack.pop().html;
这样每次操作都存完整HTML导致内存暴涨,有没有办法只记录变化差异?比如只存修改的节点路径和具体属性变更?
根本问题在于:你保存的是“状态快照”,而不是“操作行为”。其实我们不需要知道每一帧的完整 HTML 是啥,只需要知道“用户做了什么操作”,然后能反向执行它就行。这就是所谓的“命令模式” + “差异记录”。
下面我一步步告诉你怎么优化。
第一步:放弃保存完整 DOM 结构
每次深拷贝 innerHTML 不仅慢,还包含大量冗余信息。比如你只是改了个颜色,结果把整个页面内容都复制了一遍,这不划算。而且 innerHTML 是字符串,解析成 DOM 还要浏览器重新渲染,撤销一次就 reflow 一次,卡上加卡。
第二步:改用操作记录(Operation Logging)
不是保存结果,而是保存动作。比如:
- 用户把某个段落的字体颜色从 black 改成了 red
- 那我们就记下:在路径 /div[0]/p[2] 的元素上,将 style.color 从 black 变为 red
这样一条记录可能只有几十字节,而一份 innerHTML 快照可能是几 KB 甚至更大。
第三步:设计轻量的操作对象结构
我们可以定义一个操作对象,包含足够的信息来回放和撤销:
或者如果是插入文本:
第四步:怎么获取节点路径?
DOM 节点本身没有天然路径,但我们可以通过遍历父节点计算出唯一路径。比如:
这里需要注意:TextNode 和 Element 都要能定位。而且如果 DOM 结构变动大,路径可能会失效,所以尽量在操作后立刻记录,不要延迟。
第五步:监听变更而不是轮询
你可以用 MutationObserver 监听 DOM 变化,自动捕获修改。但更推荐的方式是——你自己控制所有编辑入口。比如所有格式化按钮点击都走统一函数:
第六步:实现撤销逻辑
撤销不是替换 innerHTML,而是反向执行操作:
第七步:进一步压缩存储
即使只存操作,频繁操作也会累积很多记录。可以考虑合并连续的小操作。比如用户连续打了 10 个字,不应该记 10 条 insert-text,而应该合并成一条。
第八步:限制数量 + 内存回收
即便优化了,也不能无限存。保留最近 50~100 步基本够用。老的记录直接丢掉。
最后提醒一点:selection(光标选区)的状态也很重要,撤销时要还原。你可以用 range.toString() 记录内容只是辅助,真正要存的是 range 的 startContainer、startOffset、endContainer、endOffset 这些路径信息,不然撤销后光标乱跳,用户体验很差。
总结一下关键思路:
- 别存快照,存操作
- 操作要足够小、可逆
- 路径定位 + 属性对比
- 合并连续输入
- 控制历史长度
这套方案我在多个项目里验证过,内存占用能从几百 MB 降到几 MB,撤销响应也变得非常快。虽然实现比 dump innerHTML 复杂点,但这是富文本编辑器必须迈过去的坎。
如果你觉得手动管理太麻烦,也可以看看开源库比如 Slate.js 或 ProseMirror,它们底层就是基于这种“操作变换”的思想,不过自己实现一遍对理解原理帮助很大。
别走弯路,直接说思路:记录用户的操作类型和变化内容,而不是整个DOM结构。比如用户改了颜色,就存 { type: 'colorChange', target: 'p:nth-child(2)', value: '#ff0000' } 这种形式。
撤销的时候根据记录反向操作。比如改颜色的就再改回来,删除文字的就插入回去。这样只存少量数据就能实现撤销功能。
另外给个建议,加个时间间隔合并操作。比如用户连续输入几个字,没必要每下都存,可以等1秒后合并成一个记录。
这里给你个简单例子:
这只是一个基础框架,具体实现还得看你们编辑器的需求。总之记住,存差异比存全量高效得多。