Slate History 演进与实现解析:从 CHANGELOG 看操作级 undo/redo 机制的迭代
【免费下载链接】slateA completely customizable framework for building rich text editors. (Currently in beta.)项目地址: https://gitcode.com/gh_mirrors/sl/slate
导读
slate-history是 Slate 生态中负责撤销(undo)与重做(redo)的核心子库,它通过记录每次变更产生的 Slate 操作(Operation)来实现基于操作的历史回放,而非保存整份文档快照。本文以 packages/slate-history/CHANGELOG.md 为主线,逐条还原各版本变更背后的真实源码实现:从批处理(Batch)数据结构、withHistory插件工作流,到withMerging、withNewBatch、withoutSaving等 API 的引入动机与用法。读完本文,你将理解 Slate 历史机制的设计骨架,并能正确配置合并、拆分与忽略保存等边界行为。
一、这个包是什么:基于操作的 History 实现
从 packages/slate-history/package.json 可以确认,slate-history的自述是 "An operation-based history implementation for Slate editors.",关键词包含history、operation、undo、redo、stack、save,依赖关系上要求slate >= 0.114.3(这正是 0.115.0 版本变更中"提高最小 slate 版本至 0.114.3"的落地结果)。
整个包只有三个源文件,全部通过 packages/slate-history/src/index.ts 导出:
- history.ts:
History对象与Batch批处理的数据结构定义; - history-editor.ts:挂在编辑器上的
HistoryEditor接口与全部静态辅助方法; - with-history.ts:
withHistory高阶插件,负责注入撤销/重做逻辑。
配套的官方文档位于 docs/libraries/slate-history/README.md,分别展开讲解 withHistory、HistoryEditor 和 History。
二、核心数据模型:History 与 Batch
History对象持有两摞批处理栈——undos与redos,每一摞中的元素都是一个Batch。从 history.ts 源码可见其精确结构:
interface Batch { operations: Operation[] selectionBefore: Range | null } export interface History { redos: Batch[] undos: Batch[] }关键设计点在于:历史里存的不是文档快照,而是一组操作。Batch额外记录了selectionBefore(该批操作发生前的选区),用于撤销/重做后恢复光标位置——这正是 CHANGELOG 中 0.85.0 "Changes how selections are stored in the history resulting in more consistent results"(改进历史中选区的存储方式,使选区恢复更一致)所对应的实现载体。
类型守卫:isHistory
History.isHistory 是一个 TypeScript 类型守卫,它验证传入值是否满足History结构:
isHistory(value: any): value is History { return ( isObject(value) && Array.isArray(value.redos) && Array.isArray(value.undos) && (value.redos.length === 0 || Operation.isOperationList(value.redos[0].operations)) && (value.undos.length === 0 || Operation.isOperationList(value.undos[0].operations)) ) }注意它只校验redos/undos数组的存在性以及(非空时)首个批次的operations是否为合法操作列表,属于浅层结构校验。CHANGELOG 中 0.86.0 的 "Fix isHistory check" 正是对这一守卫逻辑的修正。
三、withHistory 插件:撤销/重做的工作流
withHistory 接收任意Editor实例,返回一个带有HistoryEditor能力的编辑器。它会覆写编辑器的apply、redo、undo三个方法,并注入history与writeHistory属性。
接入方式
文档 with-history.md 明确要求:与withReact搭配时,withHistory必须包裹在内层:
const [editor] = useState(() => withReact(withHistory(createEditor())))即先注入历史能力,再注入 React 绑定。由于withHistory返回T & HistoryEditor,TypeScript 用户通常还需要在CustomTypes中声明Editor的扩展类型(详见 docs/concepts/12-typescript.md)。
apply 拦截:决定是否保存、是否合并
withHistory的核心是重写apply,它在每次操作真正应用到文档前,执行三件事:
- 判断是否保存:调用
shouldSave(op, lastOp),源码中唯一的例外是set_selection类型操作——它永远不写入历史:
const shouldSave = (op: Operation, prev: Operation | undefined): boolean => { if (op.type === 'set_selection') { return false } return true }这正是 CHANGELOG 0.62.0 "Fixed history logic to not store focus and blur selection changes in the history"(不再把聚焦/失焦等选区变化写入历史)的实现。选区只在操作入栈时作为selectionBefore快照被记录,而不会单独成为历史条目,从而避免纯光标移动污染撤销栈。
- 判断是否合并:通过
shouldMerge(op, lastOp)判定新操作能否并入上一个批次。源码里只有两种情况允许合并:
const shouldMerge = (op: Operation, prev: Operation | undefined): boolean => { // 连续插入文本:offset 恰好衔接且路径相同 if ( prev && op.type === 'insert_text' && prev.type === 'insert_text' && op.offset === prev.offset + prev.text.length && Path.equals(op.path, prev.path) ) { return true } // 连续删除文本:offset 恰好衔接且路径相同 if ( prev && op.type === 'remove_text' && prev.type === 'remove_text' && op.offset + op.text.length === prev.offset && Path.equals(op.path, prev.path) ) { return true } return false }这意味着"连续键入"会合并成一次可撤销动作,而光标跳动、跨路径修改则会拆分为独立批次。
- 压栈并清理:新批次入栈时会记录
selectionBefore: e.selection;同时undos栈深度被限制为 100(while (undos.length > 100) { undos.shift() }),任何新保存都会清空redos栈(撤销后再编辑会丢失重做历史)。
undo / redo:逆操作回放
undo取出undos栈顶批次,对其中的操作逐个求逆并反转顺序后重新apply,再恢复selectionBefore,最后把该批次移交到redos栈:
const inverseOps = batch.operations.map(Operation.inverse).reverse() for (const op of inverseOps) { e.apply(op) } if (batch.selectionBefore) { Transforms.setSelection(e, batch.selectionBefore) }redo则把redos栈顶批次按原序重放,并先恢复其selectionBefore。两者都在HistoryEditor.withoutSaving与Editor.withoutNormalizing的包裹下执行,确保回放过程本身不会再次写入历史、也不会触发中间态规范化。整段回放与入栈逻辑见 with-history.ts。
writeHistory:历史推送的独立化
writeHistory(stack: 'undos' | 'redos', batch)是HistoryEditor接口中的一个实例方法,负责把批次压入指定栈。CHANGELOG 0.93.0 的 "Extracts history push to own function"(将历史推送抽取为独立函数)正是这一设计的由来——它把"向哪一摞栈写入"抽象出来,让undo/redo/apply三处复用同一条写栈路径,也便于外部在apply覆写链中观察或劫持入栈行为。
四、合并与保存控制:四个核心静态方法
HistoryEditor通过四个 WeakMap(SAVING、MERGING、SPLITTING_ONCE,见 history-editor.ts)为编辑器维护"保存中/合并中"标志位,并提供四个静态方法以同步函数块的形式控制历史行为。这些方法正是 CHANGELOG 中几个新增 API 变更的实体:
withMerging(0.109.0 新增)
withMerging(editor: HistoryEditor, fn: () => void): void { const prev = HistoryEditor.isMerging(editor) MERGING.set(editor, true) fn() MERGING.set(editor, prev) }把fn内产生的所有操作强制合并进上一条历史批次,适合"拼写检查批量替换"、"样式批量应用"等逻辑上属于一次用户动作的场景。注意实现采用先置true、执行后再恢复prev的方式,保证嵌套调用安全。
withNewBatch(0.110.3 新增)
withNewBatch(editor: HistoryEditor, fn: () => void): void { const prev = HistoryEditor.isMerging(editor) MERGING.set(editor, true) SPLITTING_ONCE.set(editor, true) fn() MERGING.set(editor, prev) SPLITTING_ONCE.delete(editor) }它先打开合并标志,再额外设置SPLITTING_ONCE。对应 with-history.ts 中的消费逻辑:遇到isSplittingOnce时强制merge = false并清除标志,从而让fn内的第一条操作强制开启一个新批次,后续操作照常合并。典型场景是"在上一批历史之后显式切分一个新的撤销节点"。
withoutMerging
将合并标志置为false,使fn内的操作不并入上一批次,但仍然会被保存为独立历史,适合希望每次变更都独立可撤销的情形。
withoutSaving
withoutSaving(editor: HistoryEditor, fn: () => void): void { const prev = HistoryEditor.isSaving(editor) SAVING.set(editor, false) try { fn() } finally { SAVING.set(editor, prev) } }fn内的操作完全不写入历史。CHANGELOG 0.113.1 的 "add try/finally block in withoutSaving method to ensure state restoration" 正是针对此方法:即便fn抛出异常,SAVING标志也会在finally中恢复原值,避免编辑器陷入"永不保存"的脏状态。这是历史包自身 API 健壮性的重要补丁。
在apply内部,isSaving/isMerging返回null(未设置)时才会走默认的shouldSave/shouldMerge逻辑,显式设置的标志优先——这也是上述四个方法能"覆盖"默认行为的原因。
五、版本演进一览:CHANGELOG 逐条还原
| 版本 | 类型 | 变更内容 | 对应实现 |
|---|---|---|---|
| 0.62.0 | Patch | 停止把 focus/blur 选区变化写入历史 | with-history.ts 中shouldSave对set_selection返回false |
| 0.62.0 | Minor | 引入 Changesets 管理发布,CHANGELOG 由 changeset 自动生成 | 仓库根目录 package.json 与各包package.json的版本联动 |
| 0.65.3 | Patch | 移除过期且不必要的immer依赖 | 依赖清理,不改变公开 API |
| 0.66.0 | Patch | 升级is-plain-object至 v5.0.0 | 类型守卫底层依赖升级 |
| 0.81.3 | Patch | 升级 next.js 与 source-map-loader | 构建工具链升级 |
| 0.85.0 | Minor | 改变历史中选区的存储方式,结果更一致 | Batch.selectionBefore结构(history.ts) |
| 0.86.0 | Patch | 修复isHistory检查 | history.ts |
| 0.93.0 | Minor | 将历史推送抽取为独立函数 | writeHistory实例方法(with-history.ts) |
| 0.100.0 | Minor | 升级依赖至 React 18、Node 20、TS 5.2 等 | 工程环境现代化 |
| 0.109.0 | Minor | 新增withMerging | history-editor.ts |
| 0.110.3 | Patch | 新增HistoryEditor.withNewBatch | history-editor.ts |
| 0.113.1 | Patch | withoutSaving增加 try/finally 保证状态恢复 | history-editor.ts |
| 0.115.0 | Patch | 修复部分场景下 undo 撤销过量的 bug | 合并判定与逆操作回放的边界修正 |
| 0.115.0 | Patch | 最低slate版本提升至 0.114.3 | package.json 中peerDependencies.slate |
| 0.115.0 | Patch | 优化isElement/isText/isNodeList/isEditor,移除is-plain-object依赖,默认浅层检查,深层检查需传{ deep: true } | 属于核心 slate 包的类型守卫优化,因 changesets 联动发布而出现在本包 CHANGELOG 中 |
两点说明:其一,0.115.0 中的类型守卫优化实质上是核心slate包的能力(可对照 packages/slate/src/interfaces/element.ts 等接口文件),由于 Changesets 采用联动发布,核心变更会同步出现在各子包的 CHANGELOG 中;其二,"fix certain undos undoing more than they should" 这类修复直接作用于 with-history.ts 的合并判定与回放逻辑,提醒我们在升级版本时关注 undo 行为的变化。
六、实践建议与边界注意事项
- 组合顺序不可颠倒:
withReact(withHistory(createEditor())),历史能力必须先于 React 绑定注入,否则 React 层覆写的apply会破坏历史记录链。 - 区分四种历史控制:
withMerging(合并但保存)、withNewBatch(先拆后合)、withoutMerging(不合并但保存)、withoutSaving(完全不保存)。做"撤销粒度"设计时,先用withNewBatch显式切分,再用withMerging合并内部细节。 - 选区不进历史:纯光标移动不会产生历史条目,这是刻意的设计(0.62.0 起),不要把选区恢复失败误认为 bug。
- 栈深度上限 100:
undos超限时最旧的批次被丢弃,超长文档的深层撤销受限,属于有意的内存保护。 - 版本门槛:使用
withNewBatch需slate-history >= 0.110.3,使用withMerging需>= 0.109.0;整体要求slate >= 0.114.3(0.115.0 起),可对照本仓库 packages/slate/CHANGELOG.md 确认核心版本配套。
从 0.62.0 到 0.115.0,slate-history的演进始终围绕同一个目标:让"基于操作的历史"既灵活(合并、拆分、忽略可自由控制)又可靠(选区恢复、状态复位、浅层校验)。结合 packages/slate-history/src 下的三个源文件与 docs/libraries/slate-history 文档,即可完整掌握这一机制,并在自己的编辑器中精准定制撤销/重做体验。
【免费下载链接】slateA completely customizable framework for building rich text editors. (Currently in beta.)项目地址: https://gitcode.com/gh_mirrors/sl/slate
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考