ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

一条接缝救活 Slate React v2 的 decorations:投影架构如何拆、怎么证、为何这样证

一条接缝救活 Slate React v2 的 decorations:投影架构如何拆、怎么证、为何这样证

一条接缝救活 Slate React v2 的 decorations:投影架构如何拆、怎么证、为何这样证

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

在 Slate React v2 这条线上,decorations 和选区锚定覆盖层的性能与渲染问题,最终被收敛成一张极简的架构切分图:核心层只解释逻辑区间,React 层独占覆盖层投影与订阅广度,DOM 层留待以后管几何。这套"投影架构"让局部重渲染第一次有了可验证的归属——哪个切片变了,就只重渲染那个切片。下文我们以工程复盘的口吻,倒推回当时为什么要这么拆、这条接缝是如何被红绿测试一步步坐实的。

先记住这张图,再回头听故事

把结论摆在最前面。整个体系只有三层所有权:

  • 核心拥有逻辑 range 语义——把逻辑区间翻译成按 runtime id(运行时给每个文本节点颁发的稳定身份编号)索引的局部文本切片;
  • React 拥有覆盖层投影与订阅——维护投影缓存,决定哪些切片变化该通知哪些组件;
  • DOM 以后负责几何——浮层定位、浏览器选区修复,统统不在这一层里谈。

配套的判据只有一句:任何一套 decorations 或 annotations 设计,如果说不清楚这三行各自归谁管,那它就没有做完。下面按"勘查 → 拆解 → 测试 → 死路 → 证据 → 展望"的顺序,把这条接缝是怎么长出来的讲一遍。

🔎 勘查:issue 语料里的真实现场

回到 2026 年 4 月初,我们翻遍 issue 语料,发现它给的信号相当直白:

  • #4483被当成"decorations 有点慢"来抱怨,但拆开看,它本质是个渲染器失效问题——真正昂贵的不是算区间,而是重渲染打得太广;
  • #4477把需求写得很具体:协作写作需要批注,而批注要钉在文本区间上,选区锚定的评论覆盖层是真实的产品接缝;
  • #5987提了硬约束:异步的 decoration 更新落地时,光标不允许被撼动;
  • #4392、#3382、#3352则共同说明,跨节点投影(一段逻辑区间横跨多个文本节点)本来就是这类形态的一部分,即便第一刀可以不切那么宽。

与此同时,代码现场也很诚实。Slate React v2 当时几乎只剩useSlateSelector这一套 selector 订阅;Slate v2 的 snapshot 已经暴露了 ids、paths、selection、marks,但没有 overlay 投影的接缝——也就是说,想在 React 侧拿到"局部覆盖层切片",连一个诚实的入口都没有。

勘查结论只有一条:缺的不是新的 React 哲学,而是覆盖层专用的第二外部 store。selector 机制回答的是"组件如何从编辑器状态里取片段",而投影要回答的是"逻辑区间怎么切成局部切片再分发",两件事正交,谁也替代不了谁。

📐 拆开来看:一段逻辑区间,两个世界的解释

先说纯 range 语义这一半。核心侧只加一个纯辅助函数:Editor.projectRange(editor, range)。

输入是一段逻辑 SlateRange——它由anchor与focus两个端点构成,既可以在单个节点内部划出一段跨度,也可以横跨多个节点;输出是按运行时 id 键控的局部文本切片,形状形如{ runtimeId, path, start, end }。约束写得毫不含糊:不碰 DOM、不碰 React、不存任何 overlay 状态。它只回答一个纯文档语义问题——"这段逻辑区间落在哪些文本节点上,每段各占多少偏移"。输入端语义在 packages/plitejs/src/interfaces/range.ts 里早已成型,projectRange只是把这段语义兑现成可消费的切片。

再说 React 侧那一半,它是整个证明里真正干活的部分:

  • createSlateProjectionStore(editor, source):source(snapshot)返回逻辑覆盖层区间。store 承担四件职责——编辑器 snapshot(提交后的文档一致性状态,可理解为某一瞬间的"存档")每次 commit 时重算投影;外部 decoration 输入变化时可被显式刷新;把逻辑区间经由Editor.projectRange(...)投影成局部切片;最后按 runtime id 发布切片。
  • useSlateProjections(runtimeId):组件级订阅 Hook,只接收与自己 runtime id 相关的切片,别人家的变化一概不感知。
  • 可选的<Slate ...>上下文,把 store 接入组件树。

为什么必须劈成两半?因为两者各自踩的坑互不重叠:核心的坑是"一旦持有渲染期状态,就会开始揣测文档语义之外的东西";React 的坑是"一旦猜测文档语义,就会开始维护它维护不了的缓存一致性"。劈开之后,store 的重算时机(commit、外部输入变化)留在 React 侧显式表达,核心保持纯函数、snapshot 驱动,互不污染。

🔁 红绿循环:测试先把坑挖出来

我们没有"先写实现再补测试"。第一轮红测试写的是局部投影订阅广度:投影输出只为某一个 runtime id 变化时,订阅必须保持切片级作用域,其余切片不许重渲染。预期失败原因清清楚楚——useSlateProjections(...)不存在,投影 store 也不存在。红就红在 API 表面缺失,绿则只需要最小实现:一个能按 runtime id 发布切片的 store,加上那个 Hook,测试转绿。

第二轮红测试转向选区派生注解覆盖层:源要跟随已提交的选区变化,且只重渲染受影响的文本切片。这一次预期的失败落在另一头——Editor.projectRange(...)还没有,也没有选区驱动的投影重算路径。于是 Green 阶段在核心侧补齐纯区间切分,并把 store 重算挂到编辑器 commit 上。

两轮都转绿之后才允许收敛:简化投影数据形状、剥掉顺手多造的抽象;除非测试强迫,否则不在第一个证明里塞跨节点野心。整条循环里,测试定义边界,实现只是填坑。

🚧 三条死路:当时否决掉的三个候选

第一条候选是把 overlay 状态塞进EditorSnapshot。听起来最省事,实际上会让核心开始持有它不该拥有的渲染期垃圾——snapshot 从此背上"下次重渲染要画什么"的包袱,这恰恰是 #4483 那类失效问题的温床。

第二条是直接给<Slate>加decorateprop。这个诱惑更大,因为旧世界就是这么干的。但它会重新引入"effect 镜像"压力,真正的接缝反而被藏进 React 组件的 props 里,事后想再拆开就更难了。所以第一刀阶段明确否决,把它留给未来的 extension 化路径去消化。

第三条是先造持久化注解锚点。它需要的 range-ref 或 bookmark 语义远超第一刀要证明的范围,照单全收只会把证明撑爆到收不了口。我们的处理方式是把持久化锚点显式推迟,只承诺接缝留得下它的生长位置。

🧾 落地证据:验收是怎么过的

执行按五步走:先冻结接缝范围(计划文档 2026-04-03-plite-react-v2-projection-proof-plan.md 把证明限定在"比完整评论系统更小"的尺度上,核心投影 + React store 是选定接缝,持久化锚点明确推迟);接着写两个红测试,验收标准是"都因预期的缺失表面而失败";然后落核心纯辅助函数,验收要求测试在不读 DOM、不碰可变编辑器字段的前提下就能派生局部切片;再落 React 投影 store 与 Hook,验收要求覆盖层更新只重渲染相交的 runtime id、选区派生覆盖层在 commit 时重算;最后跑验证与沉淀。

2026-04-03 的执行日志记录:确认了"selector store 存在、投影 store 不存在"这个极小的现场,选定第一刀为Editor.projectRange(...)加 React 所有的投影 store,随后在当时的 v2 证明环境(仓库根下的临时证明目录,现已不在本仓库中)落地了核心纯 range 投影与 React 侧 store、上下文接线、局部投影 Hook 三件套。验证用的命令是:

npx -y node@20 /opt/homebrew/Cellar/yarn/1.22.22/libexec/bin/yarn.js workspace slate-react-v2 test npx -y node@20 /opt/homebrew/Cellar/yarn/1.22.22/libexec/bin/yarn.js mocha --require ./config/babel/register.cjs ./packages/slate-v2/test/snapshot-contract.ts

注意:这两条命令里的绝对路径是作者本机的环境,实际运行需替换成本地路径,且该证明环境目录已从仓库移除。此外,所有改动文件都跑了 LSP 诊断;那条"range 语义与 React overlay store 必须分离"的可复用接缝规则,被沉淀进了当天的 solution 笔记(docs/solutions/logic-errors/下的同名文档,当前仓库快照中该目录已被重组,笔记本体不再直接可查)。

更硬的佐证来自后续:createSlateProjectionStore作为被保留的 API 表面进入了 release 文件审查清单(原 release 台账docs/slate-v2/release-file-review-ledger.md在当前快照中已随 v2 文档迁移而不可直接定位),并配套了重叠安全(overlap-safe)的高亮渲染示例——多个装饰区间互相重叠时仍能各自正确渲染。这说明这条接缝没有止步于"证明",而是被固化进了产品表面。

🌱 往后看:接缝长成了什么

这条接缝的设计意图里埋了生长方向:先覆盖选区派生覆盖层与外部状态 decorations,再往异步 decoration 源、注解层长,需要持久化锚点时再进一步演进到 range refs。

远期的目标形态大致是:核心侧扩出Editor.projectRanges(editor, ranges)、事务感知的rangeRef/bookmark API、注解锚点重定基(rebase)原语;React 运行时扩出useTextProjections(runtimeId, layer?)、useBlockProjections(runtimeId, layer?)、useAnchoredWidgets(...)与显式refresh(sourceId, scope?);decorations 一侧,注册派生源,源返回逻辑区间 + payload + layer + priority,重叠是一等公民;annotations 一侧则是带 id 与元数据的 CRUD store,底层挂 bookmark/range ref,可解析成切片与 widget;widget 作为锚定 UI 条目,经 portal 或显式 chrome 层渲染。

今天的实际用法最能说明它长成了什么样:lint 装饰场景中,decoration source 只保存输入(模式、查询词),read({ snapshot })从当前 snapshot 现算 lint 区间,dirtiness: ['text', 'external']同时覆盖文本编辑与外部模式变化;旁边的诊断计数面板则用useEditorState订阅编辑器状态,跟随同一次 commit,避免面板与高亮脱节。这正是"选区/外部状态派生覆盖层直接来自 committed snapshot"这一理念的实践落地。

🎒 一句话带走:给接下来动手的人

如果你要在编辑器里实现评论覆盖层、选区高亮、搜索命中或诊断标注,照着这张清单走:

  1. 先写纯核心辅助函数,把"逻辑区间 → 按 runtime id 的局部切片"这一步固定下来,全程不碰 DOM 与 React;
  2. React 侧给 store 明确的两个重算入口——commit 重算与外部输入显式刷新——再给 Hook 按 runtime id 做切片级订阅;
  3. 选区派生覆盖层一律从 committed snapshot 现算,别养一份需要全局重算的decorate回调;
  4. 异步与外部 decoration 更新,走 store 的显式失效通道,保证光标纹丝不动;
  5. 浮层定位、浏览器选区修复扔进 DOM/布局层,与投影层保持解耦。

写完对照开头那三行:核心管语义、React 管投影与订阅、DOM 管几何。说不清,就继续拆。

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

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进