news 2026/9/16 11:28:46

Slate v2 History 性能基线批次:`withHistory(createEditor())` 对比通道的落地与测量

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Slate v2 History 性能基线批次:`withHistory(createEditor())` 对比通道的落地与测量

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 missingwithHistory(createEditor())perf lane for strict RC acceptance and stop hand-waving about history cost.

即在严格 RC(Release Candidate)验收前,补上withHistory(createEditor())这条缺失的性能通道,并停止对“历史记录到底多贵”的含糊其辞。该目标隐含两个验收维度:

  1. 行为正确:undo/redo 必须能正确还原文档与选区;
  2. 成本可量化:在大型文档上的 undo/redo 延迟必须能被测量、被记录、被比较,而不是靠“应该还行吧”来敷衍。

二、当前仓库中历史记录的实现骨架

2.1withHistory的核心职责

with-history.ts 中的withHistory装饰器做了三件事:

  1. 在编辑器上初始化history状态:e.history = { redos: [], undos: [] }
  2. 拦截apply,决定每个操作是否需要保存(shouldSave)、是否需要与上一个操作合并(shouldMerge)、以及是否强制开启新批(setSplittingOnce);
  3. 提供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)则提供了withMergingwithoutMergingwithNewBatchwithoutSaving等弱映射标记工具,供上层插件按需控制历史行为。

三、本次批次的具体工作

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 inwith-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 undo29.71ms
typing redo20.53ms
fragment undo27.21ms
fragment redo42.95ms

4.3 旧版(Legacy Slate)数据

通道耗时
typing undo0.36ms
typing redo0.49ms
fragment undo1.92ms
fragment redo11.18ms

4.4 关键结论一:v2 仍比旧版慢

计划文档明确承认:

So yes, currentslate-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 low20-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 理由拆解

计划文档给出的三条理由:

  1. 这是 headless 对比通道,不是用户可见的浏览器回归:本通道只测量withHistory(createEditor())在 headless 环境下的操作成本,不涉及渲染、布局、输入法、滚动等用户可感知因素,因此不属于浏览器端回归;
  2. 剩余成本虽真实存在,但在 5000 块文档上仍处于几十毫秒的低延迟区间:29.71ms、20.53ms、27.21ms、42.95ms 这些数字对用户操作来说仍然舒适,不构成体验灾难;
  3. “慢于旧版但不阻塞”的策略早有先例:项目既有的 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.undoshistory.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.inversereverse(),得到逆操作序列,再逐个apply
  • 整个过程包裹在withoutSavingwithoutNormalizing中,避免撤销动作本身污染历史或触发规范化级联;
  • 撤销后把该批连同撤销前的选区(selectionAfter)写回redos栈,供redo使用。

redo则是把redos栈顶批的操作原样重放,并恢复selectionAfter ?? selectionBefore选区。

七、如何在当前仓库中查看与运行该通道

由于本仓库为只读状态,且对比脚本(scripts/benchmarks/core/compare/history.mjs)并未包含在本仓库中,读者可以:

  1. 阅读基准目标配置 slate-v2.json 了解history-compare目标的问题定义、命令、指标与阈值;
  2. 查看历史结果归档 slate-v2-latest.json 了解该目标的登记状态;
  3. 阅读 history.ts 与 with-history.ts 源码理解历史记录实现;
  4. 阅读 with-history.spec.tsx 与 history.spec.tsx 掌握合并语义的行为契约;
  5. 如需在自己搭建的对比环境中复现,命令形态为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)完成的核心工作是:

  1. 新增history-compare对比通道,把withHistory(createEditor())的 undo/redo 成本从“口头估计”变成可重复测量的基准目标;
  2. 接通bench:history:compare:local命令,进入正式基准管理流程,并登记在 slate-v2.json 与 slate-v2-latest.json 中;
  3. 恢复旧版风格的 typing 合并启发式,把 typing-burst undo/redo 从~490ms拉回20-40ms区间——这是把“RC blocker”降级为“严格验收证明行”的关键修复;
  4. 得出验收决策:v2 历史实现仍慢于旧版,但在 5000 块文档上处于几十毫秒舒适区间,且属 headless 对比通道而非用户可见回归,因此保留通道、不单独重新打开 RC。

这条通道的价值在于:它让“历史记录贵不贵”这个问题从此有了数字答案,也让未来的优化(如批结构、逆操作计算、选区快照)有了可对比的基线。正如计划文档所说——这是“RC blocker”与“比旧版慢但依然舒适地快”之间的分界线。

【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

AI论文写作工具实测:10款神器提升学术效率

1. 为什么需要AI论文写作工具&#xff1f;作为一名自考多年的老考生&#xff0c;我深知论文写作的痛苦。从选题构思到文献综述&#xff0c;从格式调整到查重降重&#xff0c;每个环节都能让人抓狂。特别是对于在职自考的同学来说&#xff0c;白天上班晚上写论文&#xff0c;时间…

作者头像 李华
网站建设 2026/9/16 11:27:18

Puppeteer+Chromium V8电商自动化实战

简介&#xff1a;这是一套基于PHP开发的V8版本京东淘宝自动抢单系统源码&#xff0c;面向电商自动化工具开发者、个人创业者及技术爱好者&#xff0c;解决多平台抢单调度、资金结算与会员运营一体化管理问题。资源包共2000个文件&#xff0c;以577个PHP核心逻辑文件为主干&…

作者头像 李华
网站建设 2026/9/16 11:25:46

51单片机宠物喂食器闭环控制系统设计与Proteus仿真

简介&#xff1a;本资源是一套面向电子信息类本科生与单片机初学者的毕业设计级智能宠物喂食系统完整开发包&#xff0c;解决主人外出时宠物无人照看的现实需求&#xff0c;涵盖自动喂食、定时加水、感应式铲屎及上位机监控四大核心功能。资源共106个文件&#xff0c;包含13份P…

作者头像 李华
网站建设 2026/9/16 11:25:09

滤波D形连接器选型指南:读懂70dB@1GHz背后的EMI防护逻辑

去年调一台伺服驱动器的辐射发射&#xff0c;频率在500MHz附近反复超标&#xff0c;排查到最后发现根源居然是一个不起眼的DB9调试口。把板上那级RC网络拆掉&#xff0c;换成滤波D形连接器&#xff0c;频谱仪上的峰值肉眼可见往下掉。从那以后&#xff0c;滤波D-Sub在我眼里就不…

作者头像 李华
网站建设 2026/9/16 11:23:04

MS5803压力传感器与R7KA8D2KFLCAC运放协同设计实战

1. 为什么选MS5803-14BA01-00和R7KA8D2KFLCAC组合&#xff1f;——从芯片手册到实测误差的硬核选型逻辑压力测量不是把传感器焊上去就能读数的事。我做过三轮工业级水下设备压力监测项目&#xff0c;第一轮用过BMP280&#xff0c;第二轮试过ADS1115压阻式应变片&#xff0c;第三…

作者头像 李华