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 修复)、EditableDOMBeforeInputContext与EditableInputCommand的完整类型契约、回调身份稳定性(stable latest handler)的运行时实现思路,以及useMemo(() => callback)这类记忆化反模式为什么必须被硬切割。
一、背景与问题:onDOMBeforeInput为什么会成为旧 Slate 的泄漏点
1.1 症状:浏览器事件拼写成为普通教程
在旧版 Slate React 示例中,加粗、斜体这类最基础的格式化行为,被教成了「手写解析浏览器事件字符串」:
onDOMBeforeInput={(event) => { if (event.inputType === 'formatBold') { event.preventDefault() return toggleMark(editor, 'bold') } }}这段代码同时暴露出三个问题(参见 解决方案文档 的 Symptom 清单):
- 用户被迫直面浏览器差异:
formatBold、formatStrikeThrough这类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 模型策略由应用代码拥有。formatBold、formatItalic、historyUndo、delete、paste、文本插入都不再通过临时拼凑的onDOMBeforeInput示例来教学——那正是「旧 Slate 泄漏」。
三、公共 API 目标
3.1 原始原生逃生舱:onDOMBeforeInput与EditableDOMBeforeInputContext
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 语义命令处理器:onCommand与EditableInputCommand
为「不依赖 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命令,不替应用决定用bold、strong、fontWeight还是自定义 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。实现目标:
- 若仓库/代码规范/运行时约束允许,使用 React
useEffectEvent; - 否则在
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) |
|---|---|---|---|
| ProseMirror | EditorProps.handleDOMEvents是底层逃生舱(返回 true 需自行preventDefault);handleTextInput是带from/to/text和默认事务的语义文本插入钩子;keymap/inputrules是插件而非原始 DOM 事件 prop;prosemirror-view的beforeinput保守,多为 Android/选区管道 | 原始 DOM 处理器与语义编辑器行为分离;给处理器传 view/editor 上下文;默认事务/命令集中管理 | 不复制 ProseMirror 的 position 体系或插件复杂度作为 Slate 常规 DX |
| Lexical | 根事件由 editor root 注册安装;核心beforeinput把formatBold/formatItalic/formatUnderline、undo/redo、insert/delete、粘贴类输入映射为命令;公共扩展行为是命令注册而非onDOMBeforeInput;ReactContentEditable基本只设置根元素 | 原生输入属于运行时;语义命令划分优于用户态 DOM 解析;最新行为应通过 editor/runtime 上下文触达,而非 React prop 身份抖动 | 不复制 class nodes、dollar helpers 或「命令即整个应用 API」 |
| Tiptap | 扩展暴露addCommands、addKeyboardShortcuts、addInputRules和 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已传递editor、event、inputType、selection;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 中还同时存在onBeforeInput、onBeforeInputCapture与onDOMBeforeInput三条 DOM 处理器通道,规划文档明确要求:文档不得再把onBeforeInput与onDOMBeforeInput表述为可互换。
七、硬切割清单
Cut(切割):
useMemo(() => callback)回调工厂;- 教用户在 bold/italic/underline 上使用
onDOMBeforeInput的文档/示例; - 把
onBeforeInput与onDOMBeforeInput表述为可互换的文档; - 由应用回调身份引起的根监听器抖动(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-shortcuts、tables、mentions、iframe:仅在真实订阅/配置身份处保留记忆化,运行时证明后移除简单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 中,公共onCommand与EditableCommand*最终被裁掉——因为其语言过于产品化(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/renderSegment用useCallback;编辑行为示例仍依赖回调 props 与记忆化规则数组;option-object API 迫使示例记忆化对象以避免身份抖动——其中渲染 props 的计划演进方向是editor.extend(editableRenderers({...}))的渲染器注册能力,而非继续传授 React 仪式。
另一个实战佐证来自 集成测试失败记录:当onDOMBeforeInput处理器返回真值时,必须先阻止原生默认再报告事件已处理,否则浏览器仍会执行原生默认插入——这正解释了boolean | void返回值契约与preventDefault的配套关系。
十一、结论
Slate v2 的这次回调记忆化与输入架构重构,核心结论可以浓缩为三句话:
- 原生输入属于运行时:
beforeinput的分类、命令化与 DOM 修复由 Slate React 内部拥有,原生监听器根级挂载一次、通过 stable latest handler 读取最新应用回调,用户零记忆化仪式; - 语义命令属于编辑器行为通道:
format*、历史、删除、粘贴等行为通过语义命令承载,应用只回答「mark 模型策略」,Slate 不替应用硬编码bold; - 逃生舱保留但降级:
onDOMBeforeInput仍是高级场景的原生出口,但普通示例与文档只教语义路径或常规工具栏onClick,useMemo(() => callback)与「React Compiler 才干净」的表述被硬切割。
这套「原始事件在边界、模型行为在运行时」的分层纪律,既保住了 Slate 不预设立场的哲学,又让应用作者摆脱浏览器怪癖与记忆化仪式,可作为富文本编辑器输入层设计的一份可复用参考。
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考