Slate v2 回调记忆化与原生 beforeinput 输入架构:语义命令处理器设计实录
2026/9/17 6:51:06 网站建设 项目流程

Slate v2 回调记忆化与原生 beforeinput 输入架构:语义命令处理器设计实录

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

导读

本文以仓库中的规划文档 2026-05-14-slate-v2-callback-memoization-dx-ralplan.md 为核心,系统讲解 Slate v2(当前仓库的下一代编辑器内核)如何重构beforeinput原生输入路径:把onDOMBeforeInput收敛为「仅限高级场景」的原始原生逃生舱,同时为其补充语义化命令上下文,并设计onCommand语义命令处理器作为格式化、删除、历史、粘贴等行为的首选通道。读者将掌握 Slate v2 输入运行时的分层架构(原生事件 → 输入分类 → 语义命令 → 模型默认 → DOM 修复)、EditableDOMBeforeInputContextEditableInputCommand的完整类型契约、回调身份稳定性(stable latest handler)的运行时实现思路,以及useMemo(() => callback)这类记忆化反模式为什么必须被硬切割。

一、背景与问题:onDOMBeforeInput为什么会成为旧 Slate 的泄漏点

1.1 症状:浏览器事件拼写成为普通教程

在旧版 Slate React 示例中,加粗、斜体这类最基础的格式化行为,被教成了「手写解析浏览器事件字符串」:

onDOMBeforeInput={(event) => { if (event.inputType === 'formatBold') { event.preventDefault() return toggleMark(editor, 'bold') } }}

这段代码同时暴露出三个问题(参见 解决方案文档 的 Symptom 清单):

  • 用户被迫直面浏览器差异formatBoldformatStrikeThrough这类InputEvent.inputType拼写是浏览器规范层面的细节,不应成为应用作者每天要面对的正常 API;
  • 回调身份泄漏到原生监听器:示例为了「稳定」回调身份,会在外面包上useMemo(() => callback, [editor]),导致原生beforeinput监听器的挂载/卸载被应用回调身份绑架;
  • 所有权模糊:原始 DOM 事件处理器把「Slate 运行时的原生输入分类/模型决策」和「应用自己的 mark 模型策略」混在了一起。

1.2 深层原因:原生输入属于运行时,模型行为属于编辑运行时

规划文档的 Verdict 一针见血:onDOMBeforeInput应当被硬切割(hard cut)出「普通定制/示例路径」,仅保留为原始原生逃生舱(raw native escape hatch)。理由分两层:

  • Slate 哲学:Slate 保持不预设立场(unopinionated),当用户主动要求原生浏览器输入时,应当直接暴露原生事件;但「不预设立场」不等于「让用户解析浏览器怪癖」——原始 DOM 事件属于边界(boundary),模型行为属于编辑器运行时(runtime);
  • DX 底线:普通用户应该写onCommand={(command, { editor }) => ...},而不是:
useMemo(() => (event) => { switch (event.inputType) { // ... } }, [editor])

也不是:

useCallback((event) => { // ... }, [editor])

后一种写法只在回调以「订阅/监听器」身份注册、且身份本身就是 API 契约时才成立。

二、目标架构:语义化编辑流水线

规划文档给出了 Slate React 的正常编辑架构,共六层:

native DOM event -> Slate React native listener router -> input kernel classifies intent/command/selection policy -> semantic editable command handlers run -> model-owned default applies or native browser path continues -> DOM repair / selection export runs

这条流水线的关键语义是:原生事件归一化、意图/命令分类、DOM 修复由运行时拥有;mark 模型策略由应用代码拥有formatBoldformatItalichistoryUndo、delete、paste、文本插入都不再通过临时拼凑的onDOMBeforeInput示例来教学——那正是「旧 Slate 泄漏」。

三、公共 API 目标

3.1 原始原生逃生舱:onDOMBeforeInputEditableDOMBeforeInputContext

onDOMBeforeInput得以保留,是因为 Slate 保持原生友好;但契约必须显式化。目标签名:

onDOMBeforeInput?: ( event: InputEvent, context: EditableDOMBeforeInputContext ) => boolean | void

契约要点:

  • 接收原生InputEvent
  • 运行时机早于 Slate 的默认模型/原生决策;
  • 返回true或调用preventDefault()表示该事件已被处理;
  • 处理器接收上下文对象,示例不再需要闭包捕获陈旧的 editor 状态;
  • 用户代码无需记忆化
  • 仅出现在高级文档(advanced docs),不进入 walkthrough/工具栏教程。

目标上下文类型:

type EditableDOMBeforeInputContext = { command: EditableInputCommand | null; data: unknown; editor: ReactEditor; inputType: string; intent: EditableInputIntent | null; native: boolean; selection: Range | null; };

3.2 语义命令处理器:onCommandEditableInputCommand

为「不依赖 DOM 事件拼写」的编辑器行为新增语义处理器:

onCommand?: ( command: EditableInputCommand, context: EditableCommandContext ) => boolean | EditableRepairRequest | void

这是以下行为的统一通道:

  • 原生格式化命令(如formatBold);
  • 键盘格式化快捷键(如mod+b);
  • delete/backspace/enter 行为;
  • 来自原生输入或 keydown 的历史 undo/redo;
  • paste/drop/yank 路由;
  • 必须驻留在 Slate React 中的轻量 markdown 示例。

目标命令联合类型:

type EditableInputCommand = | { kind: 'format'; format: 'bold' | 'italic' | 'underline' | 'strikethrough' | string } | { kind: 'history'; direction: 'redo' | 'undo' } | { kind: 'delete'; direction: 'backward' | 'forward'; unit?: 'block' | 'line' | 'word' } | { kind: 'insert-break'; variant: 'open-line' | 'paragraph' | 'soft' } | { kind: 'insert-text'; inputType?: string; text: string } | { kind: 'insert-data'; data: DataTransfer } | ...

设计上最关键的一点:Slate 只报告format命令,不替应用决定用boldstrongfontWeight还是自定义 mark 模型——这保住了 Slate 不预设立场的核心。

3.3 示例改写:hovering-toolbar的 before/after

现状(应停止的写法):

onDOMBeforeInput={(event) => { if (event.inputType === 'formatBold') { event.preventDefault() return toggleMark(editor, 'bold') } }}

目标写法:

<Editable onCommand={(command, { editor }) => { if (command.kind !== "format") { return; } switch (command.format) { case "bold": case "italic": case "underline": editor.update((tx) => { tx.marks.toggle(command.format); }); return true; } }} />

如果执行轮次暂不引入onCommand,兜底方案是onDOMBeforeInput(event, context)——绝不是「仅闭包事件 prop」,更不是useMemo(() => callback)

四、为什么这是最佳形态

4.1 Slate 哲学

原生事件属于边界,模型行为属于编辑运行时。两者分层后,原始 Slate 既保留原生访问能力,又不必让常见示例去解析 DOM 事件字符串。

4.2 DX

用户应该写:

onCommand={(command, { editor }) => ...}

而不是用useMemo/useCallback包裹事件回调来维持监听器稳定——除非该回调确实以订阅/监听器身份注册,身份本身就是 API 契约。

4.3 性能

原生监听器应在每个根元素/编辑器根切换时只挂载一次。应用回调变化时,运行时应当更新「读取的最新回调」,而不是卸载并重新挂载原生beforeinput。实现目标:

  • 若仓库/代码规范/运行时约束允许,使用 ReactuseEffectEvent
  • 否则在slate-react内部维护一个稳定的 latest-event helper;
  • 消费端零记忆化仪式,零依赖数组谎言(no dependency-array lies)。

4.4 先前 Slate v2 经验

本方案遵循既有的 runtime-owner 教训:

  • 热编辑策略应属于具名的 Slate React 运行时门面(facades),而非宽泛的 React 根闭包;
  • 所有权切割需要静态清单,而不只是更小的文件;
  • 真实输入/选区行为必须经过浏览器证明(browser proof);
  • 归因 React 渲染前,应把原生输入通道与直接模型输入分开剖析性能;
  • 热浏览器路径应保持私有原始决策廉价,并暴露模型状态,而非原始 DOM 策略理由。

五、候选方案对比:借鉴什么、拒绝什么

规划文档对六个方向的竞品/参考实现做了证据化对比:

候选证据要点借鉴(Steal)拒绝(Reject)
ProseMirrorEditorProps.handleDOMEvents是底层逃生舱(返回 true 需自行preventDefault);handleTextInput是带from/to/text和默认事务的语义文本插入钩子;keymap/inputrules是插件而非原始 DOM 事件 prop;prosemirror-viewbeforeinput保守,多为 Android/选区管道原始 DOM 处理器与语义编辑器行为分离;给处理器传 view/editor 上下文;默认事务/命令集中管理不复制 ProseMirror 的 position 体系或插件复杂度作为 Slate 常规 DX
Lexical根事件由 editor root 注册安装;核心beforeinputformatBold/formatItalic/formatUnderline、undo/redo、insert/delete、粘贴类输入映射为命令;公共扩展行为是命令注册而非onDOMBeforeInput;ReactContentEditable基本只设置根元素原生输入属于运行时;语义命令划分优于用户态 DOM 解析;最新行为应通过 editor/runtime 上下文触达,而非 React prop 身份抖动不复制 class nodes、dollar helpers 或「命令即整个应用 API」
Tiptap扩展暴露addCommandsaddKeyboardShortcutsaddInputRules和 paste rules;键盘/输入行为在扩展边界被产品化产品级 DX 应像行为注册,而非 DOM 探索;Plate 应拥有丰富的规则族与打磨的扩展体验原始 Slate 不应变成 Tiptap 那种预设立场的扩展产品
edix明确以原生beforeinput驱动,要求InputEvent.getTargetRanges;对 beforeinput 一律 preventDefault,忽略原生 format/history 输入类型,用自研轻量事务路径管理输入原生beforeinput可以作为浏览器输入的主信号;根监听器生命周期可以简单直接仅忽略format*与 history 对 Slate React 不够;这是小表面积架构,不是完整文档编辑器答案
use-editable / rich-textarea / markdown-editor小型编辑器用直接监听器、mutation observer 或捕获的 React 事件;API 刻意小巧、面向特定表面低仪式感很重要;小表面不应要求完整的扩展/运行时心智模型不以 mutation-observer-first 包装或 React capture 处理器作为 Slate v2 严肃输入运行时的基础
Pretext / Premirror并非直接的beforeinputAPI 指南;其教训是布局/测量值得拥有独立的确定性通道,而非让输入变得布局感知布局/测量应独立成道不因此让输入变得依赖布局

六、当前 Slate v2 状态:已有构件与缺口

6.1 已经指向正确方向的实时源码

规划文档列出现有运行时就绪的部分:

  • EditableInputRuleContext已传递editoreventinputTypeselection
  • EditableKeyDownHandler已使用(event, context)形态;
  • EditableCommand已建模 delete、history、insert text/data、selection、set block、toggle mark;
  • 编辑内核已对beforeinput做意图分类;
  • runtime-before-input-events.ts已把原生beforeinput路由到「分类 → 选区同步 → 输入规则 → 模型所属操作 → DOM 修复」。

6.2 当前缺口(与仓库源码对照)

仓库当前版本的 EditableProps.ts 中,onDOMBeforeInput类型仍是:

onDOMBeforeInput?: (event: InputEvent) => void;

对应规划文档列出的缺口:

  • onDOMBeforeInput类型仍为(event: InputEvent) => void,未包含boolean | void与上下文;
  • 运行时已接受boolean | void,但文档/类型没有跟上;
  • 回调身份仍流经原生监听器/ref 依赖;
  • format*类 beforeinput 已被分类为format意图,但尚无语义命令承接——这正是示例回退到裸event.inputType的原因;
  • inputRules作为Editableprop 对原始 Slate 过于贴近 Tiptap/Plate 产品 API:仅当明确限定为底层输入钩子时才保留,否则富规则上移给 Plate。

仓库 DOMHandlers.ts 中还同时存在onBeforeInputonBeforeInputCaptureonDOMBeforeInput三条 DOM 处理器通道,规划文档明确要求:文档不得再把onBeforeInputonDOMBeforeInput表述为可互换。

七、硬切割清单

Cut(切割)

  • useMemo(() => callback)回调工厂;
  • 教用户在 bold/italic/underline 上使用onDOMBeforeInput的文档/示例;
  • onBeforeInputonDOMBeforeInput表述为可互换的文档;
  • 由应用回调身份引起的根监听器抖动(root listener churn);
  • 任何暗示「Slate 示例要干净就必须依赖 React Compiler」的表述。

Keep(保留)

  • onDOMBeforeInput作为原生逃生舱;
  • onKeyDown作为常规 React 键盘事件逃生舱;
  • 模型所有的 delete/history/insert/paste 行为留在 Slate React 运行时;
  • Plate 作为丰富输入规则/插件家族的归宿。

Revise(修订)

  • onDOMBeforeInput类型扩展为boolean | void并携带上下文;
  • EditableCommand纳入原生 format 命令;
  • 示例优先使用语义命令或常规工具栏onClick
  • 文档把原始 DOM beforeinput 移至高级章节。

八、实施计划:五个阶段与验证门

Phase 1:契约测试先行(TDD)

  • 类型测试:onDOMBeforeInput接受(event, context) => boolean | void
  • 运行时测试:更换onDOMBeforeInput身份时,调用最新处理器且不重挂原生beforeinput
  • 运行时测试:formatBold/formatItalic/formatUnderlinebeforeinput 分类为语义 format 命令;
  • 运行时测试:若onCommand处理了 format 命令,Slate 阻止原生默认且不继续模型/原生兜底;
  • 运行时测试:若无语义处理器处理 format 命令,Slate 保持安全 no-op/原生拒绝路径,不做硬编码的boldmark 变更

Phase 2:运行时边界

  • slate-react内部实现 stable latest-event;
  • 原生监听器挂载以根元素/编辑器根切换为键;
  • EditableDOMBeforeInputContext注入onDOMBeforeInput
  • 在意图/命令分类之后、默认模型/原生应用之前,把onCommand注入输入运行时。

Phase 3:命令/意图形态

  • 为原生 format 输入新增format编辑命令;
  • 归一化原生输入类型:
formatBold -> { kind: 'format', format: 'bold' } formatItalic -> { kind: 'format', format: 'italic' } formatUnderline -> { kind: 'format', format: 'underline' } formatStrikeThrough-> { kind: 'format', format: 'strikethrough' }
  • 不为原始 Slate 硬编码默认 mark 变更;
  • 命令元数据保留在 editable 运行时内,不进入根slate公共导出

Phase 4:公共示例与文档

  • 重写hovering-toolbar:原生格式化走onCommand,UI 按钮走常规工具栏onClick
  • 移除useMemo回调工厂的导入与使用;
  • 审查markdown-shortcutstablesmentionsiframe:仅在真实订阅/配置身份处保留记忆化,运行时证明后移除简单Editable事件 prop 上的仪式;
  • onDOMBeforeInput文档移入高级原生逃生舱指引;
  • 文档中不写迁移/changelog 语言。

Phase 5:验证

.tmp/slate-v2运行:

  • 新处理器类型的红/绿类型测试;
  • slate-react编辑内核/运行时聚焦测试;
  • hovering-toolbar 原生格式化的浏览器聚焦证明;
  • markdown 快捷键输入行为的浏览器聚焦证明;
  • bun --filter slate-react typecheck
  • bun lint:fix
  • bun check

grep 门禁:

rg -n -U "useMemo\\(\\s*\\n\\s*\\(\\)\\s*=>\\s*\\([^)]*\\)\\s*=>" site docs packages -g '!site/out/**' rg -n "onDOMBeforeInput=.*format|formatBold|formatItalic|formatUnderline" site docs packages -g '!site/out/**'

九、问题台账与评审门

规划文档对关联 Issue 采用「仅缓存优先的关联记录,不做修复声明」的纪律:

  • #4681:原始onDOMBeforeInput行为保持关联;
  • #3568/#3586:beforeinput 格式化/mark 变更缺陷在语义 format 命令证明后获得更好覆盖,但精确关闭仍需匹配的复现证明;
  • #5181:陈旧回调/editor prop 压力由 stable latest handlers 处理(若落地);
  • #4317:渲染回调抖动相邻但不由此计划关闭。

台账同步目标:pr-description.md 新增原生命令边界章节而不改动已修复声明;issue-coverage-matrix.md 将上述五个 Issue 刷新为 related/非关闭行;fork-issue-dossier.md 记录该表面的缓存优先关联 Issue 轮次。

评审门覆盖slate-ralplan(仅规划,不直接改 Slate v2 源码)、hard-cut(切割的是示例教学路径而非逃生舱本身)、repo-research-analyst(竞品对比基于本地源码阅读)、performance-oracle(主要风险是原生监听器抖动与回调大规模失效)、react-useeffect(原生监听器生命周期是 effect/订阅问题,用户格式化响应是命令/事件处理器逻辑)、tdd(先锁定回调稳定性/上下文形状/format 命令分类再清理)、ce-compound(可复用的语义命令/原生 beforeinput 边界模式在验证后沉淀)等。

十、落地结果与后续收敛:onCommand的最终命运

规划文档评分为 1.00,执行证明补全了剩余 0.05,并明确「最终 API 命名为onCommand,因为Editable已提供作用域,导出的EditableCommand*类型让命令族保持显式」。

但需要说明后续的真实收敛:在 pr-description.md 的 Accepted current shape 中,公共onCommandEditableCommand*最终被裁掉——因为其语言过于产品化(crossed into product command language):

  • onDOMBeforeInput保留为接收 Slate 上下文的原始原生InputEvent逃生舱;
  • 原生 format 行为归 Slate 所有,保持为内部运行时行为,由聚焦契约覆盖;
  • 原生输入监听器在根上挂载一次,无需用户useMemo/useCallback即读取最新处理器 prop;
  • Slate 报告 format 命令但不硬编码 mark 模式(pr-description.md)。

与此同时,配套的 example-memoization-hard-cut-ralplan.md 进一步确认三条库级记忆化泄漏:渲染文档仍在教renderElement/renderLeaf/renderText/renderSegmentuseCallback;编辑行为示例仍依赖回调 props 与记忆化规则数组;option-object API 迫使示例记忆化对象以避免身份抖动——其中渲染 props 的计划演进方向是editor.extend(editableRenderers({...}))的渲染器注册能力,而非继续传授 React 仪式。

另一个实战佐证来自 集成测试失败记录:当onDOMBeforeInput处理器返回真值时,必须先阻止原生默认再报告事件已处理,否则浏览器仍会执行原生默认插入——这正解释了boolean | void返回值契约与preventDefault的配套关系。

十一、结论

Slate v2 的这次回调记忆化与输入架构重构,核心结论可以浓缩为三句话:

  1. 原生输入属于运行时beforeinput的分类、命令化与 DOM 修复由 Slate React 内部拥有,原生监听器根级挂载一次、通过 stable latest handler 读取最新应用回调,用户零记忆化仪式;
  2. 语义命令属于编辑器行为通道format*、历史、删除、粘贴等行为通过语义命令承载,应用只回答「mark 模型策略」,Slate 不替应用硬编码bold
  3. 逃生舱保留但降级onDOMBeforeInput仍是高级场景的原生出口,但普通示例与文档只教语义路径或常规工具栏onClickuseMemo(() => callback)与「React Compiler 才干净」的表述被硬切割。

这套「原始事件在边界、模型行为在运行时」的分层纪律,既保住了 Slate 不预设立场的哲学,又让应用作者摆脱浏览器怪癖与记忆化仪式,可作为富文本编辑器输入层设计的一份可复用参考。

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

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

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

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

立即咨询