
Slate 自定义 TypeScript 类型完全指南从 CustomTypes 声明合并到类型守卫实战【免费下载链接】slateA completely customizable framework for building rich text editors. (Currently in beta.)项目地址: https://gitcode.com/gh_mirrors/sl/slate导读Slate 是一个完全可定制的富文本编辑器框架其文档树由Editor、Element、Text三种节点组成而不同业务场景下的文档模型如段落、标题、列表、图片、表格千差万别。本文基于 docs/concepts/12-typescript.md 系统讲解如何在 Slate 中通过CustomTypes接口声明合并定义你自己的文档模型类型涵盖从 0.47.x 迁移的踩坑点、Editor/Element/Text的完整定义范式、编辑器初始值的注解方式以及 Slate 采用联合类型 接口合并这一非常规类型设计的底层原因。读完本文你将掌握一套可复制、可运行的 Slate TypeScript 类型方案并理解如何利用类型守卫type guards在渲染层安全地收窄节点类型。重要提醒在使用 TypeScript 时你必须定义CustomTypes、必须为useState标注类型、必须为编辑器的初始值标注类型否则 Slate 会直接报出类型错误typing errors。一、为什么 Slate 需要自定义类型先理解Node联合类型在 Slate 中一棵文档树由三种节点构成。从 packages/slate/src/interfaces/node.ts 可以看到其核心定义export type BaseNode Editor | Element | Text export type Node Editor | Element | TextNode是Editor、Element、Text的联合类型。这意味着在拿到一个Node时你无法直接访问node.type——因为Text节点上根本没有type属性。这正是为什么需要自定义类型的根本原因只有把你业务里的具体节点形态如ParagraphElement、FormattedText注入 Slate 的类型系统编辑器 API 的返回值、Transforms的参数、RenderElementProps的element属性才会拥有精确的类型提示。不过Slate 官方约定一次只支持一个文档模型一组自定义的Editor、Element、Text类型。如果你需要支持多个文档模型请直接跳到本文多文档模型一节。二、从 0.47.x 迁移两个最常见的类型报错如果你正从 0.47.x 升级除了先阅读迁移指南外还需注意下面两个高频迁移问题1.Property type does not exist on type Node当你直接写node.type时可能看到该错误。原因是Node既可能是Element也可能是Text而Text没有type属性。修复方式是先用Element.isElement(node)收窄类型Element.isElement(node) node.type paragraph在 packages/slate/src/interfaces/element.ts 中Element.isElement是一个运行时类型守卫它检查对象是否满足元素的基本结构。借助它TypeScript 才能在后续访问node.type时把类型收窄到Element。这一模式在官方测试 packages/slate/test/interfaces/CustomTypes/headingElement-true.tsx 中也有直接验证export const input: Element { type: heading, level: 5, children: [], } export const test isHeadingElement export const output true其中isHeadingElement正是基于element.type heading的自定义类型守卫详见下文类型守卫实战。2.Editor的自定义类型必须是BaseEditor ...在定义Editor的 CustomType 时请务必写成Editor: BaseEditor ReactEditor HistoryEditor而不是Editor ReactEditor HistoryEditor。原因在于BaseEditor才是 Slate 定义的未被扩展的编辑器接口见 packages/slate/src/interfaces/editor.ts而导出的Editor本身已经是经过ExtendedTypeEditor, BaseEditor处理的别名同文件第 182 行export type Editor ExtendedTypeEditor, BaseEditor。若在声明合并时直接引用Editor别名会造成类型递归或循环引用问题。三、定义Editor、Element和Text类型CustomTypes 声明合并入门Slate 提供了一种扩展机制向slate模块中的CustomTypes接口做声明合并declaration merging。下面是最小可用的完整示例适用于同时使用了ReactEditor与HistoryEditor的编辑器import { BaseEditor } from slate import { ReactEditor } from slate-react import { HistoryEditor } from slate-history type CustomElement { type: paragraph; children: CustomText[] } type CustomText { text: string; bold?: true } declare module slate { interface CustomTypes { Editor: BaseEditor ReactEditor HistoryEditor Element: CustomElement Text: CustomText } }这段代码做了三件事扩展Editor把ReactEditorReact 绑定所需来自 packages/slate-react/src/plugin/react-editor.ts和HistoryEditor撤销/重做所需来自 packages/slate-history/src/history-editor.ts合并进编辑器类型定义Element声明你的自定义元素本例只有paragraph一种其children必须是CustomText[]定义Text声明叶子文本节点bold?: true表示这是一个可选的布尔标记属性。声明合并之后整个 Slate API 的类型都会随之联动Transforms.setNodes、RenderElementProps、Node.children等所有涉及Element/Text的地方都会使用你的自定义类型。底层原理ExtendedType如何工作声明合并之所以生效是因为 Slate 内部定义了一个取用逻辑。在 packages/slate/src/types/custom-types.ts 中export interface CustomTypes { [key: string]: unknown } export type ExtendedTypeK extends ExtendableTypes, B unknown extends CustomTypes[K] ? B : CustomTypes[K]CustomTypes默认是任意键值的空接口ExtendedTypeEditor, BaseEditor的含义是如果开发者没有合并CustomTypes[Editor]就回退到BaseEditor一旦你在declare module slate中合并了CustomTypes.Editor则全仓库的类型立即切换为你的自定义类型。同文件还定义了可扩展键名的完整清单Editor、Element、Text、Selection、Range、Point、Operation以及各类细分的操作类型InsertNodeOperation、InsertTextOperation、MergeNodeOperation、MoveNodeOperation、RemoveNodeOperation、RemoveTextOperation、SetNodeOperation、SetSelectionOperation、SplitNodeOperation。这也是为什么文档要求必须定义CustomTypes不定义时虽然类型上会回退到BaseEditor/BaseElement等基础类型但你的业务字段如node.type paragraph就无法通过类型检查。四、编辑器中的注解初始值与useState的类型标注仅声明CustomTypes还不够。当你创建 React 编辑器时需要同时注解初始值和useStateimport React, { useState } from react import { createEditor, Descendant } from slate import { Slate, Editable, withReact } from slate-react const initialValue: Descendant[] [ { type: paragraph, children: [{ text: A line of text in a paragraph. }], }, ] const App () { const [editor] useState(() withReact(createEditor())) return ( Slate editor{editor} initialValue{initialValue} Editable / /Slate ) }要点说明initialValue的类型必须是Descendant[]。Descendant是Element | Text的别名定义于 packages/slate/src/interfaces/node.ts它代表文档树中除根Editor之外的任意节点。把初始值显式注解为Descendant[]可以让对象字面量中的type: paragraph与你的CustomElement进行结构性匹配从而获得自动补全与错误检查useState(() withReact(createEditor()))使用函数式初始化确保createEditor()只在首次渲染执行一次避免每次渲染都重建编辑器导致内容丢失。withReact返回的编辑器类型ReactEditor需要与你在CustomTypes.Editor中声明的类型一致如果你同时启用了slate-history在 React 侧还需要用withHistory(editor)包裹此时CustomTypes.Editor中的HistoryEditor就派上了用场。五、最佳实践将每个类型独立定义并导出虽然可以直接在CustomTypes接口内联定义类型但官方最佳实践是分别定义并导出每种类型这样你就可以在业务代码中单独引用某个具体类型比如ParagraphElement、HeadingElement而不是只能引用整个联合类型// 适用于同时使用 ReactEditor 与 HistoryEditor 的编辑器 import { BaseEditor } from slate import { ReactEditor } from slate-react import { HistoryEditor } from slate-history export type CustomEditor BaseEditor ReactEditor HistoryEditor export type ParagraphElement { type: paragraph children: CustomText[] } export type HeadingElement { type: heading level: number children: CustomText[] } export type CustomElement ParagraphElement | HeadingElement export type FormattedText { text: string; bold?: true } export type CustomText FormattedText declare module slate { interface CustomTypes { Editor: CustomEditor Element: CustomElement Text: CustomText } }几点补充说明上例中CustomText恰好等于FormattedText但在真实编辑器中文本类型往往更多样——例如代码块内的文本可能不允许加粗等格式此时可以定义CodeText之类的独立文本类型再把它们联合成CustomTextHeadingElement携带了level: number这样的业务属性这就是 Slate 自定义类型的典型形态元素类型通过type字段区分额外的 schema 字段对齐方式、层级、URL、checked 等按需声明独立导出的好处还在于编写渲染函数时可以显式声明(props: RenderElementProps) JSX.Element并配合props.element的类型收窄使用。仓库中 site/examples/ts/custom-types.d.ts 是一个体量更大、更接近真实项目的完整范例它定义了ParagraphElement、六种HeadingElement、BlockQuoteElement、BulletedListElement、CheckListItemElement、MentionElement、TableElement/TableRowElement/TableCellElement、CodeBlockElement/CodeLineElement、ImageElement/VideoElement等 20 余种元素并导出CustomElementType CustomElement[type]、CustomTextKey keyof OmitCustomText, text等工具类型还演示了为Editor追加额外方法字段nodeToDecorations?: MapElement, Range[]的写法。需要设计复杂文档模型时这份文件值得直接参考。六、为什么 Slate 的类型定义如此不寻常这个问题被反复提问官方在文档中专门做了解释。Slate 的类型设计之所以特殊是因为它同时需要满足两个约束1. 类型判别type discrimination靠联合类型实现if (node.type paragraph) { /* 这里 node 被收窄为 ParagraphElement */ }只有使用联合类型如CustomElement ParagraphElement | HeadingElement时TypeScript 才能根据可判别属性discriminant propertytype自动收窄节点类型。如果CustomElement是单一接口而不是联合类型node.type paragraph的分支里就无法得到精确的ParagraphElement。2. 让开发者把自定义类型注入Slate靠接口声明合并实现interface支持声明合并declaration merging因此开发者才能通过declare module slate { interface CustomTypes { ... } }把业务类型注入到 Slate 的类型体系中而不需要修改 Slate 源码或 fork 类型定义。3. 两者结合Slate 把联合类型与接口组合使用开发者侧用联合类型获得判别能力Slate 侧用接口获得可扩展性再通过ExtendedTypeK, B完成默认回退、合并取用的桥接。这正是Editor ExtendedTypeEditor, BaseEditor这一看似绕弯的写法存在的根本原因。注关于该设计最初由社区提出的完整讨论与提案可在 docs/concepts/12-typescript.md 原文档所附的 GitHub issueProposal: Add Custom TypeScript Types to Slate中查看历史脉络本文不再赘述外部链接。七、多文档模型当前限制与变通方案目前 Slate 同一时刻只能支持一套文档模型。例如你无法在一个应用里让两个不同 schema 的富文本编辑器同时获得精确的类型。为什么这样设计因为CustomTypes是全局声明合并同一时刻只能有一组Element/Text定义生效。Slate 的作者认为支持一套 schema 的类型总比完全没有类型好官方计划未来支持多编辑器定义的类型并且已经有一个由 Slate 创建者本人维护的进行中的 PR。变通方案把每个编辑器分别放到独立的 npm 包中在各自包内声明CustomTypes再在应用里分别导入使用。官方说明该方案尚未经过充分测试但理论上是可行的。八、扩展其他类型Selection、Range、Point实验性除了Editor、Element、TextSlate 还支持扩展Selection、Range、Point三种类型。不过官方明确提示这些扩展没有被像前面几个核心类型那样经过充分测试应当视为实验特性如遇问题请到 GitHub Issues 反馈。一个真实且重要的例子是 slate-react 内部就扩展了Range与Text。在 packages/slate-react/src/custom-types.ts 中declare module slate { interface CustomTypes { Editor: ReactEditor Text: BaseText { placeholder?: string onPlaceholderResize?: (node: HTMLElement | null) void [key: string]: unknown } Range: BaseRange { placeholder?: string onPlaceholderResize?: (node: HTMLElement | null) void [key: string]: unknown } } }slate-react 通过给Text和Range挂上placeholder相关字段来实现装饰decorations与占位符功能。可见扩展其他类型并不是空谈——核心仓库自己就在用。此外packages/slate/test/interfaces/CustomTypes/custom-types.ts 还展示了更完整的可扩展键名用法包括Point: BasePoint、Selection: BaseSelection甚至自定义操作类型export type CustomOperation { type: custom_op value: string } export type ExtendedOperation Operation | CustomOperation declare module slate { interface CustomTypes { Editor: BaseEditor Element: CustomElement Text: CustomText Node: CustomElement | CustomText Point: BasePoint Range: BaseRange Selection: BaseSelection Operation: ExtendedOperation } }注意这里还出现了Node键——它对应 packages/slate/src/types/custom-types.ts 中ExtendableTypes之外的用法说明CustomTypes的键名体系是可扩展的但请以官方文档明确支持的三类Editor/Element/Text为主其余按实验特性对待。九、类型守卫实战让渲染层安全收窄节点定义好联合类型后渲染自定义元素、判断文本格式时都需要收窄。官方测试目录 packages/slate/test/interfaces/CustomTypes/ 提供了现成的类型守卫范式。以 type-guards.ts 为例import { Element, Text, Operation } from slate import { CustomText, CustomOperation, HeadingElement } from ./custom-types export const isBoldText (text: Text): text is CustomText !!(text as CustomText).bold export const isCustomText (text: Text): text is CustomText !!(text as CustomText).placeholder export const isCustomOperation ( op: Operation ): Operation is CustomOperation (op as CustomOperation).type custom_op export const isHeadingElement (element: Element): element is HeadingElement element.type heading要点每个守卫都以 Slate 的基础类型Text、Element、Operation作为入参返回值使用x is T谓词形式这样在if分支内 TypeScript 会自动完成收窄对判别属性如type做检查是最直接的方式对格式标记如bold、placeholder则通过可选属性的真值判断对应的测试用例如 boldText-true.tsx、customOperation-true.tsx、headingElement-true.tsx验证了这些守卫在真实 Slate 节点对象上返回true而对应的*-false.tsx用例验证了非目标节点返回false。结合上一节的CustomElement ParagraphElement | HeadingElement在 React 渲染层你就可以这样写const renderElement (props: RenderElementProps) { if (props.element.type heading) { // 此分支内 props.element 已被收窄为 HeadingElement可直接访问 level return h1 style{{ fontSize: props.element.level * 4 }} {...props.attributes}{props.children}/h1 } return p {...props.attributes}{props.children}/p }这种联合类型定义 判别属性收窄的组合正是第六节所述类型判别能力在业务层的最终落地。十、仓库内可参考的 TypeScript 示例资源官方文档在结尾给出的示例指引在本仓库中可以找到两处高质量参考packages/slate-react/src/custom-types.ts官方文档点名推荐的示例展示了ReactEditor如何声明自己的CustomTypesEditor: ReactEditor以及如何为Text/Range合并额外字段site/examples/ts/custom-types.d.ts站点示例工程使用的完整类型文件定义了 20 余种元素类型、多种文本格式标记与CustomEditor并导出了CustomElementType、CustomTextKey等衍生工具类型是设计大型文档模型时的最佳范本packages/slate/test/interfaces/CustomTypes/类型定义与类型守卫的官方测试套件custom-types.ts定义、type-guards.ts守卫、*-true.tsx/*-false.tsx断言三者的配合方式展示了类型系统在真实用例中的验证方法。小结一套完整的 Slate TypeScript 落地清单定义联合类型为每种元素ParagraphElement、HeadingElement……和文本FormattedText分别定义并导出声明合并注入declare module slate { interface CustomTypes { Editor: BaseEditor ReactEditor HistoryEditor; Element: CustomElement; Text: CustomText } }注意Editor键必须基于BaseEditor而非Editor注解运行时初始值标注为Descendant[]useState使用函数式初始化并配合withReact/withHistory收窄与守卫渲染层用element.type判别属性收窄复杂场景编写x is T形式的类型守卫留意限制当前仅支持单一文档模型Selection/Range/Point/Operation的扩展属于实验特性谨慎使用并及时反馈问题。【免费下载链接】slateA completely customizable framework for building rich text editors. (Currently in beta.)项目地址: https://gitcode.com/gh_mirrors/sl/slate创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考