Slate History 演进与实现解析:从 CHANGELOG 看操作级 undo/redo 机制的迭代
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.tsHistory对象与Batch批处理的数据结构定义history-editor.ts挂在编辑器上的HistoryEditor接口与全部静态辅助方法with-history.tswithHistory高阶插件负责注入撤销/重做逻辑。配套的官方文档位于 docs/libraries/slate-history/README.md分别展开讲解 withHistory、HistoryEditor 和 History。二、核心数据模型History 与 BatchHistory对象持有两摞批处理栈——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改进历史中选区的存储方式使选区恢复更一致所对应的实现载体。类型守卫isHistoryHistory.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 HistoryEditorTypeScript 用户通常还需要在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栈深度被限制为 100while (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通过四个 WeakMapSAVING、MERGING、SPLITTING_ONCE见 history-editor.ts为编辑器维护保存中/合并中标志位并提供四个静态方法以同步函数块的形式控制历史行为。这些方法正是 CHANGELOG 中几个新增 API 变更的实体withMerging0.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的方式保证嵌套调用安全。withNewBatch0.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内的操作不并入上一批次但仍然会被保存为独立历史适合希望每次变更都独立可撤销的情形。withoutSavingwithoutSaving(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.0Patch停止把 focus/blur 选区变化写入历史with-history.ts 中shouldSave对set_selection返回false0.62.0Minor引入 Changesets 管理发布CHANGELOG 由 changeset 自动生成仓库根目录 package.json 与各包package.json的版本联动0.65.3Patch移除过期且不必要的immer依赖依赖清理不改变公开 API0.66.0Patch升级is-plain-object至 v5.0.0类型守卫底层依赖升级0.81.3Patch升级 next.js 与 source-map-loader构建工具链升级0.85.0Minor改变历史中选区的存储方式结果更一致Batch.selectionBefore结构history.ts0.86.0Patch修复isHistory检查history.ts0.93.0Minor将历史推送抽取为独立函数writeHistory实例方法with-history.ts0.100.0Minor升级依赖至 React 18、Node 20、TS 5.2 等工程环境现代化0.109.0Minor新增withMerginghistory-editor.ts0.110.3Patch新增HistoryEditor.withNewBatchhistory-editor.ts0.113.1PatchwithoutSaving增加 try/finally 保证状态恢复history-editor.ts0.115.0Patch修复部分场景下 undo 撤销过量的 bug合并判定与逆操作回放的边界修正0.115.0Patch最低slate版本提升至 0.114.3package.json 中peerDependencies.slate0.115.0Patch优化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。栈深度上限 100undos超限时最旧的批次被丢弃超长文档的深层撤销受限属于有意的内存保护。版本门槛使用withNewBatch需slate-history 0.110.3使用withMerging需 0.109.0整体要求slate 0.114.30.115.0 起可对照本仓库 packages/slate/CHANGELOG.md 确认核心版本配套。从 0.62.0 到 0.115.0slate-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),仅供参考