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与 Reactbeforeinputfallbackinput与 input capturepaste、copy、cut、drag、drop- composition start/update/end
focus、blur、click、mouse down、mouse upkeydown- 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.ts | DOM input / React input / input-capture 组装 |
runtime-clipboard-events.ts | copy / cut / paste |
runtime-composition-events.ts | composition start / update / end |
runtime-focus-mouse-events.ts | focus / blur / click / mousedown / mouseup |
runtime-keyboard-events.ts | keydown 组装 |
runtime-drag-events.ts | dragstart / dragover / dragend / drop |
runtime-browser-handle-events.ts | browser 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
onKeyCommand、inputRules
目标契约:当 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-strategycomposition-state事件应用器editing-kernelprepare 函数keyboard-input-strategymodel-input-strategynative-input-strategy- 事件向的
selection-reconcilerworkers
允许保留的导入仅限于:render-only 辅助、contexts、类型,以及事件运行时 hook。
Cut 2:事件运行时拥有处理器组装权
全部 21 个根处理器在 runtime 模块中组装:onDOMBeforeInput、onReactBeforeInput、onDOMInput、onInputCapture、onPaste、onCopy、onCut、onDragStart、onDragOver、onDragEnd、onDrop、onCompositionStart、onCompositionUpdate、onCompositionEnd、onFocus、onBlur、onClick、onMouseDown、onMouseUp、onKeyDown。EditableDOMRoot只做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.tsx、test/kernel-authority-audit-contract.ts、test/surface-contract.tsx。
驱动门禁:
bun --filter slate-react test:vitest test/kernel-authority-audit-contract.test.ts test/surface-contract.test.tsxPhase 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:stressPhase 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=chromiumPhase 6:迁移 Browser Handle 与 Target Runtime 接线
attachSlateBrowserHandle(...)移入runtime-browser-handle-events.ts;writeTargetRuntime(...)移入事件运行时门面或小型 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-navigation、block-void-navigation、table-cell-boundary-navigation、external-decoration-refresh、mouse-selection-toolbar、paste-normalize-undo、selection-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对某行重试,收口前必须单独、关闭重试地重跑该行。
六、实现顺序与理由
计划给出的顺序(原文)是:
- Phase 0 inventory guard
- Phase 1 facade(零行为迁移)
- Phase 2 低风险 clipboard/drag/focus/mouse 族
- Phase 3 composition/Android 组装
- Phase 4 beforeinput/input 组装
- Phase 5 keydown 组装
- Phase 6 browser handle 与 target runtime 桥
- Phase 7 收缩与静态锁
- 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 / 21 | EditableDOMRoot直接导入 9 个事件 worker 族 |
| Phase 2 检查点 | 8 / 9 | 剪贴板/拖拽/焦点鼠标迁移完成 |
| Phase 3 检查点 | 5 / 6 | composition 组装迁出 |
| Phase 4 检查点 | 1 / 1 | beforeinput/input 组装迁出,9 行 chromium 证明 |
| Phase 5 检查点 | 0 / 0 | keydown 迁出,33 行 chromium 证明 |
| Phase 6 检查点 | 0 / 0 | browser handle 与 target-runtime 桥迁出 |
| Phase 7 最终检查点 | 0 / 0 | facade 组合完成,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 跳过收口——与主计划账本完全一致。
十二、可复用的方法论总结
- 所有权先于代码:先写清楚"组件应做什么、不应做什么"的清单,再动代码;"名字可以改,所有权不能改"。
- 门面必须组合而非吞并:facade 只编排事件族,策略 worker 继续承担算法细节,防止制造新的 god module。
- 先冻结、再迁移、后证明:Phase 0 的 inventory guard 先冻结现状,任何行为迁移都必须在清点变绿后进行。
- 最危险的路径最后动:beforeinput/input 与 keydown 在运行时结构真实存在后才触碰,且每步都有针对性的浏览器行与压力族证明。
- 静态守卫替代评审记忆:赶工补丁必须在本地直接失败,而不是靠 review 拦截。
- 完成定义可量化:0 闭包、0 直接导入、facade 唯一入口、
bun check:full全绿,缺一不可。
对任何想重构"React 组件闭包承担过多运行时职责"这一类问题的团队,这份计划从职责宣言、边界切割、分阶段执行到账本复盘,都是一份可以直接照搬的完整作战手册。
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考