news 2026/9/19 4:17:35

Slate History 演进与实现解析:从 CHANGELOG 看操作级 undo/redo 机制的迭代

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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插件工作流,到withMergingwithNewBatchwithoutSaving等 API 的引入动机与用法。读完本文,你将理解 Slate 历史机制的设计骨架,并能正确配置合并、拆分与忽略保存等边界行为。

一、这个包是什么:基于操作的 History 实现

从 packages/slate-history/package.json 可以确认,slate-history的自述是 "An operation-based history implementation for Slate editors.",关键词包含historyoperationundoredostacksave,依赖关系上要求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对象持有两摞批处理栈——undosredos,每一摞中的元素都是一个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能力的编辑器。它会覆写编辑器的applyredoundo三个方法,并注入historywriteHistory属性。

接入方式

文档 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,它在每次操作真正应用到文档前,执行三件事:

  1. 判断是否保存:调用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快照被记录,而不会单独成为历史条目,从而避免纯光标移动污染撤销栈。

  1. 判断是否合并:通过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 }

这意味着"连续键入"会合并成一次可撤销动作,而光标跳动、跨路径修改则会拆分为独立批次。

  1. 压栈并清理:新批次入栈时会记录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.withoutSavingEditor.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(SAVINGMERGINGSPLITTING_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.0Patch停止把 focus/blur 选区变化写入历史with-history.ts 中shouldSaveset_selection返回false
0.62.0Minor引入 Changesets 管理发布,CHANGELOG 由 changeset 自动生成仓库根目录 package.json 与各包package.json的版本联动
0.65.3Patch移除过期且不必要的immer依赖依赖清理,不改变公开 API
0.66.0Patch升级is-plain-object至 v5.0.0类型守卫底层依赖升级
0.81.3Patch升级 next.js 与 source-map-loader构建工具链升级
0.85.0Minor改变历史中选区的存储方式,结果更一致Batch.selectionBefore结构(history.ts)
0.86.0Patch修复isHistory检查history.ts
0.93.0Minor将历史推送抽取为独立函数writeHistory实例方法(with-history.ts)
0.100.0Minor升级依赖至 React 18、Node 20、TS 5.2 等工程环境现代化
0.109.0Minor新增withMerginghistory-editor.ts
0.110.3Patch新增HistoryEditor.withNewBatchhistory-editor.ts
0.113.1PatchwithoutSaving增加 try/finally 保证状态恢复history-editor.ts
0.115.0Patch修复部分场景下 undo 撤销过量的 bug合并判定与逆操作回放的边界修正
0.115.0Patch最低slate版本提升至 0.114.3package.json 中peerDependencies.slate
0.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超限时最旧的批次被丢弃,超长文档的深层撤销受限,属于有意的内存保护。
  • 版本门槛:使用withNewBatchslate-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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/19 4:16:58

机载LiDAR数据处理全流程:从POS解算到DEM生成的关键技术解析

简介:一份系统讲解机载激光雷达组成与数据处理流程的PPT课件,适合测绘、电力、林业、环境监测等领域的初学者、相关专业学生及教学培训使用。课件围绕LiDAR基本工作原理展开,不仅介绍了激光雷达设备、GPS/IMU定位定姿系统、数据记录系统等硬件…

作者头像 李华
网站建设 2026/9/19 4:15:12

纵横交叉算法优化神经网络结合小波变换的电力负荷预测方法

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/19 4:13:43

ResNet18+LSTM活体检测实战:基于OULU-NPU视频时序建模

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/19 4:13:27

比ES快5倍的搜索引擎:MeiliSearch轻量级搜索实战指南

这些年做后端,最常被业务方问的一句话就是:“数据量也不大,为什么搜索这么慢?”大多数时候问题不在数据量,而在搜索引擎选型。Elasticsearch确实是搜索界的扛把子,分布式、PB级、聚合分析、日志检索&#x…

作者头像 李华