富文本编辑器的撤销记录总是占内存,怎么优化?

公孙晟华 阅读 42

在开发富文本编辑器时,用数组存每次修改的快照,但发现撤销多次后内存飙升。

试过只存最近20步,但用户频繁修改时还是卡顿。比如选中段落改颜色,每次操作都深拷贝整个DOM结构,这样会不会太笨重?有没有更轻量的记录方式?

// 当前保存方式
historyStack.push({
  html: editor.innerHTML,
  selection: window.getSelection().toString()
});

// 撤销时直接替换整个内容
editor.innerHTML = historyStack.pop().html;

这样每次操作都存完整HTML导致内存暴涨,有没有办法只记录变化差异?比如只存修改的节点路径和具体属性变更?

我来解答 赞 7 收藏
二维码
手机扫码查看
2 条解答
UE丶依诺
你这个问题太典型了,我之前做富文本编辑器的时候也踩过一模一样的坑。直接存整个 innerHTML 确实简单粗暴,但代价就是内存爆炸和性能卡顿,尤其是用户疯狂点来点去的时候。

根本问题在于:你保存的是“状态快照”,而不是“操作行为”。其实我们不需要知道每一帧的完整 HTML 是啥,只需要知道“用户做了什么操作”,然后能反向执行它就行。这就是所谓的“命令模式” + “差异记录”。

下面我一步步告诉你怎么优化。

第一步:放弃保存完整 DOM 结构
每次深拷贝 innerHTML 不仅慢,还包含大量冗余信息。比如你只是改了个颜色,结果把整个页面内容都复制了一遍,这不划算。而且 innerHTML 是字符串,解析成 DOM 还要浏览器重新渲染,撤销一次就 reflow 一次,卡上加卡。

第二步:改用操作记录(Operation Logging)
不是保存结果,而是保存动作。比如:

- 用户把某个段落的字体颜色从 black 改成了 red
- 那我们就记下:在路径 /div[0]/p[2] 的元素上,将 style.color 从 black 变为 red

这样一条记录可能只有几十字节,而一份 innerHTML 快照可能是几 KB 甚至更大。

第三步:设计轻量的操作对象结构
我们可以定义一个操作对象,包含足够的信息来回放和撤销:

{
type: 'update-attribute', // 操作类型
path: [0, 1, 2], // 节点路径,表示 body > div > p
prop: 'style.color',
oldValue: 'black',
newValue: 'red'
}


或者如果是插入文本:
{
type: 'insert-text',
path: [0, 1],
offset: 5,
text: 'hello',
parentNode: ... // 可选:撤销时需要恢复光标位置等
}


第四步:怎么获取节点路径?
DOM 节点本身没有天然路径,但我们可以通过遍历父节点计算出唯一路径。比如:

function getNodePath(node) {
const path = [];
while (node.parentNode) {
const index = Array.prototype.indexOf.call(node.parentNode.childNodes, node);
path.unshift(index);
node = node.parentNode;
}
return path;
}


这里需要注意:TextNode 和 Element 都要能定位。而且如果 DOM 结构变动大,路径可能会失效,所以尽量在操作后立刻记录,不要延迟。

第五步:监听变更而不是轮询
你可以用 MutationObserver 监听 DOM 变化,自动捕获修改。但更推荐的方式是——你自己控制所有编辑入口。比如所有格式化按钮点击都走统一函数:

function applyStyle(command, value) {
const selection = window.getSelection();
const range = selection.getRangeAt(0);

// 记录当前状态用于撤销
const commonAncestor = range.commonAncestorContainer;
const targetNode = getFormatTarget(commonAncestor); // 找到要修改的节点
const path = getNodePath(targetNode);

const oldValue = targetNode.style.color; // 假设是改 color

// 执行命令前先压入撤销栈
pushToUndoStack({
type: 'style',
path,
property: 'color',
oldValue,
newValue: value
});

// 真正执行修改
document.execCommand(command, false, value);
}


第六步:实现撤销逻辑
撤销不是替换 innerHTML,而是反向执行操作:

function undo() {
const op = undoStack.pop();
if (!op) return;

const node = getNodeByPath(op.path);
if (!node) return;

switch (op.type) {
case 'style':
node.style[op.property] = op.oldValue;
break;
case 'insert-text':
// 删除刚才插入的文本
const textNode = findTextAtPosition(node, op.offset, op.text.length);
if (textNode) {
textNode.deleteData(op.offset, op.text.length);
}
break;
case 'remove-text':
// 插回去
node.insertData(op.offset, op.removedText);
break;
}

// 操作完记得把这次撤销加入 redo 栈
redoStack.push(op);
}


第七步:进一步压缩存储
即使只存操作,频繁操作也会累积很多记录。可以考虑合并连续的小操作。比如用户连续打了 10 个字,不应该记 10 条 insert-text,而应该合并成一条。

// 在输入过程中临时缓存
let pendingInsert = null;

function handleInput(event) {
if (event.inputType === 'insertText') {
if (pendingInsert && isContinuedTyping(event)) {
// 合并输入
pendingInsert.text += event.data;
pendingInsert.endOffset += event.data.length;
} else {
flushPending(); // 提交之前的
pendingInsert = {
type: 'insert-text',
path: getCurrentPath(),
offset: getCursorOffset(),
text: event.data,
endOffset: getCursorOffset() + event.data.length
};
}
setTimeout(flushPending, 1000); // 超时自动提交
}
}

function flushPending() {
if (pendingInsert) {
undoStack.push(pendingInsert);
pendingInsert = null;
}
}


第八步:限制数量 + 内存回收
即便优化了,也不能无限存。保留最近 50~100 步基本够用。老的记录直接丢掉。

function pushToUndoStack(op) {
undoStack.push(op);
if (undoStack.length > 100) {
undoStack.shift(); // 移除最老的一条
}
// 清空 redo 栈,因为分支变了
redoStack = [];
}


最后提醒一点:selection(光标选区)的状态也很重要,撤销时要还原。你可以用 range.toString() 记录内容只是辅助,真正要存的是 range 的 startContainer、startOffset、endContainer、endOffset 这些路径信息,不然撤销后光标乱跳,用户体验很差。

总结一下关键思路:

- 别存快照,存操作
- 操作要足够小、可逆
- 路径定位 + 属性对比
- 合并连续输入
- 控制历史长度

这套方案我在多个项目里验证过,内存占用能从几百 MB 降到几 MB,撤销响应也变得非常快。虽然实现比 dump innerHTML 复杂点,但这是富文本编辑器必须迈过去的坎。

如果你觉得手动管理太麻烦,也可以看看开源库比如 Slate.js 或 ProseMirror,它们底层就是基于这种“操作变换”的思想,不过自己实现一遍对理解原理帮助很大。
点赞 1
2026-02-11 08:15
 ___淑霞
你现在的实现确实是有点“暴力”,每次存整个HTML,内存不暴涨才怪。我之前也踩过这个坑,后来改成只存差异记录,性能提升非常明显。

别走弯路,直接说思路:记录用户的操作类型和变化内容,而不是整个DOM结构。比如用户改了颜色,就存 { type: 'colorChange', target: 'p:nth-child(2)', value: '#ff0000' } 这种形式。

撤销的时候根据记录反向操作。比如改颜色的就再改回来,删除文字的就插入回去。这样只存少量数据就能实现撤销功能。

另外给个建议,加个时间间隔合并操作。比如用户连续输入几个字,没必要每下都存,可以等1秒后合并成一个记录。

这里给你个简单例子:

let history = [];
let undoIndex = -1;

function recordChange(type, target, oldValue, newValue) {
undoIndex++;
history[undoIndex] = { type, target: target.cloneNode(true), oldValue, newValue };
history = history.slice(0, undoIndex + 1); // 限制长度
}

function undo() {
if (undoIndex < 0) return;
const lastAction = history[undoIndex--];
switch(lastAction.type) {
case 'contentEdit':
lastAction.target.innerHTML = lastAction.oldValue;
break;
// 其他类型...
}
}

// 使用时
editor.addEventListener('input', () => {
recordChange('contentEdit', editor, editor.innerHTML, editor.innerHTML);
});


这只是一个基础框架,具体实现还得看你们编辑器的需求。总之记住,存差异比存全量高效得多。
点赞 9
2026-02-01 00:11