真正可靠的做法是不依赖克隆而用命令模式捕获操作意图;克隆仅用于轻量锚点快照,如选区坐标或内容哈希,且需严格控制范围与格式。

对象克隆机制本身不能“完美”实现富文本撤销栈——它只是辅助手段,且容易误用。真正可靠的做法是**不依赖克隆,而用命令模式捕获操作意图**;克隆仅在极少数轻量快照场景下作为补充,且必须谨慎处理兼容性与性能边界。
为什么直接克隆 DOM 或 EditorState 不行
克隆整个编辑器内容(如 innerHTML、JSON 序列化 EditorState、structuredClone 全树)看似直观,但存在三个硬伤:
- 丢失运行时状态:光标位置、选区焦点、input 元素的 focus 状态、事件监听器、iframe/shadow DOM 上下文全部消失
- 内存与性能失控:一段含图片、表格、嵌套样式的 HTML 克隆一次可能达数 MB;连续输入每键克隆,10 秒内栈占百 MB 内存,页面明显卡顿
- 浏览器兼容风险高:structuredClone 在 Safari 16.4+ 才支持,旧版需手写浅层克隆;而 JSON.stringify(innerHTML) 会丢弃 DOM 属性、注释、空格结构,还原后格式错乱
克隆该用在哪?只用于「轻量锚点快照」
当需要记录关键节点(比如用户点击“保存版本”、执行复杂命令前、格式批量应用后),可对**最小必要字段**做可控克隆:
- 只克隆内容结构指纹:例如取 editor.innerHTML 的 MD5 或 xxHash,或对 AST 节点做简化哈希(忽略 style 属性值、class 顺序等非语义差异)
- 只序列化选区关键坐标:{ startContainerPath: [0,1,2], startOffset: 5, endContainerPath: [0,1,2], endOffset: 8 } —— 这类对象结构扁平,可安全用 structuredClone 或 JSON.parse(JSON.stringify())
- 若必须存内容快照,用 innerText + 标签白名单提取(如仅保留
- ),舍弃所有 style、data-*、onxxx 属性,体积压缩 90% 以上
真正健壮的撤销栈靠「命令对象 + 位置上下文」
撤销不是“回到某个 DOM 快照”,而是“执行一个可逆动作”。每个命令应是轻量、自包含、可重放的数据对象:
- 插入文字命令:
{ type: 'insert', at: { node: 'p-123', offset: 7 }, text: '你好', origin: 'input' } - 加粗命令:
{ type: 'toggle-style', range: { start: 12, end: 18 }, style: 'bold', oldValue: false } - 删除命令:
{ type: 'delete', range: { start: 5, end: 10 }, content: 'abc' } - 所有命令含
execute()和undo()方法,不操作真实 DOM,只调用编辑器提供的原子 API(如 insertNode、removeNode)
克隆与命令如何配合使用
在命令执行前后,可选择性触发轻量克隆,仅作校验或降级兜底:
- 命令执行前,用
structuredClone({ selection, scrollY })记录 UI 状态(不存内容),确保 undo 后光标和滚动位置可恢复 - 当检测到命令执行失败(如 DOM 已被异步修改),回退到上一个轻量快照(仅含 selection + hash),避免白屏或错位
- 协作编辑中,本地命令入栈时附带服务端返回的 versionId;若收到冲突更新,用当前快照 hash 对比远端变更,决定是否合并或提示用户
不复杂但容易忽略:撤销体验是否“完美”,取决于光标在哪、样式是否还原、中文输入是否连贯——这些都和克隆无关,只和你是否精确捕获了操作意图、是否重建了选区、是否防抖合并了输入有关。











