阅读视图

发现新文章,点击刷新页面。

非常简单地学习一下slate.js的原理

前言

目前负责编辑器领域的工作,其中接触到slate.js这个库。在学习这个库的过程中顺带写了这篇文章总结了下自己的思路和心德

SlateNode与DOM

Vue、React框架中用VDOM(虚拟DOM)来描述DOM。而在Slate.js中,是用SlateNode来描述DOM。在slate.js中,有两种基本类型的SlateNode:

  1. Element:

    定位:容器节点,负责多类内容的结构布局。如段落、标题、列表、表格、块级引用

    ts类型:{type: string, children: SlateNode[], [key: string]: any]}

  2. Text:

    定位:最小文本格式节点,负责文本样式。如字体加粗、斜体、下划线、颜色、字号

    ts类型:{text: string, [key: string]: any]}

在没有用slate.js提供的renderElementrenderLeaf进行扩展时,Element会默认用{type: "paragraph", children: SlateNode[]}作为兜底格式。而Text会默认用{text: string}作为兜底格式。

例如下👇图中的两行字的富文本内容

两行内容.jpg

在没有扩展的情况下,对应的SlateNode结构如下:

[
  {
    type: 'paragraph',
    children: [
      { text: '123' },
    ],
  },
  {
    type: 'paragraph',
    children: [
      { text: '456' },
    ],
  },
]

而当我们使用renderElementrenderLeaf进行扩展时,我们可以自定义不同内容类型的ElementText对应的DOM。例如:

const App = () => {
  const [editor] = useState(() => withReact(createEditor()))

  // 通过renderElement来定义不同Element对应的HTML表现形式
  const renderElement = useCallback(props => {
    switch (props.element.type) {
      // 例如,我们新增了type为code的Element来表示代码块
      // ,而其对应的HTML表现形式为的pre>code
      case 'code':
        return <pre {...props.attributes}>
            <code>{props.children}</code>
        </pre>
      // 我们也可以更改type为paragraph的Element的HTML表现形式为p。否则默认为div
      default:
        return <p {...props.attributes}>{props.children}</p>
    }
  }, [])

  // 通过renderLeaf来定义不同Text对应的HTML表现形式
  const renderLeaf = useCallback(props => {
    // 例如,我们新增了bold属性来表示是否加粗,当Text的数据为{text: '123', bold: true}时,HTML表现效果等同于<span style={{fontWeight: 'bold'}}>123</span>
    return <span
      {...props.attributes}
      style={{ fontWeight: props.leaf.bold ? 'bold' : 'normal' }}
    >
      {props.children}
    </span>
  }, [])

  return (
    <Slate editor={editor} initialValue={initialValue}>
      <Editable
        // 新增了renderElement和renderLeaf属性
        renderElement={renderElement}
        renderLeaf={renderLeaf}
      />
    </Slate>
  )
}

那接下来的问题就是,当我们在slate.js中的编辑器里编辑内容时,是如何引起SlateNode的改变,进而引起HTML的改变呢?

从用户交互,到SlateNode的改变, 再到HTML的改变

编辑器的交互行为通常有:

  1. 键盘输入
  2. 剪贴/复制/粘贴行为
  3. 撤销/重做
  4. 操作外部按钮去插入Elemeng或更改字体样式

上述交互行为从触发到HTML的变化,都会依次经历以下三个阶段:

  1. Transforms
  2. Normalizing
  3. Rendering

其实,不同交互行为的触发机制和Transforms是不一样的,但NormalizingRendering是相同的。下面我们来说说这三个阶段。

Transforms

注意这里的Transforms是个复数词,即意味着这个阶段是由多个Tranform组合而成的。那么,什么是Transform

Transform是slate.js提供的用于底层原子状态变换的API。在每次用户交互时,slate.js都需要去变更一些变量例如Selection(光标)、SlateNode等等。而这些变量是通过Transform来变更的。

下面我们分场景来对Transforms阶段做的事情进行分析。

字体与目前光标所在文本的字体一致的输入场景

我们以一个字体与目前光标所在文本的字体一致的输入为例,如下👇动图所示:

字体一致输入.gif

在输入4后,触发的整个链路如下👇:

输入文字时经历的transforms.jpg

我们来分析一下Transforms阶段做的最主要的三个操作:

  1. Transforms.delete: 用于删除光标选中范围内的内容。在上图示例中,因为没有选中内容后进行输入,因此不需要删除
  2. Transforms.setSelection: 调整光标位置
  3. Transforms.transform(editor,{offset: 3, path: [0, 0], text: "4", type: "insert_text"}): 插入文字,其中offset表示插入的列坐标,path表示插入的行坐标,text表示插入的内容

值得注意的是,每一个Transform是生成一系列新的SlateNode然后对旧的SlateNode进行替换,而非在旧的SlateNode上直接修改。这也是slate.js自身强调的immutable特性。生成和替换SlateNode执行的代码简化后如下所示:

// 用于生成新的SlateNode的函数
const f = (node)=>{
    // 通过offset获取光标前和光标后的文字
    const before = node.text.slice(0, offset)
    const after = node.text.slice(offset)

    return {
        // 用...的好处是会继承目前光标在文字的字体格式,例如当光标在文字是粗体,那输出的文字也是粗体
        ...node,
        text: before + text + after,
    }
}

// 根据path找到对应的SlateNode
const node = Node.get(editor, path)
const slicedPath = path.slice()
let modifiedNode: Node = f(node)

// 根据path从下到上替换整个SlateNode树中的对应节点
while (slicedPath.length > 1) {
    const index = slicedPath.pop()!
    const ancestorNode = Node.get(editor, slicedPath) as Ancestor

    modifiedNode = {
        ...ancestorNode,
        children: [
            ...ancestorNode.children.slice(0, index), 
            modifiedNode, 
            ...ancestorNode.children.slice(index + 1)
        ]
    }
}

const index = slicedPath.pop()!
// editor.children是整个SlateNode树的根节点
editor.children = [
    ...editor.children.slice(0, index), 
    modifiedNode, 
    ...editor.children.slice(index + 1)
]

相当于把第一个type为"paragraph"的SlateNode给替换了,如下👇图所示,右侧新的SlateNode树中,绿色背景方块代表新生成的SlateNode,而蓝色方块代表复用的SlateNode。

输入文字时SlateNode树的变化.jpg

以上就是这个场景下Transforms阶段所做的事情了。我们下面再以一个粘贴的场景来分析。

粘贴场景

我们以往abef中间粘贴cd为例子,如下👇动图所示:

粘贴.gif

触发的整个链路如下👇:

粘贴文字时经历的transforms.jpg

我们来分析一下粘贴场景下Transforms阶段做的最主要操作:

  1. Transforms.delete: 跟上一个场景一样,用于删除光标选中范围内的内容

  2. Transforms.splitNodes: 调整光标位置,并把光标的文字所处的SlateNode,根据光标的位置分裂成两个,例如:

    // 旧的SlateNode
    [
        {
            type: "paragraph",
            children: [{text: "abef"}]
        },
    ]
    

    光标在abef之间,因此经过分裂会变成以下

    // 新的SlateNode
    [
        {
            type: "paragraph",
            children: [
                {text: "ab"},
                {text: "ef"}
            ]
        },
    ]
    
  3. Transforms.insertNodes: 往光标所在位置插入新SlateNode

    [
        {
            type: "paragraph",
            children: [
                {text: "ab"},
                {text: "cd"}, // 插入的新的SlateNode
                {text: "ef"}
            ]
        },
    ]
    
  4. Transforms.select: 更新光标位置

    Transforms阶段中,整个SlateNode树的变化用如下👇流程图所示:

    粘贴文字时SlateNode树的变化.jpg

    对于上述过程,有两个问题值得我们注意:

    1. 为什么需要分裂SlateNode,然后往中间插入一个新的SlateNode?而不是直接在旧的SlateNode上更改其text属性,如: {text: 'abef'}->{text: 'abcdef'}

      那是因为粘贴的元素多种多样。例如我要往abef中间粘贴的是粗体的cd或者是行内代码块cd(如下代码所示),此时用合并肯定是不行的。因此为了统一应对各种场景,对于粘贴行为都是以分裂SlateNode开始

      {
        type: "paragraph",
        children: [
          {text: 'ab'},
          {text: 'cd', bold: true}, // 插入粗体 or
          {text: 'cd', code: true}, // 插入行内代码块
          {text: 'ef'},
        ]
      }
      
    2. 如果插入的文本和光标所在的文本格式一致,那插入后就有三个Text,如下所示。而这三个Text其实是可以合并成一个的,如果不合并会导致SlateNode树体积增大吧?

      其实后续是会合并的,但不是在Transforms阶段,而是在Normalizing阶段。下面我们就来说一下Normalizing阶段主要的作用是什么

      {
        type: "paragraph",
        children: [
          {text: "ab"},
          {text: "cd"}, // 插入的新的SlateNode
          {text: "ef"}
        ]
      }
      

Normalizing

Normalizing主要对整个SlateNode树做两件事:

  1. 合并: 如果两个相邻的的Text的所有自定义属性完全一致,那这两个Text会被合并

    拿上面粘贴场景的经过Transforms阶段的SlateNode树进行分析,在经过Normalizing阶段后的SlateNode树如下👇所示:

    粘贴normalize合并.jpg

  2. 修复: 对不符合规范的SlateNode进行调整

    Normalizing中针对的规范有多个,想探索的话可看Built-in Constraints

    这里我主要分析一下比较重要的一个规范:

    Inline nodes cannot be the first or last child of a parent block, nor can it be next to another inline node in the children array(在children属性中,作为子元素的布局类型为InlineElement的两侧必须是Text)

    首先要这个规范提及到的“布局类型为InlineElement”作一下说明:

    Element分三种类型:

    1. Block(块级节点): 每一个Block都是占据一行的,就跟HTML中的div元素一样。所有的Element都默认是Block。通常开发者会把代码块、表格设置成Block
    2. Inline(行内节点): 一行里可以有多个Inline,就跟HTML中的span元素一样。开发者可以通过重写editor.isInline函数来定义哪些typeElement属于Inline。通常开发者会把行内代码设置成Inline
    3. Void(空节点): Void不是用布局的类型去理解它,而是用它的子节点是否可编辑去理解。通常开发者会把图片、提及块(即@xx的内容块)设置成Void。同样的,开发者可以通过重写editor.isVoid函数来定义哪些typeElement属于Inline

    而上述的规范,其实是为了保证Selection(选区)逻辑的稳定性。什么是Selection(选区)

    在slate.js中Selection代表你目前的光标所在位置。它的ts类型如下所示:

    interface Editor{
      // ...
      // selection属性代表当前选区,当前编辑器没有聚焦,则为null
      selection: Range | null;
      // ...
    }
    
    // 如果当前光标没有选中内容,anchor和focus一致
    // 如果选中了内容,anchor代表选中内容的前侧,focus代表选中内容的后侧
    interface Range {
      anchor: Point;
      focus: Point;
    }
    
    interface Point {
      path: Path;
      offset: number;
    }
    
    type Path = number[]
    

    我们拿下图👇为例来说明,此时光标在第二行文字的456之间:

    三行文字的光标位置.jpg

    我们用SlateNode树来展示上述案例则是:

    [
      {
          type: "paragraph",
          children: [
              {
                  text: "123"
              }
          ]
      },
      {
          type: "paragraph",
          children: [
              {
                  text: "456"
              }
          ]
      },
      {
          type: "paragraph",
          children: [
              {
                  text: "789"
              }
          ]
      }
    ]
    

    由于上图案例中没有选中内容,因此代表前后位置的属性anchorfocus是一致的。因此我们只需要研究如何用Point类型来代表光标位置即可:

    此时光标在第二个元素的children属性里的第一个元素上,所以用Point["path"]来表示就是[1,0]。且光标是在456之间,用Point["offset"]来表示就是1,即光标在第1个字符后面。因此用Point类型来代表光标位置的位置就是{path: [1,0], offset: 1}。因为目前并没有选中文字,因此用于表示前后选中范围的光标位置都一致,因此editor.selection,即当前选区为:

    {
      anchor: {path: [1,0], offset: 1},
      focus: {path: [1,0], offset: 1},
    }
    

    如果存在选中的内容如下👇:

    三行文字的选中的光标位置.jpg

    editor.selection为:

    {
      anchor: {path: [1,0], offset: 1},
      focus: {path: [1,0], offset: 2},
    }
    

    现在我们回来继续说一下这个规范,以下图👇的例子来说明。图中有一个按钮文字,这个按钮文字是一个Inline

    行内文字的光标位置.jpg

    对应这个例子的SlateNode树如下:

    [
        {
            type: "paragraph",
            children: [
                { "text": "" },
                {
                    type: "button",
                    children: [
                        {
                            "text": "editable button"
                        }
                    ]
                },
                { "text": ""}
            ]
        }
    ]
    

    当把光标放在这个typebuttonInline外部的右侧时(如下👇图所示),选区就会分别定位其右侧的Text,左侧也是同理

    行内文字右侧的光标位置.gif


值得一提的是Normalizing阶段中不会对SlateNode树中的每个Node进行检查。他只会对在交互过程中,被标记的Node进行检测(注意:新生成的SlateNode不一定会被标记)。slate.js内部会有一套巧妙的标记逻辑去过滤出有必要的Node去检查,从而减少Normalizing阶段的耗时。

Rendering

Rendering阶段后,就会触发整个编辑器渲染,编辑器会遍历SlateNode树上每个节点,然后依次调用slatenode与dom章节所提及的renderElementrenderLeaf去生成jsx节点,然后React内部生成成fiber树后再映射到HTMLDOM节点上。从而完成Rendering阶段

后记

本文到此就结束了,如果有问题随时在评论区留言哈

❌