Plate 插件开发类型化指南:从 createSlatePlugin 到显式 PluginConfig 契约
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
Plate(项目根目录)是以 Slate 为核心的富文本编辑器框架,其插件体系经历了从"随手写一个createPlugin"到"Slate-first 语义基座 + Plate/React 包装层"的演进。本指南以仓库内 plate-plugin-creator 技能的 typing.md 规则文件为主体,系统讲解编写 Plate 插件时应当遵循的类型化原则:何时依赖 TypeScript 推断、何时升级到显式契约、如何通过共享KEYS保持跨插件引用一致、如何选择 API 扩展通道,以及发生类型分歧时以谁为权威。读完本文,你将能写出类型自洽、契约清晰、经得起重构的 Plate 插件。
一、Inference First:先靠推断,再谈显式
Plate 插件作者的第一条铁律是优先使用类型推断,不要一上来就堆泛型。默认使用的两个工厂函数是:
createSlatePlugin:创建纯 Slate 语义基座插件;createPlatePlugin:创建 Plate/React 层插件(或现有 Plate 插件的组合包)。
只有当确实需要显式控制导出的PluginConfig(options、api、transforms、selectors)时,才"升级"到:
createTSlatePlugincreateTPlatePlugin
这里的判断依据是"显式契约是否真的买得到东西"。若只是给一个内部选项加类型,显式泛型属于多余仪式。
从源码看,createSlatePlugin.ts 内部将配置类型定义为SlatePluginConfig<K, O, A, T, S>,其中K为插件 key 的字面量类型、O为 options、A为 api(工具方法)、T为 transforms、S为 storage;而TSlatePluginConfig<C extends AnyPluginConfig>则直接以整个PluginConfig作为唯一泛型参数。这解释了为何createTSlatePlugin适合"整个配置形状需要对外稳定"的场景——它把契约收敛到一个命名别名上,而非五个零散泛型。
仓库的类型测试 slate-plugin-contracts.ts 是这套用法的活教材:BoldPlugin直接用createSlatePlugin并靠推断拿到enabled: true与hotkey: 'mod+b'的字面量类型;而需要稳定导出配置的CalloutPlugin则先定义type CalloutConfig = PluginConfig<'callout', {...}, {...}>,再交给createTSlatePlugin<CalloutConfig>。
二、Use Context, Not Threaded Editors:用上下文,不要手穿编辑器
插件回调已经自带丰富的上下文对象。优先从回调参数里取:
editor:当前编辑器实例plugin:当前插件实例(含解析后的配置)type:插件节点的实际类型字符串api:编辑器合并后的 api 表面tf:编辑器 transformsgetOptions/getOption:读取插件配置setOption/setOptions:写入插件配置
因此不要教用户把SlateEditor通过回调签名、helper 入参或公开 option 回调手工穿透传递——当上下文已携带编辑器时,手穿只会制造噪音与漂移。
上下文对象并非魔法,其构造逻辑就写在 getEditorPlugin.ts 中:它调用editor.getPlugin(p)解析插件,然后组装出{ api, editor, plugin, setOption, setOptions, tf, type, getOption, getOptions }。注意type来自plugin.node.type,tf直接指向editor.transforms——这印证了"编辑器相关能力已全面收敛进上下文",无需在回调签名里再声明一个editor参数。
下面是被明确反对的写法:
extendTransforms(({ editor }: { editor: SlateEditor }) => ...) targetPluginToInject: ({ editor }: { editor: SlateEditor }) => ...当推断失败时,正确的修复方向是修正插件配置形状(用createT*或导出真实PluginConfig别名),而不是到处喷洒手工editor标注。
三、Keys Are Shared Contracts:key 是跨插件的共享契约
凡是会对外发布、会被其他插件引用的插件,其key一律优先取自 plate-keys.ts 导出的KEYS常量:
key: KEYS.blockSelection targetPlugins: [KEYS.p] editor.getType(KEYS.codeBlock)KEYS由NODES(所有节点类型,如codeBlock: 'code_block'、comment: 'comment'、link: 'a')、STYLE_KEYS(内联样式 key)以及autoformat等行为类 key 合并而成,并以as const锁定字面量类型。
在真实包/插件代码中以KEYS为默认,理由有三:
- 跨插件引用保持一致:
KEYS.comment在注释插件、提及插件、测试夹具中引用同一契约,不会因字符串手误而漂移; - 重命名只有一个所有者:未来若某节点类型改名,只需改动
plate-keys.ts一处; - 测试与包装层不再依赖字符串字面量:测试和 wrapper 引用共享常量,重构时类型检查会立刻暴露不一致。
原始字符串字面量仅在两种情况下被允许:插件极小且真正局部,或者测试夹具有意不建模共享契约。
四、Avoid Bad Annotations:避免噪音式标注
形如下面的{ editor }: { editor: SlateEditor }标注通常只是噪音而非帮助:
extendTransforms(({ editor }: { editor: SlateEditor }) => ...) targetPluginToInject: ({ editor }: { editor: SlateEditor }) => ...这类标注的潜台词是"推断失败了",但正确解法不是手工补类型,而是修复配置形状。处理顺序应该是:
- 检查是否误把编辑器相关逻辑放进了不该放的通道(见下文 API 通道选择);
- 需要显式契约时改用
createTSlatePlugin/createTPlatePlugin; - 或导出真实的
PluginConfig类型别名,让推断自然收敛; - 最后才考虑显式标注,且只标注真正必要的部分。
另外注意:源码中禁止any。唯一例外是非类型测试代码中"刻意且局部"的宽松,这是仓库规则对any的硬约束。
五、Choose The Right API Lane:选对 API 扩展通道
插件对外暴露能力有两条语义不同的通道,不能因为某个写法更短就混用:
| 通道 | 适用场景 |
|---|---|
extendApi/extendTransforms | 该能力语义上属于当前插件自身的表面 |
extendEditorApi/extendEditorTransforms | 你有意提供合并进编辑器的便捷表面 |
两者区别是真实存在的:extendApi扩展的是插件自身的 api 表面,调用方需要通过该插件解析上下文才能访问;extendEditorApi则将方法合并到编辑器顶层api/transforms,是面向消费者的编辑器级便捷入口。
类型测试 slate-plugin-contracts.ts 展示了两者的正确混用:BoldPlugin.extendEditorApi(...)把toggleBold挂到编辑器 api(slateEditor.api.toggleBold()),而CalloutPlugin.extendEditorApi(...)的setVariant走的是plugin.options.variant写入——前者是编辑器级便利,后者仍以插件语义为主,只是借编辑器入口暴露。
与之配套的还有组合层规则(详见 composition.md):调整嵌套子插件用configurePlugin而非手抄配置;真正属于编辑器行为的改动用overrideEditor;纯 React 层给已渲染节点补 props 时优先inject.nodeProps.transformProps,且不要把它当成node.component、render或useHooks的万能替代。
六、When Explicit Types Are Worth It:显式类型何时才值得
当插件导出的是一份"调用方应当理解、TypeScript 应当保留"的有意义契约时,使用显式插件配置别名是值得的。仓库中好的范例:
BaseCommentConfigCodeBlockConfigCopilotPluginConfig
例如 BaseCommentPlugin.ts 先声明BaseCommentConfig类型,再createTSlatePlugin<BaseCommentConfig>({ key: KEYS.comment, ... }),让 options、api、transforms 的形状对调用方和类型系统同时可见。
此外,当字面量选项本身是关键语义时,用as const锁住:
options: { trigger: '@' as const, }这样trigger的类型是字面量'@'而非string,下游分支与联合类型才能精确匹配。
七、Source Of Truth Hierarchy:类型真相的优先级
当仓库内文件互相冲突时,按以下顺序信任:
packages/core/src/lib/plugin/*—— Slate-first 插件作者原语(createSlatePlugin、getEditorPlugin等);packages/core/src/react/plugin/*—— Plate 包装原语(createPlatePlugin、toPlatePlugin等);packages/core/type-tests/*—— 插件契约的类型测试权威;- 当前仍与 1–3 一致的插件包;
- 旧包先例(最不可信)。
这条优先级链意味着:当你发现某个历史插件文件与 core 的 API 或类型测试相悖时,以 core 与 type-tests 为准,而不是被"最响亮的旧文件"带偏。这也是技能文档中"Core contracts beat precedent"的落地形式。
八、完整实战示例:把规则串起来
结合 creation-flow.md 的决策树与本文的 typing 规则,一个"语义基座 + Plate 包装"的插件大致长这样:
// 1. 语义基座:行为不依赖 React,默认 createSlatePlugin / createTSlatePlugin export const BaseCommentPlugin = createTSlatePlugin<BaseCommentConfig>({ key: KEYS.comment, // 共享 key 契约 node: { isVoid: true }, // api/transforms 属于插件自身语义时用 extendApi / extendTransforms }).extendApi(({ editor }) => ({ // 直接使用上下文,不手穿 SlateEditor findComments: () => /* 基于 editor 的查询 */, })); // 2. React/Plate 层:用 toPlatePlugin 包装,不重写语义 export const CommentPlugin = toPlatePlugin(BaseCommentPlugin);对应的测试与类型检查策略(见 SKILL.md 的 Workflow):
- 语义/插件行为改动 → 包内单元测试(如 BaseCommentPlugin.spec.ts);
- 公共契约改动 → 跑类型测试或定向 typecheck(
packages/core/type-tests/*); - 仅包装层改动 → React 测试。
更完整的正反案例可参考 plugin-authoring-audit.md。
九、总结:类型化的三条心智锚点
- 推断优先,契约其次:默认
createSlatePlugin/createPlatePlugin,只有需要稳定导出的配置/API/transforms/selectors 时才升级createTSlatePlugin/createTPlatePlugin; - 上下文取代穿针引线:
editor、plugin、type、api、tf、getOptions、setOption已在回调上下文中,别让SlateEditor在签名里重复出现; - 共享契约优于字面量:对外插件 key 用
KEYS,类型分歧时以packages/core/src/lib/plugin/*、packages/core/src/react/plugin/*、packages/core/type-tests/*为权威,旧包先例只作参考。
遵循这套规则,插件的公共类型表面会变得自解释、可复制、可演进——这正是 Plate 插件作者技能想要的核心产出。
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考