Slate 设计分析(理论层)

93 阅读7分钟
  1. Slate 简介

Slate 是一个典型的 MVC 结构的富文本编辑器,也就是说视图的改变遵循先修改数据模型(model),数据模型变化引起视图层(view)改变。

其数据模型的设计采用的是递归的嵌套树,跟 DOM 本身一样。这种设计更符合前端开发者的直觉,开发更简单。

Nested document model. The document model used for Slate is a nested, recursive tree, just like the DOM itself. This means that creating complex components like tables or nested block quotes are possible for advanced use cases. But it's also easy to keep it simple by only using a single level of hierarchy.

View 层使用的 React,并没有自己独立开发一套。

Re-inventing the view layer seemed inefficient and limiting. Most editors rolled their own views, instead of using existing technologies like React, so you had to learn a whole new system with new "gotchas".

以字体加粗为例子,我们不妨思考一下几点:

  • Slate 的数据模型长什么样子?
  • Slate 是怎么知道用户想要加粗文本的?
  • Slate 是怎么知道当前用户选中了哪些文本的并且如何去更新选中文本对应的文档模型的?
  • 新的文档模型,最终又如何渲染回视图,成为了用户所见?
  • Slate 数据模型如何保证规范化

接下来我们会一个一个解决上面问题去了解 slate 的设计和实现。

  1. Slate 数据模型

  1. 基础类型

slate 以树形结构来表示和存储文档内容,树的节点类型为 Node,分为以下三种子类型:

export type Node = Editor | Element | Text

export interface Element {
  children: Node[]
  [key: string]: any
}

export interface Text {
  text: string
  [key: string]: any
}

我们对比一下 document 就很好理解 slate 数据模型了。

  • Element 类型含有 children 属性,可以作为其他 Node 的父节点
  • Editor 可以看作是一种特殊的 Element ,它既是编辑器实例类型,也是文档树的根节点
  • Text 类型是树的叶子结点,包含文字信息

slate数据模型

html结构

当我们要给某一个节点加粗,我们只需要往其数据结构增加一个字段,渲染的时候再做对应处理即可。

Text {
  text: 11;
  bold: true;
}
  1. Path 设计

浏览器的节点存在父节点以及兄弟节点的这种关联关系。同理 slate 也需要类似的设计,这个时候我们想到了 reactFiber 的做法,为节点增加 prev,next,parent,children 数据结构关联起来。但是 slate 采用另外一种方法,通过 Path 数据结构表示节点在文档中的位置。

如下图,Editor是最顶层其路径为 [],第一个子节点[0], 第二个子节点为[1][0] 路径下的第一个子节点就是[0,0],依此类推。

通过这种数据结构构建出关联关系,只要我们知道某一个节点的路径,就可以找到其兄弟节点,父节点,父父节点等的路径。对于 [0,0] 它的右边节点的路径就是[0,1],父节点就是[0], 父父节点就是[]

  1. nodeToPath

视图层在渲染的时候内部维护了两个 WeakMap, 分别是 NODE_TO_PARENT 表示当前节点的父节点,NODE_TO_INDEX:表示当前节点是其父节点第几个子节点。这样就可以通过 Slate 节点获取对应的 Path。

比如 Text 节点的寻址算法是:

  1. Text 节点的父节点是 Element1,Text 是 Element1 第0个子节点[0]
  2. Element1 的父节点是 Element2,Element1 是 Element2 第1个子节点,[1,0]
  3. Element2 的父节点是 Editor,Element2 是 Editor 的第0个子节点,[0,1,0]
  1. pathToNode

那么我们如何通过 Path 找到对应的 Slate节点呢?举个例子:对于[0,0] 来说只需要拿到文档数据模型的第一个子节点的第一个子节点即可。

  1. Slate 是怎么知道用户想要加粗文本的?

目前实现监听用户输入有两种思路:

  • 一种是通过监听一系列内容输入事件得到对应的数据操作,最后把它转化为针对数据模型的一系列操作。

  • 另外一种是使用MutationObserver监控内容变化,反推出转化为针对数据模型的一系列操作。

Slate 使用的是监听输入事件,只有在 Android浏览器才会用到监听内容变化,从注释中可知道是因为输入事件在 Android 兼容性不好。

COMPAT: BeforeInput events aren't cancelable on android, so we have to handle them differently using the android input manager.

但是监听输入事件也不是一件简单的事情,各个浏览器对于输入事件的实现不完全统一,加上又要区分普通英文输入和中文组合输入,所以需要针对不同浏览器做很多兼容性处理(这该死的兼容性):

  1. 理想情况下:使用 beforeinput 事件完成基础输入代理,因为 beforeinput 语义化清晰,可以作为输入行为判断标准。
  2. 非理想情况:浏览器不支持 beforeinput 事件,则是通过监听 ReactKeyDown 事件处理。
  3. IME输入处理使用事件 ReactCompositionstart 和 ReactCompositionend 处理,这两个事件没有兼容性问题。
  4. 对于 undo/redo/焦点移动/快捷键操作 等也是在 ReactKeyDown 中处理。
  5. 对于 copy/cut/parse/drop 这种无法通过 beforeInput 监听到,利用的是 React 事件处理。

  1. Slate 是怎么知道当前用户选中了哪些文本?

和浏览器的选区一样,Slate的数据模型也需要选区,当数据变更发生时标识数据修改的位置,并且这个位置需要跟浏览器原生的选区保持一致,无论是浏览器的选区变化了,还是Slate的选区变化了都需要实现互相同步。

  1. 选区数据结构

浏览器选区的数据结构如下:

interface Selection {
    readonly anchorNode: Node | null; // 选区开始节点
    readonly anchorOffset: number;   // 选区开始节点的偏移量
    readonly focusNode: Node | null; // 选区结束节点
    readonly focusOffset: number;  // 选区结束节点的偏移量
    // ....
}

在 slate 中,slate 的选区采用的是 Pathoffset 的设计:

  • offset 则是对于 Text 类型的节点而言,代表光标在文本串中的 index 位置。
  • Path 加上 offet 即构成了 Point 类型,即可表示 model 中的一个位置。
export interface Point { path: Path, offset: number }

两个 Point 类型即可组合为一个 Range,表示选区。

export interface Range {
  anchor: Point // 选区开始的位置   focus: Point // 选区结束的位置 }
  1. 选区更新机制

在 slate 中 slate 选区和浏览器选区是一个双向同步机制:DOM Selection <-> Slate Selection:

  1. 监控原生 Document 对象的 selectionchange 事件,
  2. 将 DOMSelection 转换为对应的 slateSelection,
  3. 修改 slateSelection 数值,
  4. View 层重新渲染
  5. View 层根据 slateSelection 反推出 DOMSelection
  6. 利用 selection API 设置新的 selection
  1. Slate 如何更新选中文本对应的文档模型的?

在上一节已经我们已经能够通过 DOMSelection 拿到对应的 SlateSelection 了。SlateSelection 中就可以获取到开始的 slate节点和结束的 slate 节点。再调用 Transform 更改文本方法修改 数据模型。

  1. 新的文档模型,最终又如何渲染回视图,成为了用户所见?

Slate 实例化是一个洋葱模型,洋葱模型代表 slateEditor 很多方法,ReactEditor 都可以重写。调用顺序是从 SlateEditor -> ReactEditor。

ReactEditor 内部维护了一个 version state 变量,更新 Model 之后会触发 editor.onChange, 随后通过洋葱模型 ReactEditor.onChange 会被触发,通过更新 state 实现 re-render。

  1. Slate 数据模型如何保证规范化

HTML Element 可以通过 normalize 方法实现节点的标准化,例如合并多个文本节点:

const element = document.createElement('div');
element.appendChild(document.createTextNode('1'));
element.appendChild(document.createTextNode('2'));
element.appendChild(document.createTextNode('3'));

element.childNodes;   // NodeList [text, text, text]  element.textContent;  // 1 2 3 
 element.normalize();element.childNodes;   // NodeList [text]  element.textContent;  // 1 2 3

Normalize,顾名思义就是对 HTML 进行「标准化」操作。Slate 本身也存在一套标准化规范,比如

  • 所有 element 都至少保证一个 text 子节点
  • 合并空的或匹配的相邻文本节点。
  • 删除空白的文本节点
  • ...

Slate 每次修改数据模型都会产生 dirtyPath。所谓的 dirtyPath 表示被当前操作受影响的路径。以 insertText 为例子:下面红色框表示 dirtyPath。会对所有 dirtyPath 进行 normalize 操作。

// 在 [0,1,0] 这个节点的第一个偏移量中插入 2
{
  type: 'insert_text'
  path: [0,1,0],
  offset: 1,
  text: '2'
}

  1. 写在最后 & 参考资料

原文链接:Slate 设计分析(理论层)

欢迎 star:github.com/JokerLHF/mi…