ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Plate 与 Slate 性能基准剖析:宽兄弟数组上的 `set_node` 变换为何昂贵

Plate 与 Slate 性能基准剖析:宽兄弟数组上的 `set_node` 变换为何昂贵 Plate 与 Slate 性能基准剖析宽兄弟数组上的set_node变换为何昂贵【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate导读本文基于仓库内一篇面向 Slate 维护者的基准测试说明docs/plans/2026-03-30-slate-set-node-note-for-maintainer.md完整还原一次从 Plate 性能悬崖追查到 Slate 上游优化缝隙的调查过程。你将看到在超长文档上反复执行精确路径Transforms.setNodes(...)时开销到底出在哪一层——既不是归一化normalization也不是路径/范围引用簿记而是modifyDescendant中不可变祖先数组的反复重写同时结合当前仓库源码印证set_node变换的实现细节并介绍仓库中已经出现的批量精确路径更新方案setNodesBatch如何回应文档提出的优化方向。调查起点Plate 侧的性能悬崖与一次性修复在将 Plate 与 Slate 放在超长文档上进行基准对比时作者遇到了一个巨大的性能悬崖。定位后发现 Plate 自身存在一个 bug初始nodeId归一化normalization阶段对每一个缺失 id 的节点都调用了一次实时的 Slate 变换live transform导致 O(n) 次单点更新串行累积。该问题已在 Plate 侧修复修复方式是直接遍历初始值walk the initial value directly不再对每个缺失 id 走一遍实时变换。这一点值得注意它说明在初始化等一次性场景中应优先用一次性遍历解决问题而不是复用交互式的变换 API。修复 Plate 自身问题之后作者进一步把同一批精确路径更新repeated exact-path update放进slate自身去基准测试得到的结论是稳定的在扁平的超长文档上反复执行Transforms.setNodes(...)耗时主要由set_node变换路径主导主导成本不是归一化主导成本不是 path refs / range refs / dirty-path 簿记主导成本看起来是modifyDescendant中不可变的祖先数组重写immutable ancestor-array rewriting尤其是当父级数组非常宽wide时。需要强调这是一次纯变换基准pure transform benchmark不是 DOM 渲染基准测试结论只针对 Slate 数据模型层的变换开销。复现方式一组可重复的基准命令文档给出了基准文件的原始位置与运行命令。基准文件为packages/slate/test/perf/set-nodes-bench.js文档撰写时的临时产物当前仓库中已不存在复现命令如下bun ./packages/slate/test/perf/set-nodes-bench.js --blocks5000 --group-size50 --repeats5 bun ./packages/slate/test/perf/set-nodes-bench.js --blocks10000 --group-size50 --repeats3参数含义--blocks段落总数5000 或 10000--group-size分组文档中每个 section 父节点下的子节点数量50--repeats重复次数5 或 3段落越多重复次数越少以控制总耗时。基准对比了什么在段落总数相同的前提下基准对比两种文档形态扁平文档flat每个段落都是顶层兄弟节点top-level sibling根节点下是一个宽度等于段落总数的超大children数组分组文档grouped同样的段落被分布到多个 section 父节点下每个 section 恰好容纳50个子节点根节点下是section 数组宽度大幅收窄。对每种形态分别测量 5 个层级Transforms.setNodes(editor, { id }, { at: path })——对每个精确路径逐条调用同样的循环放进Editor.withoutNormalizing(...)中执行在Editor.withoutNormalizing(...)内直接editor.apply({ type: set_node, ... })裸调用modifyDescendant(...)一次遍历重写one-pass rewrite作为下界参照。计时用的apply包装还会进一步拆分ref transforms引用变换、dirty-path 更新、Transforms.transform(...)、Editor.normalize(...)各自的耗时。性能数据宽数组是成本放大器5,000 段落CaseFlatGroupedsetNodesper path73.35 ms22.09 mssetNodesinside outerwithoutNormalizing52.96 msn/adirectapply(set_node)44.62 ms9.11 msbaremodifyDescendant37.01 ms2.38 msone-pass rewrite0.15 ms0.19 ms10,000 段落CaseFlatGroupedsetNodesper path241.36 ms66.19 mssetNodesinside outerwithoutNormalizing169.07 msn/adirectapply(set_node)147.71 ms22.16 msbaremodifyDescendant140.11 ms7.25 msone-pass rewrite0.35 ms0.61 ms数据中有几个非常直观的信号形态敏感性极强同样的段落数从扁平变为分组后各层级的耗时普遍下降 310 倍。10,000段落时裸modifyDescendant从140.11 ms降到7.25 ms约 19 倍差距。setNodes和apply只是叠加开销10,000扁平下setNodesper path241.36 ms→ 直接apply(set_node)147.71 ms→ 裸modifyDescendant140.11 ms逐层剥掉包装后真正的底账几乎全在modifyDescendant。下界极低一次遍历重写one-pass rewrite只有0.15 ms/0.35 ms量级说明数据量本身不是问题问题是每次更新都重写一遍祖先链。计时apply分解10,000扁平段落applyTotalMs146.12 mstransformMs138.00 msdirtyPathsMs2.37 msnormalizeMs1.76 mspath/point/range refs 合计约2.56 ms10,000分组段落applyTotalMs21.81 mstransformMs12.51 msdirtyPathsMs3.78 msnormalizeMs0.78 ms分解结果显示normalizeMs在两种形态下都只有个位数毫秒归一化不是这个工作负载的主导成本真正的重头是transformMs——即变换本身对树结构的不可变重写。refs 与 dirty-path 簿记同样只占毫秒级。结果解读形状敏感性的含义最强的信号是形状敏感性shape sensitivity当宽大的顶层兄弟数组被替换成多个较小的分组数组后同样的段落数、同样的更新次数成本骤降。这强烈说明主要开销并非通用的归一化开销而是反复对祖先链做不可变重写尤其是对宽children数组的重写。modifyDescendant(...)的数字是最清晰的证据5,000扁平37.01 ms5,000分组2.38 ms10,000扁平140.11 ms10,000分组7.25 ms这个层级已经占据了apply(set_node)成本的绝大部分。因此当前行为可以概括为setNodes(...)本身有一定开销apply(...)也有一定开销但真实成本的绝大部分仍然落在set_node变换路径底层的不可变树重写immutable tree rewrite上。源码印证当前仓库中set_node的实现结构文档撰写时指向的源码路径为packages/slate/src/transforms-node/set-nodes.ts——遍历匹配节点并逐个editor.apply({ type: set_node, ... })packages/slate/src/interfaces/transforms/general.ts——set_node分支调用modifyDescendant(...)packages/slate/src/utils/modify.ts——modifyDescendant(...)用replaceChildren(...)重建祖先链而replaceChildren(...)依赖数组切片/展开slicing/spreading。从当前仓库的源码结构看Slate 包已经历内部重构相关实现迁移到了packages/slate/src/internal/transforms/目录setNodes.tssetNodes的入口。值得注意的是它区分两条路径——当传入marks选项时会先解析at、构造匹配match文本节点且父节点非 void 或为 markable void必要时split: true走区间切分路径否则直接调用底层setNodesBase。也就是说区间切分 / match 遍历这些重分支只在需要时启用与文档中无区间切分、无 broad match 遍历时可走快速路径的优化前提一致。setNodes.spec.tsx覆盖marks选项、展开区间先 split 再打标等行为的测试。优化缝隙已被回应仓库中的setNodesBatch文档在可能的上游方向中提出的第一项候选是增加批量精确路径set_node路径batched exact-pathset_nodepath。如果调用方已经持有精确路径共享祖先可以只重建一次而不是每个 op 重建一次。从当前仓库源码看这一方向已经在packages/slate/src/internal/transforms/setNodesBatch.ts中落地为setNodesBatch。其实现要点buildSetNodeBatchOperations(editor, updates)接收{ at: Path; props }[]形式的批量更新逐条做三件事拒绝空路径Cannot set properties on the root node.用NodeApi.get(editor, at)校验节点存在且为 Descendant计算properties旧值与newProperties新值跳过children/text键跳过值未变化value currentValue的键并将 nullish 值从newProperties中剔除与 SlatesetNodes的置空即移除属性语义保持一致。applySetNodeBatchOperations(editor, ops)先校验不存在重复路径setNodesBatch does not support duplicate update paths再在editor.tf.withoutNormalizing(...)内逐个editor.apply(op)。配合 setNodesBatch.spec.tsx 的测试setNodesBatch提供了一次调用、内部批量构造 op、统一跳过无变化更新、归一化仅触发一次的语义。对于文档中描述的需要在大宽兄弟数组上设置大量精确路径属性的工作负载这种批量形态可以直接规避逐条setNodes 逐条归一化的叠加开销是文档所提优化方向的一个具体实现回应。文档提出的上游优化候选方向作为给维护者的备注文档给出了三个候选思路明确标注为候选想法不是成熟提案批量精确路径set_node路径batched exact-pathset_nodepath调用方已持有精确路径时共享祖先只需重建一次而不是每个 op 重建一次。对应仓库现状即上文介绍的setNodesBatch。setNodes内部快速路径internal fast path当满足以下全部条件时启用at是精确Path没有区间切分no range splitting没有 broadmatch遍历更新只是属性替换property replacement。更底层的批量节点属性更新辅助函数apply many node property updates helper在保持 Slate 语义的同时避免对同一父链反复克隆祖先。第 1、2 点本质上都在说同一件事在精确路径 纯属性替换的前提下应当把对共享祖先链的重写次数从 O(更新的节点数 × 祖先深度) 压缩到接近 O(祖先链长度)。边界与注意事项文档对自身结论的适用范围做了明确限定引用时不应越界这是Node 侧微基准microbenchmark不是浏览器挂载mount基准它不能说明 Slate React 渲染的任何问题它不声称normalize在任何情况下都是免费的只说在这个特定的反复精确路径set_node工作负载中归一化不是主导成本触发本次调查的 Plate 侧问题已在 Plate 侧修复初始nodeId归一化改为直接遍历初始值分享这份数据是因为基准结果暗示 Slate 层面存在一个更通用的优化缝隙。结论与可复用的工程经验这次调查可以提炼出几条可迁移的经验性能悬崖要先分清是哪一层的账Plate 侧的问题是初始化时逐 id 走实时变换修复方式直接遍历初始值说明——批量初始化数据时不要复用单点交互变换 API。宽兄弟数组是变换成本的放大器不可变数据结构的祖先链重写成本与父数组宽度直接相关。在无法改变文档形态flat 结构是富文本的常态时应从减少重写次数入手。归一化往往不是主要成本在精确路径、无区间切分的批量更新场景中先做 profile 再优化避免把时间浪费在错误的假设上。优化方向已被实现验证文档提出的批量精确路径更新方向在仓库中已有setNodesBatch实现与配套测试可作为后续维护者在超长文档批量更新场景下的首选 API。如果需要对其他变换形态如insert_node、remove_node在大宽数组上的表现做同样分析可参考本文的测量层级设计从高层 API 逐层剥到裸底层操作并附带 one-pass 下界对照这套方法论本身也值得复用。【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表