可编辑区域的可靠撤销重做需自建双栈机制:undostack存操作后快照,redostack存被撤操作;监听keydown、input(防抖)、paste及工具栏命令触发快照,标准化html并限长;同步保存与恢复光标位置。

直接用 contenteditable 搭个可编辑区域只是起点,真正能回退、重做、不丢光标、不崩结构的“底座”,必须自己搭历史栈 + 状态快照机制。它不是加个 undo/redo 按钮就行,而是要从数据结构、触发时机、内存控制三方面稳住。
核心:用双栈管理编辑状态
撤销(undo)和重做(redo)本质是两个独立的栈,互不干扰:
- undoStack 存每次操作后的完整状态(推荐初学者用 HTML 字符串快照)
- redoStack 存被撤销的操作,供重做时还原
- 每次有效编辑(输入、删除、格式化、粘贴)后,把当前编辑区
innerHTML推入undoStack,同时清空redoStack - 点击撤销 → 从
undoStack弹出一个状态 → 渲染到编辑区 → 把该状态推入redoStack - 点击重做 → 从
redoStack弹出 → 渲染 → 推入undoStack
关键触发点:哪些操作才算“一次变更”?
不能等 input 事件才存快照——它漏掉格式操作、快捷键、粘贴等;也不能每敲一个字就存——性能爆炸。应监听并聚合以下真实变更源:
-
keydown中拦截 Ctrl+Z/Ctrl+Y,手动调用撤销/重做逻辑 -
input事件(仅用于纯文本增删,需防抖,比如 300ms 内连续输入只记一次) -
paste事件:调用preventDefault()后清洗粘贴内容,再插入,之后立刻存快照 - 所有工具栏命令(如加粗、列表、对齐)执行完 DOM 修改后,立即保存快照
- 避免在
focus、blur或鼠标移动中触发,它们不产生实质内容变更
快照怎么存才靠谱又省内存?
全量快照最简单,但要注意三点:
- 存之前先标准化 HTML:移除空
<span></span>、清理 Word 留下的style="mso-xxx"、把<b></b>统一转<strong></strong>、删多余<br> - 限制栈长度(如最多存 50 步),老记录自动踢出,防止内存溢出
- 不要直接存
editor.innerHTML—— 它可能含浏览器自动生成的不可控标签(如 Safari 插入<div> 而非 <code><p></p>)。建议用递归遍历节点后序列化成干净结构 - 若需更高阶能力(如合并连续输入为一步),可改用增量变更记录,但需额外实现反向操作逻辑,初版不建议
- 存快照前,用
getSelection()+range.cloneRange()获取当前选区起止位置(注意跨浏览器兼容性) - 把 range 的
startOffset、endOffset和对应节点路径(如第几个子节点)一起存入快照对象 - 还原快照后,重建 range 并调用
selection.removeAllRanges()→selection.addRange(range) - 若选区为空(如光标在段首),至少保留光标所在节点和偏移量,用
range.setStart(node, offset)恢复
光标与状态同步不能断
恢复快照后,光标会丢失或跳位。必须在存快照时一并记录光标位置,并在还原时恢复:











