Slate v2 Editable 事件运行时硬切割:把事件组装从 React 组件闭包迁移到 `useEditableEventRuntime` 的完整实战计划
2026/9/17 1:28:08 网站建设 项目流程

Slate v2 Editable 事件运行时硬切割:把事件组装从 React 组件闭包迁移到useEditableEventRuntime的完整实战计划

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

导读

本文以仓库中的 docs/plans/2026-04-27-slate-v2-editable-event-runtime-hard-cut-plan.md 为主体,完整解析 Slate v2 一次关键的架构硬切割:将EditableDOMRoot组件内部以 React 闭包形式组装的全部编辑器事件运行时(beforeinput/input、剪贴板、拖拽、合成事件、键盘、焦点鼠标等 20+ 个处理器)迁出到独立的事件运行时门面useEditableEventRuntime(...)与一组runtime-*.ts事件族模块。文章将带你理解"React 只负责挂载、事件运行时驱动编辑器"的职责边界、四道硬性切割约束、从 Phase 0 到 Phase 8 的分阶段执行与验收门禁,以及执行账本中"20 个闭包 → 0 个闭包"的真实收敛轨迹。读完你可以直接复用这套"静态清单锁架构 + 浏览器行证回归"的重构方法论。

一、问题背景:EditableDOMRoot仍是事件运行时的交通管制中心

本次切割发生在一次既有重构之后。此前"Editable runtime/root selector lane"已经将直接热策略体(hot policy bodies)从EditableDOMRoot中移出——那是正确的切割,但它并不是最终架构。

计划文档(问题章节)明确指出:EditableDOMRoot仍然在 React 组件闭包中组装整个编辑器事件运行时,具体包括以下 12 类职责:

  • beforeinput与 Reactbeforeinputfallback
  • input与 input capture
  • pastecopycutdragdrop
  • composition start/update/end
  • focusblurclick、mouse down、mouse up
  • keydown
  • selection import controller 接线
  • repair request 接线
  • kernel frame 与 trace 接线
  • Android manager 接线
  • shell-backed selection 状态迁移
  • browser handle 挂载

尽管策略体大多已经落在runtime/strategy模块中,组件本身仍然是"交通管制中心"。对于 React 19.2-perfect runtime 的目标而言,这意味着 React 对热路径的"所有权"依然过重——每一次按键、每一次合成输入都要经过 React 组件闭包这个枢纽,这在性能姿态上不可接受。

二、北极星:React 挂载编辑器,事件运行时驱动编辑器

计划的 North Star(原文)只有一句话的职责宣言:

React attaches the editor. The event runtime drives the editor.

EditableDOMRoot应该只做

  • 解析 props
  • 持有 React refs 与 context providers
  • 实例化一个事件运行时 hook
  • 挂载返回的稳定 handlers
  • 渲染 editable root 与 children

EditableDOMRoot不应该再做

  • 调用prepareEditable*Kernel(...)
  • 调用applyEditable*Strategy(...)
  • 记录 kernel trace payloads
  • 按浏览器编辑策略分支
  • 决定 selection import/export 时机
  • 在单个事件处理器里直接请求 repair
  • 在事件族代码里穿梭forceRender
  • 因为 app callback props 变化而重建热处理器

这套"应做/不应做"清单本身就是可复用的架构审查工具:任何让 React 组件重新承担上述任一职责的改动,都应当被视为架构回退。

三、目标形态:运行时门面 + 事件族模块 + 稳定处理器契约

3.1 Runtime Facade:useEditableEventRuntime

计划在slate-react/src/editable/runtime-event-engine.ts新增一个内部事件运行时门面(facade),首选 hook 形状如下:

const eventRuntime = useEditableEventRuntime({ androidInputManagerRef, attributes, browserHandleNextId, browserHandleRangeRefs, deferredOperations, editor, inputController, inputRules, isShellBackedSelection, largeDocument, onKeyCommand, onUserInput, readOnly, rootRef, scrollSelectionIntoView, setExplicitShellBackedSelection, setIsComposing, shellBackedSelection, });

返回形状:

{ attachBrowserHandle(): void handlers: EditableRootEventHandlers repair: EditableRepairRuntime selection: EditableSelectionRuntime }

计划特别强调:"Exact names can change. Ownership cannot."——接口名可以改,但所有权边界不可妥协。EditableDOMRoot最终只消费eventRuntime.handlers(spread 或直接赋值到根元素),自身不再逐一定义任何handle*闭包。

3.2 事件族模块:拒绝巨型文件

计划明确反对把门面做成新的 god module,要求 facade 组合更小粒度的事件族所有者,目标模块划分如下:

模块职责
runtime-before-input-events.ts原生 beforeinput 与 React fallback 组装
runtime-input-events.tsDOM input / React input / input-capture 组装
runtime-clipboard-events.tscopy / cut / paste
runtime-composition-events.tscomposition start / update / end
runtime-focus-mouse-events.tsfocus / blur / click / mousedown / mouseup
runtime-keyboard-events.tskeydown 组装
runtime-drag-events.tsdragstart / dragover / dragend / drop
runtime-browser-handle-events.tsbrowser proof handle 挂载

原则是:既有的 strategy 模块继续作为 worker 存在,事件运行时只负责"编排事件族",不吞并每一个变更算法。这也正是执行账本里反复出现的拒绝策略——"不要把 composition 状态全部折叠进 facade,那会让runtime-event-engine.ts变成计划明确拒绝的 god module"。

3.3 稳定 Handler 契约:React 19.2 性能姿态

计划要求 app callbacks 不得搅动热处理器身份。具体手段是使用 callback refs 或一个内部useLatestEditableProps(...)辅助函数,覆盖:

  • attributes.onBeforeInput/attributes.onInput/attributes.onKeyDown
  • clipboard callbacks
  • composition callbacks
  • focus/mouse callbacks
  • onKeyCommandinputRules

目标契约:当 editor/runtime 身份不变时,热根处理器应跨普通 app prop callback 变化保持稳定。这对应 React 19.2 性能姿态的四条纪律:

  • 瞬态热编辑状态放在 refs / runtime 对象中
  • React state 只用于可见渲染事实
  • 事件处理器不重新订阅宽泛的编辑器状态
  • 非紧急的 proof 或 UI 更新不进入打字路径

四、四道硬性切割(Hard Cuts)

Cut 1:EditableDOMRoot停止导入事件 worker

切割完成后,components/editable.tsx不得再直接导入以下事件 worker 族:

  • clipboard-input-strategy
  • composition-state事件应用器
  • editing-kernelprepare 函数
  • keyboard-input-strategy
  • model-input-strategy
  • native-input-strategy
  • 事件向的selection-reconcilerworkers

允许保留的导入仅限于:render-only 辅助、contexts、类型,以及事件运行时 hook。

Cut 2:事件运行时拥有处理器组装权

全部 21 个根处理器在 runtime 模块中组装:onDOMBeforeInputonReactBeforeInputonDOMInputonInputCaptureonPasteonCopyonCutonDragStartonDragOveronDragEndonDroponCompositionStartonCompositionUpdateonCompositionEndonFocusonBluronClickonMouseDownonMouseUponKeyDownEditableDOMRoot只做eventRuntime.handlers的 spread 或赋值。

Cut 3:Selection 与 Repair 成为运行时输入,而非处理器局部

事件处理器只能调用命名的运行时能力:

eventRuntime.selection.flushSelectionChange() eventRuntime.selection.applyKeyDownSelectionPolicy(...) eventRuntime.selection.syncDOMSelectionFromRuntime() eventRuntime.repair.request(...) eventRuntime.trace.record(...)

因为处理器本身已经不在EditableDOMRoot中,组件内自然不存在直接调用这些能力的机会。

Cut 4:静态守卫防止回退

计划明确写道:"Do not rely on code review memory. The next rushed patch must fail locally."——不依赖代码评审的记忆,下一个赶工补丁必须在本地直接失败。为此要新增守卫,当EditableDOMRoot导入或调用被禁事件 worker 时直接报错。

五、分阶段执行计划(Phase 0–8)

Phase 0:冻结当前事件表面

目的:证明计划针对的是真实当前表面,而不是陈旧债务。行动包括:清点EditableDOMRoot中每一个 handler 闭包、清点components/editable.tsx的每一个事件 worker 导入、新增记录当前 forbidden/import 所有者清单的包级契约,并把任何允许保留的导入分类为:render-only / root ref-context wiring / event runtime facade / 带 burn-down 所有者的临时桥。

验收:事件导入全部登记在一个守卫中;守卫为每个导入族指明最终所有者;清点变绿前不动任何行为。涉及文件为slate-react/src/components/editable.tsxtest/kernel-authority-audit-contract.tstest/surface-contract.tsx

驱动门禁:

bun --filter slate-react test:vitest test/kernel-authority-audit-contract.test.ts test/surface-contract.test.tsx

Phase 1:创建事件运行时门面(零行为变更)

新增runtime-event-engine.ts,暂不移动任何事件行为;定义EditableRootEventHandlers与运行时输入/输出类型;把既有运行时引擎(selection change runtime、selection import controller、repair runtime、kernel trace runtime、composition runtime、Android runtime)穿线进 facade;需要处原样返回既有 handler 值。

验收:EditableDOMRoot可以实例化useEditableEventRuntime(...);既有测试在任何事件族抽取之前全绿;无公开 API 变更。驱动门禁:bun --filter slate-react typecheck

Phase 2:先迁移低风险事件族

在触碰最敏感输入路径之前先瘦身:依次迁移 (1) copy/cut/paste,(2) drag/drop,(3) focus/blur/click/mousedown/mouseup。验收要求EditableDOMRoot不再定义这些闭包;剪贴板与鼠标选择的 kernel trace 保持完全一致;hovering toolbar 在鼠标选择后仍显示工具栏;paste/normalize/undo 压力族仍可回放。

驱动门禁:

bun --filter slate-react test:vitest test/editing-kernel-contract.test.ts test/editing-epoch-kernel-contract.test.ts PLAYWRIGHT_RETRIES=0 PLAYWRIGHT_WORKERS=1 bun playwright playwright/integration/examples/hovering-toolbar.test.ts playwright/integration/examples/richtext.test.ts --project=chromium --grep "hovering toolbar|paste|undo"

Phase 3:迁移 Composition 与 Android 事件组装

将 composition start/update/end 处理器移入runtime-composition-events.ts;状态迁移保留在runtime-composition-engine.ts;Android 生命周期保留在runtime-android-engine.ts;事件运行时负责把 Android ref 接给 composition/input workers。验收:composition 处理器不随 app composition callbacks 变化而重建;IME 压力行保持 model text、focus owner 与 trace 断言全绿。

驱动门禁:

bun --filter slate-react test:vitest test/editing-epoch-kernel-contract.test.ts STRESS_FAMILIES=selection-repair-ime PLAYWRIGHT_RETRIES=0 bun test:stress

Phase 4:迁移 Beforeinput 与 Input 组装(最难的 React 拥有路径)

原生beforeinput组装移入runtime-before-input-events.ts,React fallback 归同一所有者;input / input-capture 组装移入runtime-input-events.ts;实际变更决策仍留在既有 worker 模块。必须逐条保持 selection import 时机不变

  • 在 model-owned beforeinput 之前 flush selectionchange
  • 尊重 internal targets
  • 保留 Android beforeinput 分支
  • 保留 WebKit shadow DOM 分支
  • 保留重复 epoch command 守卫
  • 保留 model-owned native history repair

验收:EditableDOMRoot不再导入/调用 beforeinput/input strategy workers;native word-delete 行在关闭重试下保持绿色;search highlighting 输入保持焦点;placeholder 输入/删除/撤销不回退;直接 DOM 文本同步不引入公开 stale selector 策略。

驱动门禁:

bun --filter slate-react test:vitest test/selection-controller-contract.test.ts test/editing-kernel-contract.test.ts test/surface-contract.test.tsx PLAYWRIGHT_RETRIES=0 PLAYWRIGHT_WORKERS=1 bun playwright playwright/integration/examples/richtext.test.ts playwright/integration/examples/search-highlighting.test.ts playwright/integration/examples/placeholder.test.ts --project=chromium --grep "native word-delete|search|placeholder"

Phase 5:迁移 Keydown 组装

事件运行时拥有:prepareEditableKeyDownKernel(...)、selection policy 应用、keydown event frame 创建、keyboard worker 调用、arrow-up/down 延迟 DOM selection 同步、keydown trace 记录;同时保留onKeyCommand、read-only 行为、shell-backed selection 更新与 large-document 策略。验收:mentions inline void 双侧导航、表格右箭头单元格边界偏移0、图片/块 void 键盘导航、large-document shell 激活全部保持绿色。

驱动门禁:

bun --filter slate-react test:vitest test/selection-runtime-contract.test.ts test/selection-controller-contract.test.ts PLAYWRIGHT_RETRIES=0 PLAYWRIGHT_WORKERS=1 bun playwright playwright/integration/examples/mentions.test.ts playwright/integration/examples/tables.test.ts playwright/integration/examples/images.test.ts playwright/integration/examples/large-document-runtime.test.ts --project=chromium

Phase 6:迁移 Browser Handle 与 Target Runtime 接线

attachSlateBrowserHandle(...)移入runtime-browser-handle-events.tswriteTargetRuntime(...)移入事件运行时门面或小型 target-runtime 桥所有者;browser handle force-render 调用在独立 proof-transport 清理落地前,继续归类为 proof bridge 调用。验收:EditableDOMRoot不再直接挂载 browser handle;target runtime 只有一个所有者;browser proof handle 保持 test/proof-only 并被审计。

Phase 7:收缩EditableDOMRoot并锁定边界

移除components/editable.tsx的直接事件 worker 导入;EditableDOMRoot降级为纯 wiring/render 组件;新增静态契约:forbidden imports、forbidden calls、最大容忍的 handler 闭包清单、允许的 runtime facade 导入;如可行再补 handler 身份契约(app callback prop 变化不应重建热处理器,editor/runtime 身份变化可以)。验收:静态守卫在事件 worker 回归导入或调用prepareEditable*Kernel/applyEditable*/recordKernelEventTrace时直接失败。

Phase 8:浏览器证明与压力收口

证明这不是单纯的文件搬移。必测的聚焦浏览器行包括:hovering toolbar 鼠标选择、mentions inline void 双侧导航、表格右箭头单元格边界0、图片/块 void 键盘导航、embeds/块 void 布局与导航、search highlighting 输入焦点保持、placeholder 输入/删除/撤销、richtext 持久 native word-delete、large-document shell 激活与 composition 行。

必测压力族包括 7 个:inline-void-boundary-navigationblock-void-navigationtable-cell-boundary-navigationexternal-decoration-refreshmouse-selection-toolbarpaste-normalize-undoselection-repair-ime

最终收口门禁:

bun --filter slate-react test:vitest bun --filter slate-react typecheck bun --filter slate-react build bun lint:fix # 关闭重试的定向 Chromium 回归包 bun test:stress bun check:full

纪律:如果bun check:full对某行重试,收口前必须单独、关闭重试地重跑该行。

六、实现顺序与理由

计划给出的顺序(原文)是:

  1. Phase 0 inventory guard
  2. Phase 1 facade(零行为迁移)
  3. Phase 2 低风险 clipboard/drag/focus/mouse 族
  4. Phase 3 composition/Android 组装
  5. Phase 4 beforeinput/input 组装
  6. Phase 5 keydown 组装
  7. Phase 6 browser handle 与 target runtime 桥
  8. Phase 7 收缩与静态锁
  9. Phase 8 proof 收口

理由非常明确:beforeinput/input 与 keydown 是最高风险的时序路径,所以在触碰它们之前,事件运行时必须在结构上已经真实存在。低风险族先行,既能在早期拿到确定性收益,又能在最敏感路径动刀前积累足够的门禁与证明经验。

七、非目标:这条 lane 明确不做的事

  • 不改动公开 app renderer DX
  • 不重写 selection / repair / composition / Android 算法,除非失败契约证明抽取暴露了真实 bug
  • 不把runtime-event-engine.ts做成塞满所有事件体的巨型文件
  • 不通过放宽 React 重渲染来换取浏览器行变绿
  • 不给默认bun check增加慢速压力测试
  • 不声称 legacy 浏览器对等性(那是另一条独立的 current-vs-legacy 生成 harness lane)

八、停止与重新规划条件

计划预定义了六种必须停下重新规划的情形:

  • handler 身份稳定要求使用 stale app callbacks
  • 事件运行时变成比EditableDOMRoot更糟的 god module
  • beforeinput/input 行只能靠放宽forceRender()通过
  • keydown 行只能靠把 DOM selection 直接导回EditableDOMRoot通过
  • 静态守卫需要大而模糊的 allowlist
  • 浏览器测试在不断言 model selection、DOM selection、focus owner 与 render budget 的情况下通过(而这些事实恰恰是关键点)

九、完成定义

这条 lane 只有在以下条件全部满足时才算完成:

  • EditableDOMRoot不再组装根事件处理器
  • EditableDOMRoot不再直接导入事件 worker strategy 模块
  • 事件族组装位于useEditableEventRuntime(...)之后
  • 热根处理器跨普通 app callback prop 变化保持稳定,或剩余扰动被显式测量并接受
  • selection、repair、kernel trace、composition、Android、browser handle、target runtime 接线全部成为事件运行时能力
  • 静态守卫阻止事件 worker 导入/调用回归EditableDOMRoot
  • 聚焦浏览器行与生成压力族通过
  • bun check:full通过后才标记完成

十、执行账本复盘:从 20+21 到 0+0 的真实收敛

计划的执行账本(Execution Ledger)完整记录了 2026-04-27 当天的推进轨迹,是理解这套方法"如何落地"的最佳教材:

阶段handler 闭包 / wrapper 常量关键证据
激活(Phase 0 起点)20 / 21EditableDOMRoot直接导入 9 个事件 worker 族
Phase 2 检查点8 / 9剪贴板/拖拽/焦点鼠标迁移完成
Phase 3 检查点5 / 6composition 组装迁出
Phase 4 检查点1 / 1beforeinput/input 组装迁出,9 行 chromium 证明
Phase 5 检查点0 / 0keydown 迁出,33 行 chromium 证明
Phase 6 检查点0 / 0browser handle 与 target-runtime 桥迁出
Phase 7 最终检查点0 / 0facade 组合完成,bun check:full通过

最终收口数据(来自 Phase 7 检查点):authority/surface guard 通过;slate-reacttypecheck 通过(修掉了 runtime facade 状态类型到EditableInputControllerState的一处问题);合并单元门禁 6 文件 50 测试全绿;定向包构建通过(仅保留既有is-hotkeyexternal 警告);聚焦浏览器证明:hovering toolbar/richtext/search-highlighting 15 行 + mentions/tables/images/large-document 33 行;bun lint:fix格式化 10 个文件后各门禁复跑全绿;bun check:full通过,完整集成扫描 628 passed / 4 skipped。

每个检查点都记录了"决策 + 拒绝策略 + 下一步",例如 Phase 4 后明确"不要借机扩大为键盘策略",Phase 5 后明确"不能因为闭包消失就跳过 browser handle/target runtime"。这种"每步一个可验证证据 + 明确排除项"的账本写法,本身也是大型架构迁移值得借鉴的工程纪律。

十一、仓库佐证:静态清单锁 + 浏览器行证模式

本次计划沉淀的"静态清单 + 浏览器证明"双锁模式,在仓库的解决方案文档 docs/solutions/developer-experience/2026-04-27-slate-react-runtime-owner-cuts-need-static-inventories-and-browser-proof.md 中有完整记录。它给出的核心洞察是:把代码移进辅助函数只能让文件变小,无法证明所有权真的变了;必须用静态清单作为架构锁,再用浏览器证明作为回归锁。典型守卫形态:

// test/kernel-authority-audit-contract.ts expectAuthorityInventory(/\bbeginEditableEventFrame\(/g, { 'packages/slate-react/src/editable/runtime-kernel-trace.ts': { count: 3, next: 'central-owner', owner: 'Runtime kernel trace engine', rationale: 'Non-selectionchange event frames are owned by the runtime kernel trace engine.', }, })

该文档还记录了三条"行不通"的路径,与主计划的决策相互印证:把 examples 当安全网行不通(示例行无法阻止宽泛 selector/桥接策略潜回EditableDOMRoot);无清单的 helper 抽取行不通;只跑定向 Chromium 证明不足以收口(压力与bun check:full也必须通过)。

研究决策文档 docs/research/decisions/slate-v2-architecture-verdict-after-human-stress-sweep.md 在 2026-04-28 的状态章节确认了这条 lane 的最终成果:EditableDOMRoot将根策略编排委托给useEditableRootRuntime(...),事件处理器组装位于useEditableEventRuntime(...)之后,通用根选择器被围栏到root-selector-sources.ts,release escape-hatch inventory 反映降低后的react-runtime:stale计数,bun check:full以 628 通过 / 4 跳过收口——与主计划账本完全一致。

十二、可复用的方法论总结

  1. 所有权先于代码:先写清楚"组件应做什么、不应做什么"的清单,再动代码;"名字可以改,所有权不能改"。
  2. 门面必须组合而非吞并:facade 只编排事件族,策略 worker 继续承担算法细节,防止制造新的 god module。
  3. 先冻结、再迁移、后证明:Phase 0 的 inventory guard 先冻结现状,任何行为迁移都必须在清点变绿后进行。
  4. 最危险的路径最后动:beforeinput/input 与 keydown 在运行时结构真实存在后才触碰,且每步都有针对性的浏览器行与压力族证明。
  5. 静态守卫替代评审记忆:赶工补丁必须在本地直接失败,而不是靠 review 拦截。
  6. 完成定义可量化:0 闭包、0 直接导入、facade 唯一入口、bun check:full全绿,缺一不可。

对任何想重构"React 组件闭包承担过多运行时职责"这一类问题的团队,这份计划从职责宣言、边界切割、分阶段执行到账本复盘,都是一份可以直接照搬的完整作战手册。

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

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询