深入指南:内置约束、自定义 normalizeNode 与多轮修复原理)
Slate 规范化机制Normalizing深入指南内置约束、自定义 normalizeNode 与多轮修复原理【免费下载链接】slateA completely customizable framework for building rich text editors. (Currently in beta.)项目地址: https://gitcode.com/gh_mirrors/sl/slateSlate 允许用户编辑复杂、嵌套的富文本数据结构但粘贴任意内容或执行复杂操作时往往会产生结构不一致的数据。本文聚焦于 Slate 的 Normalizing规范化机制——它不是简单的校验而是主动把内容修复回合法状态的约束系统。读完本文你将掌握 Slate 内置的七条数据结构约束、如何通过扩展normalizeNode添加领域专属约束、多轮multi-pass修复的工作方式以及如何用Editor.withoutNormalizing规避批量变换时被意外打断的问题。什么是 NormalizingSlate 编辑器可以编辑复杂的、嵌套的数据结构这在大多数情况下都是好事。但某些情况下数据结构会引入不一致——最常见的是当用户粘贴任意富文本内容时。规范化Normalizing就是你用来确保编辑器内容始终符合某种形态的方式。它与校验Validating类似区别在于校验只负责判断内容是否合法而规范化的职责是修复内容使其重新变得合法。也就是说Normalizing 是自愈机制而不是报错机制。在 Slate 中这个机制被深度内置于核心流程每一次对文档的操作operation在应用之后都会触发脏路径dirty paths的收集与归一化流程。见 apply.ts操作应用后updateDirtyPaths收集受影响的路径随后调用Editor.normalize(editor, { operation: op })后者遍历脏路径逐一执行normalizeNode。内置约束开箱即用的七条规则Slate 编辑器开箱即用地带有一批内置约束。这些约束存在的目的是让内容处理比标准的contenteditable可预测得多。Slate 中所有内置逻辑都依赖这些约束因此你无法省略它们。它们分别是所有Element节点必须至少包含一个Text后代——即使是 Void Elements 也不例外。如果某个元素节点没有任何子节点会自动添加一个空文本节点作为其唯一子节点。这条约束确保选区selection的 anchor 和 focus 点它们依赖引用文本节点总能被放置到任意节点内部。否则空元素或 void 元素将无法被选中。两个相邻且自定义属性相同的文本节点会被合并。如果两个相邻文本节点拥有相同的格式它们会被合并成一个文本节点文本内容为两者拼接。这条约束防止文档中文本节点数量只增不减——因为添加和移除格式都会导致文本节点被拆分。块级Block节点只能包含其他块节点或者内联节点 文本节点。例如一个paragraph块不能同时包含另一个paragraph块元素和link内联元素作为子节点。允许的子节点类型由第一个子节点决定其余不符合的子节点会被尝试转换如可能或移除。这保证了将一个块一分为二之类的常见富文本行为保持一致。块节点的转换通过 unwrap 该块节点完成内联/文本节点的转换则是将其包装进fallbackElement如果在normalizeNode的 options 中指定了的话。fallbackElement可以由编辑器通过覆写normalizeNode函数来指定。内联节点不能是父块的第一个或最后一个子节点也不能与另一个内联节点在 children 数组中相邻。如果出现这种情况会添加一个空文本节点以符合约束。顶层编辑器节点只能包含块节点。如果任一顶层子节点是内联或文本节点它会被移除。这确保编辑器中始终存在块节点使将块一分为二等行为按预期工作。节点必须是 JSON 可序列化的。例如避免在数据模型中使用undefined。这保证 operations 也是 JSON 可序列化的——协作类库假定了这一性质。属性值不能是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 })则mergeNodesnormalize-node.ts。块/内联内容规则约束 3、4根据shouldHaveInlines判断当前元素应包含内联内容还是块内容。内联场景下内联节点前后若没有文本节点则插入空文本约束 4块场景下出现文本或内联子节点时若有fallbackElement则wrapNodes包装之否则直接removeNodesnormalize-node.ts。顶层只含块约束 5normalize的主循环把编辑器节点本身也纳入归一化范围见下文。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于是块被移除、其子节点取而代之。节点就被修复了。注意这段代码的两个要点先处理自定义规则再回退只有不匹配自定义规则时才调用原始normalizeNode从而保证内置约束仍然生效。这是一个通用模式——你可以在自定义判断之前或之后调用原始函数取决于你的规则与内置规则的关系。Element.isElement与editor.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 cword/paragraph /paragraph /paragraph /editor编辑器首先对paragraph c运行normalizeNode。它是合法的因为它的子节点只有文本节点。然后向上移动树对paragraph b运行normalizeNode。这个段落是非法的因为它包含块元素paragraph c。于是这个子块被 unwrap得到新文档editor paragraph a paragraph bword/paragraph /paragraph /editor在执行这个修复时顶层paragraph a的内容发生了变化。它被归一化发现是非法的于是paragraph b被 unwrap得到editor paragraph aword/paragraph /editor现在当normalizeNode再次运行时没有产生任何修改文档就合法了 大多数情况下你不需要考虑这些内部细节。你只需知道任何时候normalizeNode被调用且你发现一个非法状态修复这一个非法状态即可并相信normalizeNode会反复被调用直到节点变得合法。多轮机制的底层支撑dirty paths 与迭代上限这个修复一次、自动再跑一轮的机制在源码层面由脏路径dirty paths队列驱动。整个流程是操作应用时标记脏路径getDirtyPaths根据操作类型insert_node、remove_node、move_node、split_node、merge_node、set_node、insert_text、remove_text等计算受影响的路径集合包括操作路径的所有祖先层级以及新插入节点的全部后代路径get-dirty-paths.ts。去重与路径变换updateDirtyPaths将新脏路径合并进现有队列并用Set去重如果操作会改变路径如move_node还会把已存在的脏路径一并变换到新位置update-dirty-paths.ts。主循环反复弹出脏路径Editor.normalize在Editor.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 之间被规范化的话。这在先unwrapNodes再wrapNodes时经常出现。例如你可能写一个改变块类型的函数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记录当前Editor.isNormalizing(editor)的值Editor.setNormalizing(editor, false)关闭规范化在try/finally中执行回调无论回调是否抛错都恢复原来的规范化开关回调结束后主动调用一次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相邻文本合并的直接验证。此外void与block等目录下的用例如 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),仅供参考