Slate 规范化机制(Normalizing)深入指南:内置约束、自定义 normalizeNode 与多轮修复原理
2026/9/19 2:36:00 网站建设 项目流程

Slate 规范化机制(Normalizing)深入指南:内置约束、自定义 normalizeNode 与多轮修复原理

【免费下载链接】slateA completely customizable framework for building rich text editors. (Currently in beta.)项目地址: https://gitcode.com/gh_mirrors/sl/slate

Slate 允许用户编辑复杂、嵌套的富文本数据结构,但粘贴任意内容或执行复杂操作时往往会产生结构不一致的数据。本文聚焦于 Slate 的 Normalizing(规范化)机制——它不是简单的"校验",而是主动把内容修复回合法状态的约束系统。读完本文,你将掌握 Slate 内置的七条数据结构约束、如何通过扩展normalizeNode添加领域专属约束、多轮(multi-pass)修复的工作方式,以及如何用Editor.withoutNormalizing规避批量变换时被意外打断的问题。

什么是 Normalizing?

Slate 编辑器可以编辑复杂的、嵌套的数据结构,这在大多数情况下都是好事。但某些情况下数据结构会引入不一致——最常见的是当用户粘贴任意富文本内容时。

"规范化"(Normalizing)就是你用来确保编辑器内容始终符合某种形态的方式。它与"校验"(Validating)类似,区别在于:校验只负责判断内容是否合法,而规范化的职责是修复内容使其重新变得合法。也就是说,Normalizing 是"自愈"机制,而不是"报错"机制。

在 Slate 中,这个机制被深度内置于核心流程:每一次对文档的操作(operation)在应用之后,都会触发脏路径(dirty paths)的收集与归一化流程。见 apply.ts:操作应用后,updateDirtyPaths收集受影响的路径,随后调用Editor.normalize(editor, { operation: op }),后者遍历脏路径逐一执行normalizeNode

内置约束:开箱即用的七条规则

Slate 编辑器开箱即用地带有一批内置约束。这些约束存在的目的是让内容处理比标准的contenteditable可预测得多。Slate 中所有内置逻辑都依赖这些约束,因此你无法省略它们。它们分别是:

  1. 所有Element节点必须至少包含一个Text后代——即使是 Void Elements 也不例外。如果某个元素节点没有任何子节点,会自动添加一个空文本节点作为其唯一子节点。这条约束确保选区(selection)的 anchor 和 focus 点(它们依赖引用文本节点)总能被放置到任意节点内部。否则,空元素(或 void 元素)将无法被选中。

  2. 两个相邻且自定义属性相同的文本节点会被合并。如果两个相邻文本节点拥有相同的格式,它们会被合并成一个文本节点,文本内容为两者拼接。这条约束防止文档中文本节点数量只增不减——因为添加和移除格式都会导致文本节点被拆分。

  3. 块级(Block)节点只能包含其他块节点,或者"内联节点 + 文本节点"。例如,一个paragraph块不能同时包含另一个paragraph块元素和link内联元素作为子节点。允许的子节点类型由第一个子节点决定,其余不符合的子节点会被尝试转换(如可能)或移除。这保证了"将一个块一分为二"之类的常见富文本行为保持一致。块节点的转换通过 unwrap 该块节点完成;内联/文本节点的转换则是将其包装进fallbackElement(如果在normalizeNode的 options 中指定了的话)。fallbackElement可以由编辑器通过覆写normalizeNode函数来指定。

  4. 内联节点不能是父块的第一个或最后一个子节点,也不能与另一个内联节点在 children 数组中相邻。如果出现这种情况,会添加一个空文本节点以符合约束。

  5. 顶层编辑器节点只能包含块节点。如果任一顶层子节点是内联或文本节点,它会被移除。这确保编辑器中始终存在块节点,使"将块一分为二"等行为按预期工作。

  6. 节点必须是 JSON 可序列化的。例如,避免在数据模型中使用undefined。这保证 operations 也是 JSON 可序列化的——协作类库假定了这一性质。

  7. 属性值不能是null。你应该使用可选属性,例如foo?: string而不是foo: string | null。这个限制源于null在 operations 中被用来表示"属性不存在"。

这些默认约束之所以被强制要求,是因为它们让 Slate 文档处理更加可预测

🤖 虽然这些约束是我们目前能想到的最佳方案,但我们一直在寻找让 Slate 内置约束更"宽松"的可能性——只要标准行为依然容易推理即可。如果你想到用不同方案减少或移除某条内置约束,欢迎告诉我们!

从源码看内置约束的实现

内置约束并非文档中的"空谈",它们都有对应的核心实现。默认的normalizeNode位于 normalize-node.ts:

  • 空节点补空文本(约束 1):当element.children.length === 0时,通过Transforms.insertNodes(editor, { text: '' }, { at: path.concat(0), voids: true })插入空文本节点(normalize-node.ts)。
  • 相邻文本合并 / 空文本清理(约束 2):遍历子节点时,若相邻两个都是文本节点,则:空文本直接removeNodes;若Text.equals(child, prev, { loose: true })mergeNodes(normalize-node.ts)。
  • 块/内联内容规则(约束 3、4):根据shouldHaveInlines判断当前元素应包含内联内容还是块内容。内联场景下,内联节点前后若没有文本节点则插入空文本(约束 4);块场景下,出现文本或内联子节点时,若有fallbackElementwrapNodes包装之,否则直接removeNodes(normalize-node.ts)。
  • 顶层只含块(约束 5):normalize的主循环把编辑器节点本身也纳入归一化范围(见下文)。

normalizeNode的签名在 editor.ts 中定义:

normalizeNode: ( entry: NodeEntry, options?: { operation?: Operation fallbackElement?: () => Element force?: boolean } ) => void

其中fallbackElement正对应约束 3 中提到的"将不合规内联/文本包装进回退元素"的能力。

添加自定义约束:扩展normalizeNode

内置约束相当通用,但你完全可以在它们之上添加自己领域专属的约束。

做法是扩展编辑器上的normalizeNode函数。每当一个操作被应用且插入或更新了节点(或其子孙节点)时,normalizeNode都会被调用,这给你机会确保变更没有让节点处于非法状态,并在非法时修正它。

例如,下面这个插件确保paragraph块的子节点只能是文本或内联元素:

import { Transforms, Element, Node } from 'slate' const withParagraphs = editor => { const { normalizeNode } = editor editor.normalizeNode = (entry, options) => { const [node, path] = entry // If the element is a paragraph, ensure its children are valid. if (Element.isElement(node) && node.type === 'paragraph') { for (const [child, childPath] of Node.children(editor, path)) { if (Element.isElement(child) && !editor.isInline(child)) { Transforms.unwrapNodes(editor, { at: childPath }) return } } } // Fall back to the original `normalizeNode` to enforce other constraints. normalizeNode(entry, options) } return editor }

这个例子相当简单。每当normalizeNode在段落元素上被调用时,它遍历每个子节点,确保没有块级元素;如果发现块级元素,就将其 unwrap,于是块被移除、其子节点取而代之。节点就被"修复"了。

注意这段代码的两个要点:

  1. 先处理自定义规则,再回退:只有不匹配自定义规则时才调用原始normalizeNode,从而保证内置约束仍然生效。这是一个通用模式——你可以在自定义判断之前或之后调用原始函数,取决于你的规则与内置规则的关系。
  2. Element.isElementeditor.isInline配合使用Element.isElement判断是否为元素节点(区别于文本节点),editor.isInline判断该元素是否被当前编辑器视为内联元素。这两个判断是书写自定义约束的基石。

但要是子节点里还有嵌套的块呢?

多轮规范化(Multi-pass Normalizing)

理解normalizeNode约束时,有一点非常重要:它们是多轮(multi-pass)的。

再回看上面的例子,注意那个return语句:

if (Element.isElement(child) && !editor.isInline(child)) { Transforms.unwrapNodes(editor, { at: childPath }) return }

你可能会觉得这很奇怪:因为有了return,原始normalizeNode永远不会被调用,内置约束也就没有机会执行它们自己的规范化。

但规范化有一个小小的"诀窍":

当你调用Transforms.unwrapNodes时,你实际上改变了正在被规范化的节点的内容。因此,即使你结束了当前这轮规范化,对节点的修改也会触发一轮新的规范化。这形成了一种递归式的规范化。

这种多轮特性让编写规范化逻辑容易得多,因为你每次只需要修复一个问题,而不必一次性修复所有可能导致节点非法的问题。

看一个实际例子。假设我们有这样一个非法文档:

<editor> <paragraph a> <paragraph b> <paragraph c>word</paragraph> </paragraph> </paragraph> </editor>

编辑器首先对<paragraph c>运行normalizeNode。它是合法的,因为它的子节点只有文本节点。

然后向上移动树,对<paragraph b>运行normalizeNode。这个段落是非法的,因为它包含块元素(<paragraph c>)。于是这个子块被 unwrap,得到新文档:

<editor> <paragraph a> <paragraph b>word</paragraph> </paragraph> </editor>

在执行这个修复时,顶层<paragraph a>的内容发生了变化。它被归一化,发现是非法的,于是<paragraph b>被 unwrap,得到:

<editor> <paragraph a>word</paragraph> </editor>

现在当normalizeNode再次运行时,没有产生任何修改,文档就合法了!

🤖 大多数情况下你不需要考虑这些内部细节。你只需知道:任何时候normalizeNode被调用且你发现一个非法状态,修复这一个非法状态即可,并相信normalizeNode会反复被调用,直到节点变得合法。

多轮机制的底层支撑:dirty paths 与迭代上限

这个"修复一次、自动再跑一轮"的机制,在源码层面由"脏路径"(dirty paths)队列驱动。整个流程是:

  1. 操作应用时标记脏路径getDirtyPaths根据操作类型(insert_noderemove_nodemove_nodesplit_nodemerge_nodeset_nodeinsert_textremove_text等)计算受影响的路径集合,包括操作路径的所有祖先层级,以及新插入节点的全部后代路径(get-dirty-paths.ts)。
  2. 去重与路径变换updateDirtyPaths将新脏路径合并进现有队列,并用Set去重;如果操作会改变路径(如move_node),还会把已存在的脏路径一并变换到新位置(update-dirty-paths.ts)。
  3. 主循环反复弹出脏路径Editor.normalizeEditor.withoutNormalizing包裹下循环,只要脏路径队列非空,就弹出路径并对其执行editor.normalizeNode(entry, { operation, force });修复产生的新的操作会再次生成脏路径,从而形成"递归"效果(normalize.ts)。

防死循环保护:如果规范化逻辑写错导致节点始终无法被修复,这个循环会永远跑下去。因此shouldNormalize设定了迭代上限:maxIterations = initialDirtyPathsLength * 42,超过后抛出错误Could not completely normalize the editor after N iterations! This is usually due to incorrect normalization logic that leaves a node in an invalid state.(should-normalize.ts)。这说明:当你看到这个报错时,几乎总是意味着你的自定义规范化逻辑没有真正修复它声称要修复的问题(参见下文"错误的修复")。

空子节点的优先约束执行

有一条特殊的规范化会在所有其他规范化之前执行,编写规范化逻辑时需要格外留意。

在任何其他规范化执行之前,Slate 会遍历所有Element节点,确保它们至少有一个子节点。如果没有,就创建一个空的Text后代。

这在你有"当元素没有子节点时的自定义处理"时会给你造成困扰。例如,如果一个表格元素没有行,你可能想移除这个表格;但这种情况永远不会发生,因为在你自己的规范化运行之前,一个Text节点会自动被创建。

这个"早期空子节点约束"在源码中有明确体现。normalize.ts 在主循环开始前先做一轮"预检":对所有脏路径,若节点是元素且children.length === 0,立即调用editor.normalizeNode(entry, { operation, force })补上空文本。注释解释了原因:默认规范化器在此场景插入空文本节点,但该行为可以被自定义——为了避免"空节点需要先修、修复又依赖空节点"的竞态(catch-22),必须先跑这一轮预检。

错误的修复:避免无限循环

一个需要避免的陷阱是创建无限规范化循环。这发生在你检查了一个特定非法结构,但你对节点做的修改并没有真正修复那个结构时。结果就是无限循环:节点持续被标记为非法,却从未被真正修复。

例如,考虑一个确保link元素拥有合法url属性的规范化:

// WARNING: this is an example of incorrect behavior! const withLinks = editor => { const { normalizeNode } = editor editor.normalizeNode = (entry, options) => { const [node, path] = entry if ( Element.isElement(node) && node.type === 'link' && typeof node.url !== 'string' ) { // ERROR: null is not a valid value for a url Transforms.setNodes(editor, { url: null }, { at: path }) return } normalizeNode(entry, options) } return editor }

这个修复写得不对。它的目标是确保所有link元素都有字符串类型的url属性。但为了修复非法链接,它把url设成了null——而null依然不是字符串!

这样节点在被修复后依旧非法,下一轮规范化会再次触发修复,形成死循环。实际运行中,这种逻辑最终会触发上文提到的maxIterations保护并抛出错误。

正确的做法有两种:要么 unwrap 这个链接,彻底移除它;要么扩大你的校验范围,把"空的url == null"也接受为合法。

对其他代码的影响:Editor.withoutNormalizing

变换(Transforms)的序列可能需要用Editor.withoutNormalizing包裹,如果节点树不应在两次 Transforms 之间被规范化的话。

这在"先unwrapNodeswrapNodes"时经常出现。例如,你可能写一个改变块类型的函数:

const LIST_TYPES = ['numbered-list', 'bulleted-list'] function changeBlockType(editor, type) { Editor.withoutNormalizing(editor, () => { const isActive = isBlockActive(editor, type) const isList = LIST_TYPES.includes(type) Transforms.unwrapNodes(editor, { match: n => LIST_TYPES.includes( !Editor.isEditor(n) && SlateElement.isElement(n) && n.type ), split: true, }) const newProperties = { type: isActive ? 'paragraph' : isList ? 'list-item' : type, } Transforms.setNodes(editor, newProperties) if (!isActive && isList) { const block = { type: type, children: [] } Transforms.wrapNodes(editor, block) } }) }

为什么需要withoutNormalizing?因为"先解包再设置类型再重新包裹"是一个原子性的中间状态序列:如果第一步unwrapNodes之后、setNodes/wrapNodes之前,规范化就被触发,节点可能处于一个符合旧结构的非法中间态,被内置约束(比如约束 3/5)抢先"修复",从而破坏你的后续变换逻辑。

从源码看,withoutNormalizing的实现是(without-normalizing.ts):

  1. 记录当前Editor.isNormalizing(editor)的值;
  2. Editor.setNormalizing(editor, false)关闭规范化;
  3. try/finally中执行回调,无论回调是否抛错都恢复原来的规范化开关;
  4. 回调结束后主动调用一次Editor.normalize(editor)补跑规范化。

这意味着:关闭规范化只是延迟,而不是取消。所有在回调内累积的脏路径会在回调结束后一次性被规范化处理。同时注意Editor.normalize自身也检查if (!Editor.isNormalizing(editor)) return,所以嵌套的withoutNormalizing不会提前触发(normalize.ts)。

测试用例佐证

仓库的测试套件为上述约束提供了直接的可验证证据,例如 normalization/text/merge-adjacent-empty.tsx:

// input <editor> <block> <text /> <text /> </block> </editor> // output <editor> <block> <text /> </block> </editor>

输入中同一个 block 下有两个相邻的文本节点(即使都是空的),经过规范化后合并为一个——这正是内置约束 2(相邻文本合并)的直接验证。此外,voidblock等目录下的用例(如 void/block-insert-text.tsx)分别验证了 void 元素中插入文本时约束 1、4 的行为。当你实现自己的normalizeNode插件时,可以仿照这些测试的结构(input/output成对)来为规范化逻辑建立回归测试。

小结

  • Normalizing 是修复而非校验:Slate 通过规范化机制主动修正非法文档结构,使其始终符合七条内置约束。
  • 自定义约束 = 覆写normalizeNode:先处理自己的规则,再回退调用原始实现;每次只修复一个非法点,依靠多轮机制递归收敛。
  • 多轮机制有底层保障:脏路径队列 +maxIterations * 42的迭代上限,既保证递归收敛,又防止死循环拖垮编辑器。
  • 注意空子节点预检:空元素会优先被补上空文本节点,别指望"检测到空元素后删除它"的逻辑能先于补文本运行。
  • 修复必须真正消除非法状态:否则会触发无限循环报错。
  • 批量变换用withoutNormalizing包裹:需要保持中间状态不被规范化打断时(典型如 unwrap + wrap 组合),推迟到原子操作序列结束后统一规范化。

掌握这些要点后,你就能安全地为 Slate 编辑器编写领域专属的数据结构约束,让"脏"内容在进入文档的瞬间被自动修复成你期望的形态。

【免费下载链接】slateA completely customizable framework for building rich text editors. (Currently in beta.)项目地址: https://gitcode.com/gh_mirrors/sl/slate

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

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

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

立即咨询