Slate v2 示例 DX 重构指南:从遗留示例反推六大公共 API 表面
2026/9/17 6:12:23 网站建设 项目流程

Slate v2 示例 DX 重构指南:从遗留示例反推六大公共 API 表面

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

本篇技术指南以 Slate v2 Legacy Example DX Ralplan 为骨架,系统拆解如何在不污染原始 Slate 无意见(unopinionated)内核的前提下,通过修复公共 React 辅助层来消除示例中的样板代码。文章覆盖六个已评审的 API 表面(编辑器历史初始化、清单回退行为、代码高亮装饰生命周期、TypeScript 类型推断、注解 store 上下文、void 渲染属性命名),并给出可直接照搬的 API 形态、分阶段执行计划与回归证明清单。读完你将掌握:如何判断"示例里的样板是 API 缺陷而非用户错误"、如何用 React 生命周期 Hook 收敛运行时 store 的手工清理、以及如何让 TypeScript 示例教推断而不是教强转。

一、评估结论:遗留示例正在教坏两种模式

该计划给当前状态打分为0.91ready-for-user-review),并给出一个尖锐判断:遗留示例正在滑向两种坏的教学模式

  1. 运行时 store 的生命周期样板过多——用户在示例里被迫手写useMemo创建运行时对象,再在useEffectcleanup 里手动销毁;
  2. 模型行为被塞进 React 事件处理器——因为扩展/输入 API 还不够好用,示例作者只能退而求其次,把本应属于编辑器模型的行为写进onKeyDown

计划给出的立场非常明确:不要为了让示例好看就让 Slate 变得魔法化(opinionated),而要修复示例暴露出来的可复用表面。这决定了整篇计划的性质——它更新但不替代已接受的初始化方案 React Editor Initialization And Value Ralplan,是一个纯粹的 DX/API 清理 pass。

二、意图与边界:无意见内核 + 一等公民示例

计划的 Intent 可以浓缩为三点:

  • 保持原始 Slate 无意见,同时让示例有一等公民(first-class)的体验;
  • 避免教用户复制那些仅仅因为公共 React 辅助层缺了一层而存在的样板代码
  • 对照 Lexical、ProseMirror、Tiptap 比较示例 DX,但不引入它们的完整心智模型。

范围内范围(In scope)

计划明确圈定了六个待评审表面与执行时的涉及面:

  • .tmp/slate-v2/site/examples/ts/**(示例源码)
  • .tmp/slate-v2/packages/slate-react/src/hooks/use-slate-editor.ts
  • .tmp/slate-v2/packages/slate-react/src/decoration-source.ts
  • .tmp/slate-v2/packages/slate-react/src/hooks/use-slate-annotations.tsx
  • .tmp/slate-v2/packages/slate-react/src/components/slate.tsx
  • .tmp/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsx
  • .agents/rules/ralph.mdc规则更新(仅执行阶段)

说明:计划中的slate-v2是一个独立的 workspace 检出(原文以绝对路径/Users/zbeyens/git/slate-v2/...引用)。在当前仓库中,对应的 Slate 引擎位于 packages/slate,其中slate-react相关的 Hook、decoration source、annotation 上下文等即为该 workspace 的内容,本文以计划原文为准,并结合仓库内packages/slate源码佐证历史插件与类型契约部分。

非目标(Non-goals)

这四条边界决定了所有后续决策不能越界:

  • 不让slate-react依赖slate-history(保持运行时边界干净);
  • 不给原始 Slate 加 Plate 式插件
  • 不复活公开的扩展commands槽位——v2 扩展硬切(hard cut)明确拒绝该表面;
  • 不宣称关闭任何 issue——这是 DX/API 清理,issue 账本不增加Fixes #....行。

三、现场状态(Live Current State):六个痛点的真实代码

计划先逐一定位现状,这是所有决策的事实基础。

3.1 编辑器创建与历史初始化

  • useSlateEditor创建withReact(createEditor(editorOptions)),且只有显式传入时才运行withEditoruse-slate-editor.ts:23);
  • CreateEditorOptions目前只携带initialValueinitialSelection两个字段;
  • slate-reactslate-history只是devDependency,不是运行时依赖——这一点在本仓库的 packages/slate/package.json 中也可印证:slate包本身只依赖@udecode/utilsis-plain-objectlodashscroll-into-view-if-neededslateslate-dom,历史能力由单独的 slate-history/with-history.ts 提供。

示例层的历史样板分两种:

  • 复杂示例重复写withEditor: (editor) => withHistory(editor) as CustomEditor(如code-highlighting.tsx:54);
  • 简单示例可以直接传withEditor: withHistory(如plaintext.tsx:6)。

差异的根源是withHistory的泛型保真度不够,导致复杂场景必须强转。

3.2 清单(Checklist)回退行为

  • 清单目前通过EditableonKeyDown处理 Backspace(check-lists.tsx:75);
  • 该处理器调用模型逻辑并返回trueEditable视其为已处理并阻止默认行为(keyboard-input-strategy.ts:74);
  • 模型行为本身是基于辅助函数的,而非扩展拥有(check-lists.tsx:97)。

3.3 代码高亮与装饰源生命周期

  • 示例用useMemo创建装饰源,并在useEffect中手动destroy()code-highlighting.tsx:60);
  • 公共底层工厂是createDecorationSource(editor, options)decoration-source.ts:111);
  • code-highlighting.tsx导入type Node as SlateNode仅为标注match回调参数(code-highlighting.tsx:25)——这是典型的"为类型擦屁股"样板。

3.4 注解(Annotation)store 上下文

  • <Slate>目前接受annotationStores并组合它们的投影 store(slate.tsx:62);
  • useSlateAnnotations仍要求显式传 store 参数(use-slate-annotations.tsx:68);
  • collaborative-comments.tsx把同一个 store 传给<Slate>,又手工向下传给CommentListcollaborative-comments.tsx:540)——同一份数据被反复透传。

3.5 Void 渲染属性

  • renderVoid收到名为target的属性,但该值字面上就是一个Patheditable-text-blocks.tsx:456);
  • 导出的属性类型是target: Patheditable-text-blocks.tsx:496);
  • 示例随后把该属性类型写为target: Path并当作at使用(images.tsx:119)。

"名字叫 target、类型是 Path、用起来是 at"——这是命名与语义脱节的典型反例。

四、生态证据:Lexical / ProseMirror / Tiptap 怎么处理同一问题

计划没有闭门造车,而是对照了三大编辑器生态的本地源码:

Lexical

  • 核心createEditor默认不包含历史;vanilla 示例用registerHistory显式注册(lexical/examples/vanilla-js-iframe/src/main.ts:38);
  • React 富文本通过<HistoryPlugin />插件加入(lexical/examples/react-rich/src/App.tsx:159);
  • 更新的扩展路径在 dependencies 中显式包含HistoryExtensionlexical/examples/extension-vanilla-tailwind/src/main.ts:83);
  • 清单与 Tab 行为注册为编辑器命令,而不是散落在应用onKeyDown分支里(checkList.ts:68TabIndentationExtension.ts:83)。

ProseMirror

  • 历史是一个插件,带事务元数据、分组与协作 rebase 行为,不是核心编辑器默认能力(history/src/history.ts:258)。

Tiptap

  • StarterKit 默认包含 undo/redo,但允许用undoRedo: false关闭(starter-kit.ts:95starter-kit.ts:218);
  • 更底层的扩展显式注册 ProseMirror history 与键盘快捷键(undo-redo.ts:46);
  • 键盘行为通过扩展的addKeyboardShortcuts注册,由扩展管理器转成 keymap 插件(ExtensionManager.ts:110)。

结论:三条可复制经验

  1. 复制"显式历史 / 意见化预设"的拆分——原始引擎与历史插件/扩展分离;
  2. 复制"扩展拥有键盘行为"——模型行为归扩展,React 事件钩子只做逃生舱;
  3. 不要让原始 Slate 静默包含历史——这是对协作、只读、历史分叉与自定义批处理场景的尊重。

五、决策简报:六个可落地的公共 API 决策

这是整篇计划的核心,每一条都给出"为什么 + 目标形态 + 被否决方案"。

5.1 历史默认:不进useSlateEditor

决策:默认不把历史放进useSlateEditor

理由slate-react运行时并不拥有slate-history;Lexical 与 ProseMirror 都让历史保持 opt-in;Tiptap 只在意见化的 starter kit 里默认历史;协作场景常常需要自定义历史,隐藏默认会造成意外双历史(double-history)和迁移痛苦。

目标形态

const editor = useSlateEditor({ initialValue, withEditor: withHistory, });

自定义组合时:

const editor = useSlateEditor({ initialValue, withEditor: (editor) => withImages(withHistory(editor)), });

执行要求:修复withHistory的泛型保真,使常见示例不再需要as CustomEditor。如果仓库未来创建slate-starter预设包或示例专用辅助层,该预设可以默认包含历史并提供显式关闭开关,但必须保持远离原始useSlateEditor

被否决

const editor = useSlateEditor({ initialValue }); // 隐藏历史

计划原话:"That is convenient but wrong for Slate."

本仓库中的实现佐证:withHistory在 packages/slate/src/slate-history/with-history.ts 中是独立的泛型插件,为T extends Editor的编辑器注入historyundoredo与批处理 API;packages/slate/type-tests/history.ts 用@ts-expect-error断言了history.undos批次必须携带 operations、setSplittingOnce只接受 boolean,正是"泛型保真 + 类型契约"的落地形态。

5.2 清单 Backspace:从组件事件回归模型行为

决策:当前onKeyDown方案机械上安全,但不是最佳 DX。

为什么会这样:v2 的Editable拥有按键分类、组合输入(composition)、shell/虚拟化修复与默认行为阻止;用户按键处理器返回true就接入这条运行时路径。

为什么还不够好:清单 Backspace 是模型行为,不是组件事件关注点;应用作者不应每次使用清单都记得接线onKeyDown;Lexical 与 Tiptap 把这类行为放在命令/扩展里。

目标

  • 保留当前辅助函数作为本地安全桥;
  • 为需要按键意图访问的模型行为增加扩展拥有的键盘/输入能力(capability);
  • 通过该能力或类型化的withChecklists组合器实现清单 Backspace,而不是在示例里逐个写Editable onKeyDown
  • 不复活公开扩展commands——v2 扩展硬切明确拒绝公开commands槽位,改用 capabilities 或运行时输入处理器。

5.3 代码高亮与装饰源生命周期:新增 React 生命周期 Hook

决策:为装饰源增加 React 生命周期 Hook。

现状样板(太多手工代码):

const codeHighlightingSource = useMemo( () => createDecorationSource(editor, options), [editor], ); useEffect( () => () => codeHighlightingSource.destroy(), [codeHighlightingSource], );

目标形态

const codeHighlightingSource = useSlateDecorationSource(editor, { id: "code-highlighting", dirtiness: ["text", "node"], read: ({ snapshot }) => collectCodeProjections(snapshot.children), runtimeScope: ({ snapshot }) => collectCodeRuntimeScope(snapshot), });

规则

  • createDecorationSource保留为底层 API;
  • slate-react新增useSlateDecorationSource负责常见 React 生命周期;
  • 不要隐藏dirtinessruntimeScope——它们就是性能契约(invalidation contract);
  • 通过抽取命名辅助函数削减code-highlighting.tsx的体量。

5.4 TypeScript 推断:示例要教推断,不教强转

决策:激进清理示例类型。

当前最差范例

match: (n: SlateNode) => Node.isElement(n) && n.type === ParagraphType;

目标形态

match: (node) => Node.isElement(node) && node.type === ParagraphType;

执行清单

  • 删除无用的type Node as SlateNode导入;
  • 删除 prop/API 已能推断的内联回调参数类型;
  • 保留真正导出、无处可推断的组件 prop 类型;
  • 在组合器类型修复后删除可避免的as CustomEditoras any与别名强转;若仍有强转,必须附带局部理由;
  • 把规则写入.agents/rules/ralph.mdc,然后运行pnpm install(规则是生成技能的源头)。

需编码的规则文本

For TypeScript examples, prefer inference. Do not annotate callback parameters, alias broad node types, or use `as any` / public type casts unless the compiler cannot infer the public API shape. Prefer type guards, `satisfies`, and fixed generic surfaces over local assertions.

5.5 注解 store 上下文:单数annotationStore+ 上下文默认 Hook

决策:store 应从 Slate 上下文中消费,公共 provider prop 应为单数annotationStore

现状问题<Slate>接受复数annotationStores,DX 更差。一个SlateAnnotationStore本身就通过allIds/byId存储多个注解;channel/source 的区分应落在注解数据/投影上,而不是复数的 provider prop。

目标形态

<Slate annotationStore={annotationStore} editor={editor}> <CommentList /> </Slate>
const snapshot = useSlateAnnotations();

API 形态

  • useSlateAnnotations()使用最近的注解 store;
  • useSlateAnnotations(store)对外部侧边栏、跨编辑器检查器、显式非上下文读取仍然有效;
  • 若不存在 store,按当前 Hook 哲学返回空快照或在开发环境抛出;
  • 若产品确实需要独立生命周期的多个 store,暴露显式组合辅助函数或要求应用自建聚合 store——不要让公共 provider prop 变成复数
  • 不要把注解 store 放到核心编辑器上——它们是 React/投影运行时状态,不是文档模型状态。

5.6renderVoid属性命名:targetpath

决策:把公共renderVoidprop 从target改名为path,除非值先变成真正的 target 对象。

当前 API

target: Path;

这很含糊。如果值就是Path,prop 就应该是:

path: Path;

未来可选形态(仅当确实有用):

target: { path: Path; runtimeId: RuntimeId; }

不要target: Path作为 v2 稳定公共 API——它听起来抽象,实际信息量比path更少,对 Agent 和人类都不友好。

六、分阶段执行计划(Phase 1–6)与验收标准

计划明确要求"不要一次性实现所有阶段",首次执行应先做 Phase 1 + 一个窄幅示例清理,让类型证据先落地。

阶段内容验收标准
Phase 1历史与组合器类型useSlateEditor默认保持无历史;复查withHistory/withReact泛型;移除简单示例中的强转;为withHistory保持ValueOf<T>与编辑器交叉类型增加类型测试示例可用withEditor: withHistory;自定义组合器无需强转(除非自定义编辑器类型刻意比运行时扩展更宽)
Phase 2清单行为归属保留onKeyDown作为基线证明;设计兼容 v2 输入运行时与组合保护的最小扩展键盘/输入能力;把清单 Backspace 移入withChecklists或清单扩展;补充"清单项开头按 Backspace"的聚焦测试清单示例不再在Editable手工接线 Backspace;IME/组合按键行为保持绿色
Phase 3装饰 Hook新增useSlateDecorationSource;迁移代码高亮、搜索高亮、外部装饰源、markdown 预览、高亮文本等示例;保留底层createDecorationSource导出除演示底层 API 的示例外,不再手工配对createDecorationSource+ cleanupuseEffect;装饰 store 指标与 runtime-scope 行为不变
Phase 4注解上下文 Hook公共 provider prop 从annotationStores改名为annotationStore;由<Slate annotationStore>派生上下文;useSlateAnnotations(store?)useSlateAnnotation(id, store?)支持上下文默认;迁移协作评论、审阅评论、持久化注解锚点collaborative-comments.tsx不再为列注解把同一 store 同时塞进<Slate>和组件 props;一个 store 可承载多个注解 channel
Phase 5Void prop 改名RenderVoidProps<T>['target']改名为path;更新消费它的示例与 Hook;仅在测试/示例需要迁移桥时才保留临时内部别名公共示例把path传给 transforms 或useElementSelected;若未来引入稳定对象,必须按真实 target 命名与类型化,而非 Path 别名
Phase 6示例类型清理与规则同步移除所有示例中无用的回调参数标注与别名导入;在 API 修复后移除可避免的强转;更新.agents/rules/ralph.mdc的类型推断规则;运行pnpm install同步生成的 skillsrg -n "match: \(n: SlateNode\)|type Node as SlateNode| as any| as CustomEditor" .tmp/slate-v2/site/examples/ts只剩有正当理由的残留;.agents/rules/ralph.mdc包含推断规则

七、回归证明:改完怎么验证

实现后必须执行的聚焦证明清单:

  • bun --filter slate-react typecheck
  • bun --filter slate-history typecheck
  • 聚焦的 Slate React 装饰源生命周期测试
  • 聚焦的注解 store Hook 测试
  • 聚焦的清单 Backspace 测试
  • 聚焦的示例 typecheck 或示例应用 typecheck
  • 浏览器冒烟测试页面:/examples/check-lists/examples/code-highlighting/examples/collaborative-comments/examples/images/examples/embeds/examples/mentions

除非聚焦行指向运行时选区/输入风险,否则不跑全量浏览器集成。

八、维护者异议与回应:七个关键 Q&A

计划通过 steelman 记录了对立观点,这些问答最能体现设计权衡:

"历史就该默认,人人都要 undo。"否。人人都要 undo,直到协作、只读、历史分叉或自定义批处理出现。Lexical、ProseMirror、Tiptap 都把原始引擎与历史插件/扩展分开,Slate 也应如此。

"但示例没有默认历史更吵了。"正确。那就修组合器类型与示例辅助层的故事,而不是改包边界。

"清单行为写在onKeyDown更简单。"对单个文件更简单,对被人复制的示例更糟。模型行为属于编辑器扩展行为,React 事件钩子只是逃生舱。

"装饰 Hook 会隐藏性能。"只有当它隐藏dirtinessruntimeScope时才会。Hook 应隐藏生命周期清理,而非失效契约。

"注解 store 是外部的;Hook 应显式接收 store。"显式 store 参数应保留。默认值应使用最近的 Slate 上下文,因为<Slate annotationStore>就是编辑器局部的投影通道。

"如果我有评论、建议、审阅标记呢?"放进同一个SlateAnnotationStore,加kind/channel/source字段。store 本身就是集合。若确实需要独立生命周期,先组合再传给<Slate>

"target听起来面向未来。"只要类型还是Path就不成立。用误导性名字做面向未来设计是假设计:要么叫path,要么做成真正的 target 对象。

九、应用技能记录与评分

计划明确记录了所使用的技能与验证维度:

  • slate-ralplan:已应用,基于活源码的规划/评审 pass;
  • intent-boundary-pass:意图、范围、非目标、issue 边界均已显式化;
  • steelman-pass:记录了维护者异议;
  • high-risk-deliberate-pass:公共 API 与运行时行为变更以证明为准;
  • performance/performance-oracle:装饰失效与运行时 store 生命周期保持dirtinessruntimeScope可见;
  • react-useeffect:用 Hook 收敛用户层重复的useMemo/useEffect清理仪式;
  • tdd:作为证明要求而非实现方式。

六个维度的评分(满分 1.0):React 19.2 运行时性能0.90、Slate 无意见 DX0.94、Plate 与 slate-yjs 迁移主干0.88、回归证明测试策略0.90、研究证据完整性0.92、shadcn 风格组合性与极简0.92,加权总分0.91

十、执行日志:Phase 1–6 已全部落地

计划的执行日志记录了从"决策"到"证据"的完整闭环,全部阶段标注为complete

  • Phase 1useSlateEditor({ withEditor: withHistory })保持默认无历史且保留ReactEditor & HistoryEditor交叉类型;清单示例改为直接withEditor: withHistory,不再as CustomEditor;新增覆盖"装饰 sidecar 状态可选"的示例形态类型契约。
  • Phase 2:新增editableInputRules(...)作为从编辑器扩展能力注册Editable输入行为的 Slate React 辅助函数;Editable合并显式 propinputRules与扩展输入规则;清单示例安装checklists扩展,不再在示例级Editable onKeyDown接线 Backspace。浏览器冒烟:在 "Slide to the left." 开头按 Backspace,复选框数从 6 变 5 且文本保留。
  • Phase 3:新增useSlateDecorationSource(editor, options);代码高亮、搜索高亮、markdown 预览、高亮文本、外部装饰源、渲染策略运行时示例全部从手工createDecorationSource+ cleanupuseEffect迁移;底层 API 保留,dirtiness/runtimeScope在调用点保持可见。
  • Phase 4<Slate annotationStores={[store]}>改名<Slate annotationStore={store}>;新增注解 store 上下文与上下文默认的useSlateAnnotations()/useSlateAnnotation(id);保留显式 store 参数供树外/跨编辑器读取;rg "annotationStores"在源码与文档中零命中。
  • Phase 5renderVoid改为接收{ element, path }而非{ element, target }RenderVoidProps['path']是唯一公共 path 字段,不保留target别名;useElementSelected(path?)Path参数按字面命名;rg验证target相关模式零命中。
  • Phase 6:删除type Node as SlateNodematch: (n: SlateNode)as anyas CustomEditor等禁止模式;huge-document的 content-visibility 强转换成窄解析器;类型推断规则写入ralph.mdc并用pnpm install同步生成技能;新增slate-reactchangeset 记录公开注解/void API 清理。最终验收rgsite/examples/ts零命中。

每个阶段都附带了bun testbunx tsc --project ... --noEmitbun --filter slate typecheckbun --filter slate-react typecheckbun --filter slate-history typecheckbun typecheck:sitebun lint:fixdev-browser冒烟证据。

十一、仓库源码佐证:历史插件的独立性与类型契约

计划讨论的"显式历史 + 泛型保真"在当前仓库中可以直接找到实现证据:

  • with-history.ts 是独立的泛型插件:为T extends Editor注入historyredos/undos栈)、redo/undo以及tf.withoutSavingtf.withoutNormalizingtf.withMerging等批处理 API——历史被设计为可插拔能力而非编辑器内置;
  • history.ts 与 history.spec.tsx、with-history.spec.tsx 提供实现与行为测试;
  • type-tests/history.ts 用@ts-expect-error锁定类型契约:历史批次必须携带 operations、setSplittingOnce只接受 boolean——这正是 Phase 1"为withHistory保持ValueOf<T>与编辑器交叉类型增加类型测试"的仓库内样板;
  • create-editor.ts 中createEditor({ children, selection })只处理文档初值与选区,HistoryApi以独立命名空间导入,进一步印证"核心引擎不内置历史"。

这些证据表明:计划的六个决策并非空中楼阁,而是对既有代码结构的顺理成章的收敛——slate-history保持独立、注解 store 留在 React 投影层、void prop 用字面命名、装饰源生命周期由 Hook 接管、示例类型教推断。

十二、Ready State:面向执行者的启动建议

计划以ready-for-user-review状态收尾,并给出明确启动策略:不要一次性实现所有阶段。第一次执行应取 Phase 1(历史与组合器类型)加上一个窄幅示例清理,让类型证据先落地,再触碰其余阶段。执行日志显示该建议已被遵守并全部完成,最终状态可置为done

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

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

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

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

立即咨询