序列化

Slate 的数据模型是以序列化为目标构建的。具体而言,它的文本节点的定义使它们更容易一目了然,但也易于序列化为常见格式,如 HTMLMarkdown

而且,由于 Slate 使用普通的 JSON 来存储数据,您可以非常轻松地编写序列化逻辑。

纯文本

例如,获取编辑器的值并返回纯文本:

import { Node } from 'slate'const serialize = nodes => {return nodes.map(n => Node.string(n)).join('\n')}

这里我们将编辑器的子节点作为 nodes 参数,并返回一个纯文本表示,其中每个顶级节点由一个换行字符分隔。

对于以下输入:

const nodes = [{type: 'paragraph',children: [{ text: 'An opening paragraph...' }],},{type: 'quote',children: [{ text: 'A wise quote.' }],},{type: 'paragraph',children: [{ text: 'A closing paragraph!' }],},]

您将得到:

An opening paragraph...A wise quote.A closing paragraph!

请注意引用块在任何方面都没有区分,这是因为我们正在讨论纯文本。但您可以将数据序列化为任何您想要的格式——它只是 JSON

HTML

例如,这里是一个类似的用于 HTML 的序列化函数:

import escapeHtml from 'escape-html'import { Text } from 'slate'const serialize = node => {if (Text.isText(node)) {let string = escapeHtml(node.text)if (node.bold) {string = `${string}`}return string}const children = node.children.map(n => serialize(n)).join('')switch (node.type) {case 'quote':return `

${children}

`
case 'paragraph':return `

${children}

`
case 'link':return `${escapeHtml(node.url)}">${children}`default:return children}}

这个函数比上面的纯文本序列化器更加灵活。它实际上是递归的,这样它就可以继续通过节点的子节点深入迭代,直到达到叶子文本节点。并且对于它接收到的每个节点,它将其转换为 HTML 字符串。

它还将一个节点作为输入,而不是一个数组,因此如果您传入一个像这样的编辑器:

const editor = {children: [{type: 'paragraph',children: [{ text: 'An opening paragraph with a ' },{type: 'link',url: 'https://example.com',children: [{ text: 'link' }],},{ text: ' in it.' },],},{type: 'quote',children: [{ text: 'A wise quote.' }],},{type: 'paragraph',children: [{ text: 'A closing paragraph!' }],},],// `Editor` objects also have other properties that are omitted here...}

您将会得到(为了可读性增加了换行):

<p>An opening paragraph with a <a href="https://example.com">link</a> in it.</p><blockquote><p>A wise quote.</p></blockquote><p>A closing paragraph!</p>

就是这么简单!

反序列化

Slate 中另一个常见的用例是执行相反的操作——反序列化。这是当您有一些任意输入并希望将其转换为与 Slate 兼容的 JSON 结构时的情况。例如,当有人将 HTML 粘贴到您的编辑器中,您希望确保它以适合您编辑器的正确格式进行解析。

Slate 有一个内置的辅助工具:slate-hyperscript 包。

slate-hyperscript 最常见的用法是编写 JSX 文档,例如在编写测试时。您可以这样使用:

/** @jsx jsx */import { jsx } from 'slate-hyperscript'const input = (<fragment><element type="paragraph">A line of text.</element></fragment>)

并且您的编译器的 JSX 功能(Babel、TypeScript 等)会将该输入变量转换为:

const input = [{type: 'paragraph',children: [{ text: 'A line of text.' }],},]

这对于测试用例或您想要以非常可读的形式编写大量 Slate 对象的地方非常有用。

然而!这对于反序列化并不起作用。

但是 slate-hyperscript 不仅仅适用于 JSX。它只是构建 Slate 内容树的一种方式。当您要反序列化类似 HTML 的东西时,它正是您想要做的事情。

例如,这里是一个用于 HTML 的反序列化函数:

import { jsx } from 'slate-hyperscript'const deserialize = (el, markAttributes = {}) => {if (el.nodeType === Node.TEXT_NODE) {return jsx('text', markAttributes, el.textContent)} else if (el.nodeType !== Node.ELEMENT_NODE) {return null}const nodeAttributes = { ...markAttributes }// define attributes for text nodesswitch (el.nodeName) {case 'STRONG':nodeAttributes.bold = true}const children = Array.from(el.childNodes).map(node => deserialize(node, nodeAttributes)).flat()if (children.length === 0) {children.push(jsx('text', nodeAttributes, ''))}switch (el.nodeName) {case 'BODY':return jsx('fragment', {}, children)case 'BR':return '\n'case 'BLOCKQUOTE':return jsx('element', { type: 'quote' }, children)case 'P':return jsx('element', { type: 'paragraph' }, children)case 'A':return jsx('element',{ type: 'link', url: el.getAttribute('href') },children)default:return children}}

它接受一个 el HTML 元素对象,并返回一个 Slate 片段。因此,如果您有一个 HTML 字符串,您可以像这样解析和反序列化它:

const html = '...'const document = new DOMParser().parseFromString(html, 'text/html')deserialize(document.body)

使用这个输入:

<p>An opening paragraph with a <a href="https://example.com">link</a> in it.</p><blockquote><p>A wise quote.</p></blockquote><p>A closing paragraph!</p>

您将得到这个输出:

const fragment = [{type: 'paragraph',children: [{ text: 'An opening paragraph with a ' },{type: 'link',url: 'https://example.com',children: [{ text: 'link' }],},{ text: ' in it.' },],},{type: 'quote',children: [{type: 'paragraph',children: [{ text: 'A wise quote.' }],},],},{type: 'paragraph',children: [{ text: 'A closing paragraph!' }],},]

就像序列化函数一样,您可以扩展它以满足您的确切领域模型的需求。

规范化

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

“规范化” 是您可以确保编辑器的内容始终具有特定形状的方法。它类似于 “验证”,但与仅确定内容是有效还是无效不同,它的工作是修复内容使其再次有效。

内置约束
Slate 编辑器默认带有一些内置约束。这些约束存在是为了使与内容的工作比标准的 contenteditable 更可预测。Slate 中的所有内置逻辑都依赖于这些约束,因此不幸的是,您不能省略它们。它们是…

  • 所有的 Element 节点必须至少包含一个 Text 后代节点 — 即使是 Void Elements 也是如此。如果一个元素节点不包含任何子节点,将添加一个空的文本节点作为其唯一的子节点。此约束存在是为了确保选择的锚点和焦点位置(依赖于引用文本节点)始终可以放置在任何节点内部。如果没有这样做,空元素(或 Void 元素)将无法选择。

  • 相同自定义属性的两个相邻文本将被合并。如果两个相邻的文本节点具有相同的格式,它们将合并为一个具有两者组合文本字符串的单个文本节点。这样做是为了防止文本节点在文档中只扩展而不收缩,因为添加和删除格式都会导致文本节点的分裂。

  • 块节点只能包含其他块,或者内联和文本节点。例如,段落块不能同时拥有另一个段落块元素和一个链接内联元素作为子元素。允许的子节点类型由第一个子节点确定,任何其他不符合规范的子节点都将被删除。这确保了常见的富文本行为,例如 “将块拆分为两个” 的功能一直保持一致。

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

  • 顶级编辑器节点只能包含块节点。如果任何顶级子节点是内联或文本节点,则它们将被删除。这确保了编辑器中始终存在块节点,以便 “将块拆分为两个” 等功能正常工作。

  • 节点必须是可 JSON 序列化的。例如,避免在数据模型中使用 undefined。这确保了操作也是可 JSON 序列化的,这是协作库假定的一个属性。

  • 属性值不能是 null。相反,您应该使用可选属性,例如 foo?: string 而不是 foo: string | null。此限制是由于 null 在操作中表示属性不存在。

这些默认约束都是必需的,因为它们使与 Slate 文档的工作更加可预测。

尽管这些约束是我们目前想出的最好的,但我们总是在寻找方法,如果可能的话,让 Slate 的内置约束更少约束一些——只要它能使标准行为易于理解。如果您想出了一种用不同方法减少或移除内置约束的方法,我们很乐意听取您的意见!

添加约束

内置约束相当通用。但您还可以添加适用于您领域的

特定约束,这些约束建立在内置约束的基础上。

要做到这一点,您可以扩展编辑器上的 normalizeNode 函数。每次应用插入或更新节点(或其后代)的操作时,都会调用 normalizeNode 函数,从而让您有机会确保更改没有使节点处于无效状态,并在需要时纠正节点。

例如,这里是一个插件,确保段落块只包含文本或内联元素作为子元素:

import { Transforms, Element, Node } from 'slate'const withParagraphs = editor => {const { normalizeNode } = editoreditor.normalizeNode = entry => {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)}return editor}

这个示例相当简单。每当对段落元素调用 normalizeNode 时,它会循环遍历其每个子节点,确保它们都不是块元素。如果有一个块元素,它就会被取消包装,从而删除块并让其子节点代替。节点被 “修复”。

但如果子元素有嵌套块呢?

多次规范化

要理解 normalizeNode 约束,它们是多次进行的。

如果再次查看上面的示例,您会注意到 return 语句:

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

起初,您可能会觉得这很奇怪,因为有了 return,就不会调用原始的 normalizeNodes,内置约束就无法运行自己的规范化了。

但是,规范化有一个微妙的 “技巧”。

当您调用 Transforms.unwrapNodes 时,您实际上正在更改当前正在规范化的节点的内容。因此,即使您结束了当前的规范化过程,通过对节点进行更改,您触发了新的规范化过程。这导致了一种递归规范化。

这种多次通过的特性使得编写规范化更容易,因为您只需要担心一次修复一个问题,而不是修复每个可能将节点置于无效状态的问题。

要了解这在实践中是如何工作的,请从这个无效文档开始:

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

编辑器首先在 上运行 normalizeNode。它是有效的,因为它只包含文本节点作为子节点。

然后,它向上移动到树,运行 上的 normalizeNode。这个段落无效,因为它包含一个块元素()。因此,子块被取消包装,结果是一个新文档:

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

在执行此修复后,顶级的 发生了变化。它被规范化,并且无效,所以 被取消包装,导致:

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

现在当 normalizeNode 运行时,不会进行任何更改,因此文档是有效的!

对于大多数情况,您不需要考虑这些内部细节。您只需知道,每当调用 normalizeNode 并发现无效状态时,您可以修复该单个无效状态,并相信 normalizeNode 将再次被调用,直到节点变得有效。

提前空子节点约束执行

一种特殊的规范化在所有其他规范化之前执行,当您编写您的规范化程序时,这一点很重要。

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

Element 没有子节点时,这可能会让您感到困惑。例如,如果表格元素没有行,您可能希望删除该表格;然而,这将永远不会发生,因为在该规范化运行之前,Text 节点将自动创建。

不正确的修复

要避免的一个陷阱是创建一个无限规范化循环。如果检查了特定的无效结构,但实际上并没有用您对节点所做的更改来修复该结构,那么就会出现无限循环,因为节点仍然被标记为无效,但它从未被正确修复。

例如,考虑一个确保链接元素具有有效 url 属性的规范化:

// 警告:这是一个不正确行为的示例!const withLinks = editor => {const { normalizeNode } = editoreditor.normalizeNode = entry => {const [node, path] = entryif (Element.isElement(node) &&node.type === 'link' &&typeof node.url !== 'string') {// 错误:null 不是 url 的有效值Transforms.setNodes(editor, { url: null }, { at: path })return}normalizeNode(entry)}return editor}

这个修复写得不正确。它希望确保所有链接元素都具有 url 属性字符串。但是为了修复无效链接,它将 url 设置为 null,这仍然不是一个字符串!

在这种情况下,您可能希望取消链接,完全删除它。或者扩展您的验证以接受一个 “空” 的 url == null 也是可以的。

其他代码的影响

如果在 Transforms 之间不希望规范化节点树,请将转换序列包装在 Editor.withoutNormalizing 中。当您取消包装节点后,紧跟着包装节点时,经常会出现这种情况。例如,您可能会编写一个函数来更改块的类型,如下所示:

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)}})}

总结

Slate 是一个用于编辑富文本内容的框架,它支持复杂的、嵌套的数据结构。为了确保编辑器的内容始终保持一致和有效,Slate 提供了规范化(Normalizing)机制。

规范化的目的是确保编辑器内容的形状符合特定的约束条件。Slate 默认提供了一些内置约束,如确保元素节点包含至少一个文本后代、合并相邻的文本节点、块节点只包含特定类型的子节点等。

开发人员可以根据特定的需求添加自定义约束,通过扩展编辑器上的 normalizeNode 函数来实现。此函数在插入或更新节点时被调用,允许开发人员检查和修复节点的状态以符合约束。

规范化是一个多次执行的过程,每次调用 normalizeNode 都会修复一个特定的无效状态。此过程使用一种递归的方式,以确保所有节点最终达到有效状态。

在编写规范化逻辑时,开发人员应注意避免创建无限循环的情况。例如,在修复无效状态时,应确保实际上解决了问题,以防止规范化进入无限循环。

Slate 的规范化机制是确保编辑器内容始终保持一致性和有效性的重要工具,开发人员可以根据需要对其进行定制和扩展。