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.91(ready-for-user-review),并给出一个尖锐判断:遗留示例正在滑向两种坏的教学模式:
- 运行时 store 的生命周期样板过多——用户在示例里被迫手写
useMemo创建运行时对象,再在useEffectcleanup 里手动销毁; - 模型行为被塞进 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)),且只有显式传入时才运行withEditor(use-slate-editor.ts:23);CreateEditorOptions目前只携带initialValue与initialSelection两个字段;slate-react对slate-history只是devDependency,不是运行时依赖——这一点在本仓库的 packages/slate/package.json 中也可印证:slate包本身只依赖@udecode/utils、is-plain-object、lodash、scroll-into-view-if-needed、slate与slate-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)回退行为
- 清单目前通过
Editable的onKeyDown处理 Backspace(check-lists.tsx:75); - 该处理器调用模型逻辑并返回
true,Editable视其为已处理并阻止默认行为(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>,又手工向下传给CommentList(collaborative-comments.tsx:540)——同一份数据被反复透传。
3.5 Void 渲染属性
renderVoid收到名为target的属性,但该值字面上就是一个Path(editable-text-blocks.tsx:456);- 导出的属性类型是
target: Path(editable-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 中显式包含
HistoryExtension(lexical/examples/extension-vanilla-tailwind/src/main.ts:83); - 清单与 Tab 行为注册为编辑器命令,而不是散落在应用
onKeyDown分支里(checkList.ts:68、TabIndentationExtension.ts:83)。
ProseMirror
- 历史是一个插件,带事务元数据、分组与协作 rebase 行为,不是核心编辑器默认能力(
history/src/history.ts:258)。
Tiptap
- StarterKit 默认包含 undo/redo,但允许用
undoRedo: false关闭(starter-kit.ts:95、starter-kit.ts:218); - 更底层的扩展显式注册 ProseMirror history 与键盘快捷键(
undo-redo.ts:46); - 键盘行为通过扩展的
addKeyboardShortcuts注册,由扩展管理器转成 keymap 插件(ExtensionManager.ts:110)。
结论:三条可复制经验
- 复制"显式历史 / 意见化预设"的拆分——原始引擎与历史插件/扩展分离;
- 复制"扩展拥有键盘行为"——模型行为归扩展,React 事件钩子只做逃生舱;
- 不要让原始 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的编辑器注入history、undo、redo与批处理 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 生命周期; - 不要隐藏
dirtiness或runtimeScope——它们就是性能契约(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 CustomEditor、as 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属性命名:target→path
决策:把公共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同步生成的 skills | rg -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 typecheckbun --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 会隐藏性能。"只有当它隐藏dirtiness和runtimeScope时才会。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 生命周期保持dirtiness与runtimeScope可见;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 1:
useSlateEditor({ 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 5:
renderVoid改为接收{ element, path }而非{ element, target };RenderVoidProps['path']是唯一公共 path 字段,不保留target别名;useElementSelected(path?)的Path参数按字面命名;rg验证target相关模式零命中。 - Phase 6:删除
type Node as SlateNode、match: (n: SlateNode)、as any、as CustomEditor等禁止模式;huge-document的 content-visibility 强转换成窄解析器;类型推断规则写入ralph.mdc并用pnpm install同步生成技能;新增slate-reactchangeset 记录公开注解/void API 清理。最终验收rg在site/examples/ts零命中。
每个阶段都附带了bun test、bunx tsc --project ... --noEmit、bun --filter slate typecheck、bun --filter slate-react typecheck、bun --filter slate-history typecheck、bun typecheck:site、bun lint:fix与dev-browser冒烟证据。
十一、仓库源码佐证:历史插件的独立性与类型契约
计划讨论的"显式历史 + 泛型保真"在当前仓库中可以直接找到实现证据:
- with-history.ts 是独立的泛型插件:为
T extends Editor注入history(redos/undos栈)、redo/undo以及tf.withoutSaving、tf.withoutNormalizing、tf.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),仅供参考