Vue富文本编辑器撤销功能导致光标位置错乱怎么办?
我在用contenteditable做富文本编辑器时,想通过保存历史快照实现撤销功能。但每次undo后光标会跳到开头,而且频繁操作会内存溢出。
现在用Vue维护一个history数组,在input事件里push当前innerHTML。undo方法直接取前一个快照赋值给content,但发现:
<template>
<div
contenteditable
@input="saveState"
v-html="content"
></div>
</template>
<script>
export default {
data() {
return {
content: '<p>初始内容</p>',
history: []
}
},
methods: {
saveState() {
this.history.push(this.content)
this.history = this.history.slice(-20) // 限制长度
},
undo() {
this.history.pop()
this.content = this.history[this.history.length-1]
}
}
}
</script>
这样实现后,撤销确实生效了,但光标总跳到开头,而且快速输入几十次后页面明显卡顿。有没有更好的历史记录管理方案?
光标跳到开头是因为你每次直接用
v-html重写整个 DOM,浏览器自然会把光标重置到开头,这个没得商量。要保留光标位置,必须在撤销前先保存当前光标位置,撤销后再手动恢复。内存卡顿是因为你存的是
innerHTML字符串,每次 input 都 push 一整段 HTML,频繁操作后数组越来越大,哪怕你只保留 20 个快照,每个快照可能几百 KB,内存还是吃不消。正确的做法是:只存操作指令,不存整个 DOM,也就是用 Operational Transformation(操作转换)的思想,或者更简单点,用「指令快照」——比如记录每次插入/删除的文本、位置、是否包含格式等。
不过如果你不想搞太复杂,可以退而求其次:
- 用
selectionAPI 保存光标 range- 快照里只存当前 DOM 的结构,但用
deep clone的方式缓存,避免重复引用- 更关键的是:别在 input 事件里直接存
innerHTML,这玩意触发太频繁,输入法还没确认呢你就存了给个能跑的简化方案:
注意几个坑:
-
getRangeAt的节点引用在 DOM 重写后可能失效,恢复光标前最好校验下节点还在不在-
execCommand('Undo')虽然能用,但兼容性差,而且和 Vue 响应式冲突,所以主动禁用掉- 真要高性能,还是建议用 Quill、TipTap 这种成熟的库,它们内部用 delta 格式存操作,内存占用极低
你这方案现在卡顿,八成是 input 触发太频繁,可以加个 200ms 的 debounce,或者改成在 blur 事件里保存(看需求),别在每次按键都 push。
光标跳到开头是因为直接修改了content的值,导致浏览器重新渲染整个DOM结构,自然会重置光标。要解决这个问题,需要在undo的时候手动保存和恢复光标位置。可以借助Selection和Range API来处理:
在undo方法里调用这两个辅助方法:
至于性能问题,频繁push完整HTML确实吃内存,建议改成存增量更新。比如只记录每次变化的差异,或者限制history长度在10以内就够了。另外,不要在每个input事件都存快照,可以加个防抖:
这样既解决了光标问题,又优化了性能。记得测试下主流浏览器兼容性,尤其是IE这种老古董可能需要额外打补丁。做富文本编辑器确实挺烦人的,慢慢调吧。