应弃用浏览器原生撤销栈和document.execcommand,改用状态快照+栈管理:监听input/compositionend/mouseup事件,防抖保存清洗后的innerhtml与序列化选区,还原时重建光标位置并禁用原生ctrl+z/y。

contenteditable 的原生 undo 栈根本不可靠
浏览器对 contenteditable 元素提供的 Ctrl+Z/Ctrl+Y 行为,只是对“用户直接操作”的有限快照,不是可编程的撤销系统。它不记录 JS 修改,不暴露栈长度,也不支持插入、跳转或清空历史。一旦你调用 element.innerHTML = 或触发 document.execCommand(哪怕只是 'insertText'),大多数浏览器会重置整个栈——上一步还在打字,下一步就再也按不了 Ctrl+Z。
常见错误现象:
- 用户粘贴一段 HTML 后按 Ctrl+Z,只撤回了最后几个字符,而不是整段粘贴内容
- JS 自动格式化(如输入 “**bold**” 转成
<strong>bold</strong>)后,撤销点错位,Ctrl+Z 撤掉的是前一次输入,而非格式化动作 - Safari 中连续加粗两次,Ctrl+Z 只能撤一次,第二次操作直接丢失
为什么 document.execCommand('undo') 必须禁用
这个 API 已被 Chrome 90+ 和 Firefox 84+ 正式废弃,且行为完全不可控:它不接受参数,无法指定步数,也不反馈当前是否可撤销。更致命的是,它和用户按键(Ctrl+Z)共用同一隐式栈,但 JS 调用和键盘事件的“操作粒度”判定规则完全不同。
实操建议:
- 在
keydown事件中统一拦截:if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'y')) { e.preventDefault(); } - 务必同时拦截
Ctrl+Shift+Z(重做),某些系统将其视为独立快捷键 - 不要试图用
document.execCommand('undo')替代用户按键——它在 Safari 中常静默失败,在 Edge 中可能触发异常 DOM 重建
自己维护 undo/redo 栈时,快照存什么才真正有用
只存 innerHTML 是最常见也最危险的做法:DOM 结构稍有变动(比如插入图片后自动加 data-id),路径就失效;纯文本快照又丢格式;而完整 cloneNode 太耗内存。
将 Markdown 文章转换为微信公众号兼容的纯 HTML 格式。当用户要求"转成微信格式"、"生成公众号文章 HTML"、"排版到微信草稿箱"、"微信粘贴格式"时触发。输出带内联样式的纯 HTML,微信编辑器可直接渲染,无需额外适配。支持标题、段落、列表、粗体、行内代码、表格、引用块、代码块、配图的完整转换。
真正可用的快照必须包含两部分:
-
innerHTML(清洗后):用DOMPurify.sanitize(html, { ALLOWED_TAGS: ['p', 'br', 'strong', 'em'] })过滤,避免 XSS 和冗余标签 - 光标位置序列化结果:不是简单存
getSelection().focusOffset,而是用range.startContainer转成相对于编辑器根节点的路径数组(如[0, 1, 2])+ 偏移量,还原时递归查找并重建Range
防抖保存很关键:监听 input + compositionend + mouseup,延迟 200ms 后比对 innerHTML 是否真变了,再推入栈——否则连打十个字会生成十份快照,内存暴涨且无意义。
还原光标位置比还原内容难十倍
直接赋值 editor.innerHTML = snapshot.html 后,光标默认落到开头或末尾,用户编辑体验直接断裂。这不是样式问题,而是 DOM 替换后浏览器无法自动恢复 selection 上下文。
容易被忽略的关键点:
- 存快照前必须调用
saveSelection(),且要在compositionend后立即执行——中文输入法上屏瞬间,getSelection()才稳定 - 还原时若按路径查不到节点(比如用户删了一整段,快照里的
[0, 1, 2]节点已不存在),得 fallback 到文本锚点匹配:用快照中光标前 5 个字符向后搜索最近匹配位置 - 千万别在 Vue/React 组件里用
v-html渲染快照——它会全量替换 DOM,彻底丢失 Range;必须用textContent+Range手动插入
前端入门到VUE实战笔记:立即使用
在学习笔记中,你将探索 前端 的入门与实战技巧!










