Slate v2 大文档范围删除性能重构:replace_children 子区间替换操作的规划与落地
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
本文是 plate 仓库中 Slate v2 开发线的技术规划与实现复盘,核心主题是为大文档场景下的剪切/删除(Issue #5992)引入一种新的核心操作原语replace_children:把"删除选中的整块顶层子节点"从逐个remove_node循环收敛为一次父子数组窗口替换,从而把操作成本从"与文档规模成正比"降为"与选中范围成正比"。读完本文,你将掌握这一操作契约的设计动机、候选方案取舍、内部运行时降级路径、回归证明矩阵、基准阈值,以及最终落地后的实测数据与 Issue 状态判定逻辑。
1. 问题背景:Issue #5992 与大文档剪切成本
Slate v2 开发线追踪的 Issue #5992 是一个经典的大文档编辑性能问题:在超大文档中使用剪切(cut)功能耗时严重。gitcrawl 实时台账 中的原始记录为:"In the case of large documents, using the cut function took a lot of time."(大文档下使用剪切功能非常耗时),归类为 bug,所属集群为 large-document-edit-performance。
该计划文档记录的最新技术状态:在 50,000 个块的文档中执行"选中两个节点后剪切"这一最小负载,copy-plus-delete(复制加删除)耗时621.26ms,edit-only(仅编辑删除)耗时511.47ms,共产生3个操作。当前证明已将其从早期"多秒级"的旧主人(owner)改善到毫秒级,但距离可接受的交互目标仍有差距,因此台账中 #5992 的状态是Improves而非Fixes(见 issue-coverage-matrix.md 第 315 行,以及 benchmark-candidate-map.md 的 #5992 小节)。
1.1 为什么逐个remove_node循环不是答案
当前删除路径的实现(计划文档引用 slate-v2 开发树中的transforms-text/delete-text.ts)在检测到"精确的整块顶层范围"后,会从endIndex到startIndex循环,对每一个被删除的顶层子节点单独发出一条remove_node操作。每个remove_node都要完整走一遍:
- 父节点 children 数组的一次整体替换(见 operation.ts 中
remove_node的类型定义:node+path); - selection / path 变换;
- dirty path 分类;
- 快照与提交(snapshot/commit)工作。
也就是说,删除 N 个块就产生 N 条操作、N 次完整的变换与提交管线。这正是"操作成本随文档规模线性放大"的根源——而实际上用户只改了 2 个块。计划文档的结论非常直接:3个操作比旧行为好,但"不要用当前remove_node循环来关闭 #5992"。
1.2 为什么虚拟化不是答案
计划文档明确划出了边界:不要用虚拟化(virtualization)作为剪切/删除的修复手段。理由是该成本是"模型与操作成本"(model and operation cost),不是"DOM/渲染成本"(DOM/render cost)。虚拟化解决的是渲染层的问题,而 #5992 的瓶颈在数据模型层的操作放大,两者不能互相替代。这一判断也被列入第 17 节的硬性切割(Hard Cuts)清单。
2. 决策论证:候选方案与取舍
计划文档的决策章节(Decision Brief)给出五个候选方案并逐一裁决,核心决策原则有四条:
- Slate 操作仍然是对外的模型(external model);
- 一次用户剪切/删除不应变成许多条结构化操作;
- 操作负载应与"被改变的范围"成正比,而不是与整个文档成正比(除非是有意整篇替换);
- 协作(collaboration)与历史(history)需要确定性的逆操作(inverse)与重放(replay)。
| 候选方案 | 裁决 | 理由 |
|---|---|---|
保留当前remove_node循环 | 拒绝(reject) | 每个被删子节点发一条remove_node,每条都经过子数组替换、selection/path 变换、dirty 分类、快照/提交工作 |
用根级replace_fragment做剪切/删除 | 仅过渡(transitional only) | 只产生一条操作,但在大文档的小范围编辑中存储了完整的旧/新子数组,负载形状对 #5992 是错的 |
新增delete_fragment | 拒绝(reject) | 删除本质就是newChildren: []的替换;独立的删除操作会重复变换/逆操作逻辑 |
新增/泛化为replace_children | 硬化后选择(choose after hardening) | 符合 Slate 形状、按范围缩放、对逆操作友好、兼容粘贴,最接近 ProseMirror 的 replace-step 经验,且不照搬整数位置模型 |
| 在操作系统之外做可变根子数组拼接 | 拒绝(reject) | 对 history/collaboration 隐藏真实变更,重新引入快照绕过风险 |
2.1replace_fragment的定位:正确的证明产物,错误的操作名
replace_fragment是此前"最佳粘贴策略"计划留下的证明产物(proof artifact),它证明了"单次操作完成顶层子数组替换"是可行的。但计划文档给出了三条否定性判断:
- 命名是 paste 形状的:
fragment语义来自剪贴板/粘贴,而引擎真正需要的原语是"父节点的子区间拼接"(a parent child-range splice),不应以产品事件(paste)来命名模型机制; - 负载过宽:根级使用时携带完整
children与newChildren数组,对大文档中的小范围删除来说内存与复制成本都不合适; - 不宜冻结为最终操作:实现期间应把当前的语义用途迁移到
replace_children,仅在需要安全落地时才保留临时内部桥接,发布前移除。
2.2 命名决策:为什么是replace_children而不是splice_children
计划文档的维护者反对意见账本记录了一个有代表性的争论:为什么不直接叫splice_children(JS 数组拼接语义)?结论是 Slate 的操作命名传统是描述模型动作(insert_node、remove_node、set_node、split_node、merge_node),而不是原始 JS API。replace_children对 undo/collab 负载更清晰,也更贴近tx.value.replace的既有命名习惯。同时delete_fragment因"过窄"被拒绝。
3. 目标操作契约:ReplaceChildrenOperation 详解
计划文档接受的核心实现目标是如下操作类型:
type ReplaceChildrenOperation<V extends Value = Value> = { type: "replace_children"; path: Path; index: number; children: DescendantIn<V>[]; newChildren: DescendantIn<V>[]; selection: Range | null; newSelection: Range | null; };各字段语义:
path:父节点路径。path: []表示根节点自身(此时若范围覆盖全部子节点,即为整篇文档替换);index:被替换子窗口的起始下标;children:被移除的旧子节点窗口(仅限被移除的窗口,而不是整个父节点的全部子节点);newChildren:替换后的新子节点窗口(删除场景下为[]);selection/newSelection:操作前后选择范围,用于让选择修复内聚到操作契约中,而非事后修补。
该操作作为下述场景的统一实现基座:
- 删除整块顶层子范围;
- 粘贴时替换选中的顶层块;
- 当
path: []且范围覆盖全部子节点时的整篇文档替换; - 未来的 fragment 拟合(fragment fitting):替换父节点内部的一个子窗口。
对比当前仓库快照中 operation.ts 列出的经典操作族(insert_node、remove_node、set_node、split_node、merge_node、move_node、insert_text、remove_text、set_selection),可以看到replace_children是首个"父子数组窗口级"的结构化操作:它把过去由多条insert_node/remove_node组合表达的变更压缩为一条自包含的操作,且负载与变更窗口成正比。
3.1 对应用层透明:事务用法保持不变
尽管新增了核心操作,应用层代码不需要任何改动:
editor.update((tx) => { tx.text.delete({ at: selection }); });计划文档明确要求:除非最终操作表面(operation surface)被刻意设为公开,否则公共文档不应教用户手写构造该操作。普通应用代码保持editor.update形态不变——这一点与当前仓库中 editor-transforms.ts 等提供的变换层设计一脉相承。
3.2 内部降级(Internal Lowering)规则
- 精确的子范围删除 → 降级为
replace_children,其中newChildren: []; - 顶层粘贴替换 → 降级为
replace_children,携带插入的子节点; - 整篇文档替换 → 使用
path: []、index: 0的replace_children。
4. 内部运行时流程:从删除到单次子区间替换
计划文档给出了目标运行时流水线:
tx.text.delete({ at }) -> detect exact replaceable child window -> compute parent path + index + removed children + new children -> apply replace_children once -> map selection to newSelection -> classify dirty parent + changed child window -> history stores one inverse -> collab can lower one deterministic child splice该操作被明确要求不得:
- 当只改变两个子节点时,用完整的根子数组重建操作负载;
- 对 #5992 精确整子范围逐子节点发
remove_node; - 在操作应用之外直接变异根子数组;
- 绕过 selection/path/ref 变换契约。
4.1 历史(History)侧:一条操作 = 一条逆操作
"一条逻辑操作 + 一条逆操作"的设计与当前仓库的历史机制完全吻合。在 with-history.ts 中,undo 的实现是取批次的全部操作、逐个OperationApi.inverse求逆后反转顺序再依次应用(见第 69–74 行),redo 则原序重放并恢复selectionAfter。因此操作数量越少、每一条操作的逆变换越确定,undo/redo 的语义就越稳定。replace_children的逆操作只需交换children与newChildren两个窗口数组,天然可逆。
4.2 与现有 fragment 提取路径的关系
当前仓库中NodeApi.fragment提供"取 root 内某 range 对应的切片片段"能力(见 node.ts 第 113–115 行的接口文档与第 222–229 行的实现),getFragment.ts 进一步将其包装为编辑器级 API。计划文档中"整块顶层子 fragment 快速路径"正是这一能力的演进:提取与替换从"整文档切片"收敛为"受控子窗口",二者共用同一套路径/range 语义,保证复制与粘贴两侧形状一致。
5. 生态系统策略:从 Lexical / ProseMirror / Tiptap 借鉴
计划文档第 6 节给出了跨编辑器生态的策略综合,结论是"策略性偷师"而非照搬:
| 系统 | 机制 | 借鉴点 | 拒绝点 | Slate 目标 | 裁决 |
|---|---|---|---|---|---|
| Lexical | editor.update+ dirty leaves/elements + 生命周期标签 | dirty 运行时桶与更新标签(供 commit 消费者使用) | class 节点与$helper API | 一条子区间操作 + dirty parent/range 元数据 | 部分采纳 |
| ProseMirror | 事务累积 steps 并映射 selection | replace-step 纪律与映射后的 selection | 整数位置模型与 schema 优先的身份体系 | replace_children附带 path/index/range 变换语义 | 认同 |
| Tiptap | command/chain 糖包裹单一事务 | 扩展 DX 停留在事务引擎之上 | 把 command chain 当作必需的 Slate API | 保留editor.update;产品糖可降级为单条操作 | 部分采纳 |
| Slate v2 现状 | replace_fragment证明 +remove_node删除循环 | 复用证明表面与测试 | paste 形状的操作名 + 小范围大文档场景下的全数组负载 | 泛化为replace_children | 修订 |
综合策略可表达为:
Lexical-style dirty metadata + ProseMirror-style range replacement + Slate paths/runtime ids + Tiptap-like extension sugar above the engine相关的源研究文档在本仓库中可继续深入:ProseMirror 事务/视图/DOM 运行时、Lexical 读/更新扩展运行时、Tiptap 扩展命令与 React DX。
6. Plate 与 slate-yjs 的迁移骨架
6.1 Plate 侧:零产品 API 变化
Plate 应当看到的是:
- 相同的
editor.update写作形态; - 大范围删除/剪切时更少的操作数;
- 对 history/collab 桥更干净的操作负载;
- 没有新的 Plate 自有的粘贴/删除策略。
Plate不需要:围绕 #5992 的兼容包装、选择加入快速路径的产品命令、或用来掩盖核心删除成本的虚拟化。这是典型的"核心只做模型操作、产品层零感知"设计。
6.2 slate-yjs 侧:直接消费或单事务降级
已接受的协作骨架:
- yjs/collab 适配器在能表达"父节点子拼接"时应直接消费
replace_children; - 若传输层无法原子表达,则适配器在一个远程事务内将其降级为 remove/insert 操作;
- 本地 Slate 历史仍然只看到一条逻辑操作与一条逆操作;
- 远程重放必须与本地重放通过相同的 path/index/window 语义收敛。
硬性门槛:在协作重放/降级有聚焦的契约测试之前(或发布说明显式标注首个切片不支持协作降级之前),replace_children不算发布就绪。这一门槛同样体现在第 11 节的"协作重放/降级契约测试或显式门禁"表述中。
7. 回归证明矩阵与浏览器压测门槛
7.1 遗留回归证明矩阵
任何变更都必须覆盖以下行为证明:
| 行为 | 必需证明 |
|---|---|
| 删除一个选中的顶层块 | 一条操作 + 正确的选择 |
| 在 50,000 块文档中删除两个选中顶层块 | 达标的延迟目标、一条逻辑替换、子节点正确 |
| 剪切选中的顶层块 | 复制 fragment 正确、删除后模型正确、一条历史记录 |
| 文档首/尾删除 | 选择落在合法邻居或编辑器首/尾 |
| 删除整篇文档 | 保留默认文档策略或显式替换行为 |
| 删除嵌套列表范围 | 既有列表删除测试保持绿色或显式回退 |
| 删除 inline/void 范围 | 既有 inline/void 行为保持绿色 |
| 撤销/重做 | 逆操作恢复被删子节点与选择 |
| path/point/range refs | 被删范围内的 refs 置空;范围之后的 refs 按 delta 平移 |
| 协作重放 | 本地与远程重放收敛 |
| dirty paths/runtime ids | 父节点与受影响的顶层范围失效,而非整个文档(除非必要) |
7.2 浏览器压测命令与 #5992 阈值
在声明任何Fixes #5992之前,至少需要以下行(在 slate-v2 开发树中执行):
SLATE_CLIPBOARD_BENCH_HUGE_CUT_BLOCKS=50000 SLATE_CLIPBOARD_BENCH_ISSUE_TARGETS=1 bun ./scripts/benchmarks/slate/5945-large-plaintext-paste.mjs bun test ./packages/slate/test/delete-contract.ts bun test ./packages/slate/test/clipboard-contract.ts bun test ./packages/slate/test/operations-contract.ts PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/stress/generated-editing.test.ts -g "paste-normalize-undo" --project=chromium若基准推进到Fixes #5992,还需要新增浏览器剪切行,因为 Issue 描述的是"cut function"(剪切函数),不仅仅是纯模型删除。
已接受的 #5992 阈值:
- 50,000 块双节点 edit-only 剪切:本地基准 lane 上 p50
<150ms; - 50,000 块双节点 copy-plus-delete:同 lane 上 p50
<250ms; - 操作数:一条
replace_children结构化操作;仅当操作契约无法携带newSelection时才允许额外的显式选择操作; - 负载:旧/新子数组仅限被移除/插入的窗口,而非整个 50,000 子节点的根;
- 若以上目标无实质改善,则停止归因于操作数,下一个归因点是快照/索引分配(snapshot/index allocation)。
8. 高风险预演(Pre-Mortem)与硬性切割
由于本变更涉及操作/数据模型行为,计划文档触发了高风险刻意模式预演,列出五个失败场景及对应证明路径:
replace_children之后的 path/point/range 变换错误 → 被删范围之后的选择 refs 漂移;- 历史逆操作恢复了子节点但未恢复选择 → 破坏剪切周边的 undo/redo;
- 协作适配器将其解释为全父节点替换 → 丢失意图或产生远程冲突;
- dirty-path 分类过宽 → 只是把 #5992 的成本搬进 React/运行时失效;
- 负载存储过多旧/新文档状态 → 大文档上的内存回归。
对应证明计划:单元级(op 校验、inverse、path/point/range 变换)、核心级(删除契约、粘贴替换契约、历史重放)、基准级(#5992 issue 规模行 + 已接受阈值)、浏览器级(推进到Fixes时的剪切/粘贴/撤销行)、协作级(重放/降级契约或显式门禁)。
硬性切割清单(明确不做):
- 不用虚拟化修复模型删除成本;
- 不提供公开的
editor.cutFast或应用自选; - 不用全根
replace_fragment负载作为小范围子节点删除的最终答案; - 不用多条
remove_node循环关闭 #5992; - 不单凭操作数就宣称 #5992 已修复。
9. 维护者反对意见账本
计划文档第 18 节以账本形式预演了维护者最可能的三个反对意见,并给出钢化反驳(steelman antithesis)与取舍:
| 变更 | 可能的反对 | 裁决 |
|---|---|---|
新增/泛化replace_children操作 | "Slate 操作应当是小而原始的;小操作更容易变换与推理" | 保留。问题恰恰在于原始循环在规模化时变得病态;ProseMirror 式范围替换才是复合子变更的正确原语 |
替换或降级replace_fragment | "你刚加了它,为什么又要变?" | 保留。证明是对的,但名称与负载对 delete/cut 过于 paste 化,最好在公开冻结前硬切割 |
不叫splice_children | "splice 就是精确的数组原语" | 保留。Slate 操作名描述模型动作而非 JS API;replace_children对 undo/collab 负载更清晰 |
10. 实施阶段(TDD 路线)与快驱动门禁
计划文档第 22 节给出七个实施阶段,明确"此 ralplan 到达 done 之前不得执行":
- 红色证明(Red proof):新增
replace_children操作契约测试;新增 #5992 edit-only 剪切基准目标行;断言旧行仍高于目标; - 操作核心(Operation core):op 类型、校验、inverse、apply、dirty paths、runtime-index 失效、path/point/range 变换;
- 删除降级(Delete lowering):把精确整子范围删除降级为单条
replace_children; - 粘贴迁移(Paste migration):将当前顶层
replace_fragment用法迁移到replace_children; - 历史/协作(History/collab):证明 inverse、undo/redo、远程重放/降级;
- 基准/浏览器(Benchmark/browser):运行 #5992 issue 规模基准与聚焦的浏览器剪切/粘贴/撤销行;
- 文档/台账(Docs/ledgers):更新 issue 覆盖矩阵、fork 档案、PR 描述与计划状态。
执行期间的快驱动门禁(fast driver gates,在 slate-v2 开发树中运行):
bun test ./packages/slate/test/delete-contract.ts bun test ./packages/slate/test/clipboard-contract.ts bun test ./packages/slate/test/operations-contract.ts bun --filter slate typecheck SLATE_CLIPBOARD_BENCH_HUGE_CUT_BLOCKS=50000 SLATE_CLIPBOARD_BENCH_ISSUE_TARGETS=1 bun ./scripts/benchmarks/slate/5945-large-plaintext-paste.mjs浏览器证明(Fixes #5992之前必须):
PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/stress/generated-editing.test.ts -g "paste-normalize-undo" --project=chromium11. Ralph 执行结果与最终基准
计划文档第 26 节记录了ralph执行的结果(状态:complete for the accepted execution lane)。已实现的清单:
replace_children操作类型、校验、inverse、apply、dirty paths、runtime-index 失效、path/point ref 变换;- 精确整块顶层子范围删除降级为单条
replace_children; - 粘贴快速路径从
replace_fragment迁移到replace_children(root/block 子窗口替换); - DOM 纯文本粘贴回退路径迁移到
replace_children; - history/collab 重放证明;
- 分离"冷快照分配"与"暖编辑器交互延迟"的基准目标行;
- 大文档剪切的浏览器压力行 + 既有 paste/normalize/undo 行。
验证命令:
bun test ./packages/slate/test/operations-contract.ts ./packages/slate/test/delete-contract.ts ./packages/slate/test/collab-history-runtime-contract.ts ./packages/slate/test/clipboard-contract.ts bun --filter slate typecheck bun --filter slate-dom typecheck bun lint:fix SLATE_CLIPBOARD_BENCH_HUGE_CUT_BLOCKS=50000 SLATE_CLIPBOARD_BENCH_HUGE_CUT_ITERATIONS=3 SLATE_CLIPBOARD_BENCH_ISSUE_TARGETS=1 SLATE_CLIPBOARD_BENCH_ISSUE_ITERATIONS=1 bun ./scripts/benchmarks/slate/5945-large-plaintext-paste.mjs STRESS_FAMILIES=huge-document-cut,paste-normalize-undo PLAYWRIGHT_RETRIES=0 bunx playwright test playwright/stress/generated-editing.test.ts -g "huge-document-cut|paste-normalize-undo" --project=chromium11.1 最新 #5992 基准(计划文档记录)
- 暖编辑(warm edit)p50:
9.95ms; - 暖复制加删除(warm copy-plus-delete)p50:
8.62ms; - 操作数:
1; - 冷编辑(cold edit)p50(单独追踪):
171.91ms。
对照规划阶段的621.26ms(copy-plus-delete)与511.47ms(edit-only),暖路径的实测延迟下降了两个数量级,且操作数从 3 收敛到 1——这正是"负载与变更窗口成正比"的量化验证。注意:冷路径(cold snapshot allocation)仍被单独追踪,计划文档明确将"快照/索引分配"列为下一层可能的成本归属。
12. 结论:为什么 #5992 仍保持 Improves 而非 Fixes
尽管实测数据已大幅改善,计划文档与 issue-coverage-matrix.md 台账都坚持 #5992 维持Improves而非Fixes,原因有三:
- 接受标准是"维护者接受度":50,000 块基准 + 5,000 块浏览器压力行需要维护者认可其与原始复现路径("cut function")等价,这属于人为决策,不由实现方单方面裁定;
- 浏览器层证据要求:Issue 描述的是剪切函数而非纯模型删除,因此推进到
Fixes必须追加浏览器剪切行; - 历史路径证明未完成:台账中明确 "exact closure remains maintainer acceptance plus historical-path proof"(精确关闭仍需要维护者接受加历史路径证明)。
该判定逻辑与 benchmark-candidate-map.md 第 55–71 行的 #5992 条目一致:ready-with-minor-setup,基准接缝为 huge-document cut benchmark,主指标是"随文档规模增长的剪切延迟",次指标是"剪切路径中任何可见的选择或规范化放大"。
13. 相关 Issue 生态与后续方向
计划文档第 12 节的 issue 账本把这次变更放入更广的生态坐标系:#2288(operation-granularity-and-range-steps)被明确升级为范围能力操作的架构支撑——它要求"selectAll + delete 不应爆炸成大量操作";#6038、#5811、#3534、#3551、#4104、#5089、#5630 等分别从批处理引擎、规范化、历史选择状态、inline-void、多块粘贴形状、select-all 粘贴等角度被标记为Related(相关但不由本操作单独关闭)。后续的 CRDT/Yjs 专项计划将决定远程传输是直接消费replace_children,还是在单个远程事务内降级为 remove/insert——这对应计划文档第 2 节中唯一保留的"待用户决策点"的演进方向。
14. 总结
replace_children是 Slate v2 开发线对"大文档范围编辑性能"的一次核心模型级重构:它把剪切/删除/粘贴替换统一收敛为一条按变更窗口缩放的父子数组替换操作,通过历史逆操作、协作降级、ref 变换与 dirty-path 分类的协同契约,把 50,000 块文档中的双节点剪切从数百毫秒压到个位数毫秒(暖路径)。对于 Plate 与 slate-yjs 的使用者,这一变更完全透明——应用代码依旧是editor.update,产品粘贴策略依旧归 Plate/应用所有。本文所依据的完整规划与执行记录见 2026-05-06-slate-v2-range-delete-replace-children-ralplan.md,当前仓库中可继续对照的源码与台账包括 operation.ts(经典操作族)、with-history.ts(逆操作重放机制)、node.ts(fragment 提取接口)以及 issue-coverage-matrix.md(#5992 状态追踪)。
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考