Slate v2 History 性能基线批次:withHistory(createEditor())对比通道的落地与测量
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
本文档基于仓库中的 2026-04-11-slate-v2-history-perf-batch.md 展开,围绕 Slate v2 重写过程中历史记录(undo/redo)性能基线的建立与验收决策展开。文章以该计划文档为骨架,结合当前仓库中的源码实现(with-history.ts、history.ts)、测试用例(with-history.spec.tsx)与基准目标配置(slate-v2.json、slate-v2-latest.json)进行深度扩充,帮助读者理解:
- Slate v2 的历史记录实现与旧版 Slate 在架构与合并启发式上的差异;
- 为什么“typing 合并启发式”是历史性能的生死线;
- 如何通过
bench:history:compare:local对比通道量化 undo/redo 成本; - 在“比旧版慢但不阻塞发布”的灰色地带,如何用数据而非感觉做出验收决策。
阅读本文前建议先了解 Slate v2 项目背景(见 docs/slate-v2/master-roadmap.md),本文聚焦于历史记录性能这一条具体验收通道。
一、背景:为什么需要一条withHistory(createEditor())的性能对比通道
1.1 Slate v2 重写带来的历史记录不确定性
Slate v2 是对 Slate 核心的重新实现,其中 with-history.ts 对应的历史记录插件负责把每次操作写入 undo/redo 栈,并支持将同类型、同路径的连续操作合并成单个批(batch),从而让“连续输入一整段文字”能够一次撤销。旧版 Slate 的slate-history经过多年打磨,积累了成熟的合并启发式;而 v2 重写后的历史实现属于新代码,其行为与成本都需要重新验证。
1.2 该批次的目标
本计划文档(2026-04-11-slate-v2-history-perf-batch.md)明确列出的目标是:
Close the missing
withHistory(createEditor())perf lane for strict RC acceptance and stop hand-waving about history cost.
即在严格 RC(Release Candidate)验收前,补上withHistory(createEditor())这条缺失的性能通道,并停止对“历史记录到底多贵”的含糊其辞。该目标隐含两个验收维度:
- 行为正确:undo/redo 必须能正确还原文档与选区;
- 成本可量化:在大型文档上的 undo/redo 延迟必须能被测量、被记录、被比较,而不是靠“应该还行吧”来敷衍。
二、当前仓库中历史记录的实现骨架
2.1withHistory的核心职责
with-history.ts 中的withHistory装饰器做了三件事:
- 在编辑器上初始化
history状态:e.history = { redos: [], undos: [] }; - 拦截
apply,决定每个操作是否需要保存(shouldSave)、是否需要与上一个操作合并(shouldMerge)、以及是否强制开启新批(setSplittingOnce); - 提供
undo/redo方法,通过OperationApi.inverse反转操作实现撤销,并在withoutSaving/withoutNormalizing的包裹下执行,避免撤销本身再次写入历史或触发规范化。
2.2 合并启发式:性能与体验的生死线
shouldMerge是历史记录性能的核心逻辑(with-history.ts):
const shouldMerge = (op: Operation, prev: Operation | undefined): boolean => { if ( prev && op.type === 'insert_text' && prev.type === 'insert_text' && op.offset === prev.offset + prev.text.length && PathApi.equals(op.path, prev.path) ) { return true; } if ( prev && op.type === 'remove_text' && prev.type === 'remove_text' && op.offset + op.text.length === prev.offset && PathApi.equals(op.path, prev.path) ) { return true; } return false; };其逻辑要点:
- 连续插入:
insert_text与上一个insert_text在同一路径、且新操作偏移量恰好等于上一个操作偏移量加文本长度时合并——即“在光标处连续打字”会合成一个批; - 连续删除:
remove_text与上一个remove_text在同一路径、且新操作的结束位置恰好衔接上一个操作的开始位置时合并——即“连续按退格键”会合成一个批; - 其余情况(如切换路径、混合插入删除、结构变化)一律不合并,各自成批。
2.3 保存策略与批管理
shouldSave(with-history.ts)只排除set_selection操作,其余操作全部计入历史。apply内对批的处理逻辑为:
- 若当前处于
withoutSaving(如 undo/redo 执行期),不写历史; - 若处于
withoutMerging,强制不合并; - 若处于
withNewBatch/setSplittingOnce,强制当前操作开启新批; - 否则按
shouldMerge判断并入上一个批; - 每次写入后若
undos超过 100 个批则从队首丢弃,并清空redos——这是历史上限策略,防止无限增长。
HistoryApi(history.ts)则提供了withMerging、withoutMerging、withNewBatch、withoutSaving等弱映射标记工具,供上层插件按需控制历史行为。
三、本次批次的具体工作
3.1 新增对比通道history.mjs
计划文档记录的“Kept Work”第一项是新增对比脚本:
history.mjs注意:该路径是作者本地机器上的绝对路径,在当前仓库中并不存在(本仓库未包含该脚本文件)。我们可以从仓库内的基准目标配置推断其职责——slate-v2.json 中登记了名为history-compare的基准目标:
{ "id": "history-compare", "question": "Does Slate v2 match legacy Slate for undo/redo typing and fragment history?", "owner": "slate-v2", "family": "history", "kind": "compare", "cwd": ".tmp/slate-v2", "command": "HISTORY_BENCH_LEGACY_REPO=../../../slate bun run bench:history:compare:local", "metrics": { "primary": "history_compare_worst_p95_ratio", "direction": "lower", "unit": "ratio", "printsMetric": true }, "correctness": { "command": "bun check", "policy": "Use the target-specific correctness command when one exists; bun check is the fallback before promotion." }, "artifacts": [ { "path": ".tmp/slate-v2/tmp/slate-history-compare-benchmark.json", "required": true } ], "thresholds": { "promotion": "history_compare_worst_p95_ratio at or below 2.0 with bun check green", "plateau": "stop after 2 correctness-green packets with less than 5% gain" } }从该配置可以推断,history.mjs的职责是:
- 在同一进程内分别构建 Slate v2 的
withHistory(createEditor())编辑器与旧版 Slate 的历史编辑器; - 在相同的操作序列(打字、粘贴 fragment)上分别执行 undo/redo;
- 输出双方耗时,计算最差 p95 比率(
history_compare_worst_p95_ratio),并写入.tmp/slate-v2/tmp/slate-history-compare-benchmark.json作为验收产物。
3.2 接通命令bun run bench:history:compare:local
命令在基准目标配置中体现为HISTORY_BENCH_LEGACY_REPO=../../../slate bun run bench:history:compare:local,即通过环境变量HISTORY_BENCH_LEGACY_REPO指向旧版 Slate 仓库,再运行bench:history:compare:local脚本。历史对比通道还出现在仓库的基准注册表(benchmark-registry.json)以及历史结果归档(slate-v2-latest.json)中,说明该通道已进入正式基准管理流程。
3.3 恢复旧版风格的 typing 合并启发式
计划文档记录的第三项工作是:
restored legacy-style typing merge heuristics in
with-history.ts
“恢复旧版风格的 typing 合并启发式”是本批次的核心修复。结合上文shouldMerge的实现,恢复的正是“同路径连续 insert_text 合并 / 连续 remove_text 合并”这两条规则。在此之前,v2 重写的历史实现可能缺少或改动了这些规则,导致每一次按键都各自成批。
四、测量结果:现状与基线
4.1 基准场景
计划文档给出的测量场景为:
- 5000 blocks:文档包含 5000 个块;
- 20 typed characters:模拟连续输入 20 个字符;
- 200 inserted fragment blocks:模拟一次插入包含 200 个块的 fragment。
共四条测量通道:typing undo、typing redo、fragment undo、fragment redo。
4.2 当前版本(Slate v2)数据
计划文档记录的最新一轮读数:
| 通道 | 耗时 |
|---|---|
| typing undo | 29.71ms |
| typing redo | 20.53ms |
| fragment undo | 27.21ms |
| fragment redo | 42.95ms |
4.3 旧版(Legacy Slate)数据
| 通道 | 耗时 |
|---|---|
| typing undo | 0.36ms |
| typing redo | 0.49ms |
| fragment undo | 1.92ms |
| fragment redo | 11.18ms |
4.4 关键结论一:v2 仍比旧版慢
计划文档明确承认:
So yes, current
slate-historyis still slower than legacy.
按上表计算,typing undo 相差约 82 倍(29.71ms vs 0.36ms),fragment redo 相差约 3.8 倍(42.95ms vs 11.18ms)。需要注意:这些数字来自计划文档作者在 2026-04-11 当日的测量,属于当时版本的快照,并非当前仓库代码的实时结果;当前仓库并未包含history.mjs脚本与其产物,读者如需复现,需自行搭建对比环境。该差距指向 v2 历史实现仍有优化空间,例如批的selectionBefore/selectionAfter记录、逆操作计算、以及批结构本身的分配成本。
4.5 关键结论二:先修掉的才是真正的灾难
计划文档记录的修复前数据是:
before restoring merge heuristics, typing-burst undo/redo was roughly
~490ms
在恢复合并启发式之前,打字爆发场景(typing-burst)的 undo/redo 大约在490ms量级——这已经接近“用户可感知卡顿”的边界,属于真正的 RC blocker。修复后同一通道回到20-40ms低延迟区间:
after the fix, the same lane is back in the low
20-40msband
这正是合并启发式价值的直接证明:如果连续 20 次按键被拆成 20 个独立批,undo 就要反转 20 组操作;合并成一个批后,undo 只反转一组操作。批数量从线性增长降为常数级,undo/redo 的成本随之骤降。
五、验收决策:保留通道,但不重新打开 RC
5.1 结论
计划文档的最终结论(Verdict)是:
- Keep the lane.—— 保留该对比通道,作为历史性能的长期监测;
- Do not reopen RC on this alone.—— 不因“v2 比旧版慢”这一点单独重新打开 RC 流程。
5.2 理由拆解
计划文档给出的三条理由:
- 这是 headless 对比通道,不是用户可见的浏览器回归:本通道只测量
withHistory(createEditor())在 headless 环境下的操作成本,不涉及渲染、布局、输入法、滚动等用户可感知因素,因此不属于浏览器端回归; - 剩余成本虽真实存在,但在 5000 块文档上仍处于几十毫秒的低延迟区间:29.71ms、20.53ms、27.21ms、42.95ms 这些数字对用户操作来说仍然舒适,不构成体验灾难;
- “慢于旧版但不阻塞”的策略早有先例:项目既有的 deep-interview blocker 政策已经写明——只要实际体验仍然舒适,慢于旧版可以不作为阻塞项。
5.3 与项目验收体系的衔接
在仓库的基准目标配置中,history-compare的 promotion 阈值是history_compare_worst_p95_ratio at or below 2.0 with bun check green,即“最差 p95 比率 ≤ 2.0 且bun check通过”才允许晋升。而计划文档中的 4.4 节数据显示最差比率约为 82 倍,远高于 2.0 阈值——这说明该目标在 2026-04-11 时点处于“未晋升但已登记、已测量、已监测”的状态,符合“保留通道但不重新打开 RC”的决策逻辑。后续版本是否达到 promotion 阈值,需要以实际基准产物(.tmp/slate-v2/tmp/slate-history-compare-benchmark.json)为准。
六、从代码与测试验证历史行为
6.1 单元测试覆盖的合并语义
with-history.spec.tsx 用测试锁定了合并语义:
- 选区操作不入历史:
editor.select(...)后history.undos与history.redos均为空(对应shouldSave排除set_selection); - 连续 insertText 合并成一个批:连续
insertText('t')、insertText('w')、insertText('o')后history.undos长度为 1,批内operations长度为 3,一次undo()即可全部还原; - 连续 remove_text 合并成一个批:连续两次
delete({ reverse: true })后history.undos长度为 1,批内operations长度为 2,一次undo()还原整个单词。
这些测试正是“恢复 typing 合并启发式”的行为证据:测试要求连续打字/删除必须合成单批,否则undo步数会与按键次数一致,行为与性能双双退化。
6.2 撤销与重做的实现细节
undo的实现(with-history.ts)值得注意:
- 对批内操作逐一
OperationApi.inverse后reverse(),得到逆操作序列,再逐个apply; - 整个过程包裹在
withoutSaving与withoutNormalizing中,避免撤销动作本身污染历史或触发规范化级联; - 撤销后把该批连同撤销前的选区(
selectionAfter)写回redos栈,供redo使用。
redo则是把redos栈顶批的操作原样重放,并恢复selectionAfter ?? selectionBefore选区。
七、如何在当前仓库中查看与运行该通道
由于本仓库为只读状态,且对比脚本(scripts/benchmarks/core/compare/history.mjs)并未包含在本仓库中,读者可以:
- 阅读基准目标配置 slate-v2.json 了解
history-compare目标的问题定义、命令、指标与阈值; - 查看历史结果归档 slate-v2-latest.json 了解该目标的登记状态;
- 阅读 history.ts 与 with-history.ts 源码理解历史记录实现;
- 阅读 with-history.spec.tsx 与 history.spec.tsx 掌握合并语义的行为契约;
- 如需在自己搭建的对比环境中复现,命令形态为
HISTORY_BENCH_LEGACY_REPO=<legacy-slate-path> bun run bench:history:compare:local,产物路径为.tmp/slate-v2/tmp/slate-history-compare-benchmark.json。
八、总结
本批次(2026-04-11)完成的核心工作是:
- 新增
history-compare对比通道,把withHistory(createEditor())的 undo/redo 成本从“口头估计”变成可重复测量的基准目标; - 接通
bench:history:compare:local命令,进入正式基准管理流程,并登记在 slate-v2.json 与 slate-v2-latest.json 中; - 恢复旧版风格的 typing 合并启发式,把 typing-burst undo/redo 从
~490ms拉回20-40ms区间——这是把“RC blocker”降级为“严格验收证明行”的关键修复; - 得出验收决策:v2 历史实现仍慢于旧版,但在 5000 块文档上处于几十毫秒舒适区间,且属 headless 对比通道而非用户可见回归,因此保留通道、不单独重新打开 RC。
这条通道的价值在于:它让“历史记录贵不贵”这个问题从此有了数字答案,也让未来的优化(如批结构、逆操作计算、选区快照)有了可对比的基线。正如计划文档所说——这是“RC blocker”与“比旧版慢但依然舒适地快”之间的分界线。
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考