Closed wzhudev closed 2 years ago
最终是通过对slate的增强来实现复杂排版问题吗?
最终是通过对slate的增强来实现复杂排版问题吗?
我目前团队的商业项目并没有用到 slate
不过看起来也很难基于 slate 实现复杂排版
请问为什么我在slate项目中yarn install成功之后没有找到node_modules依赖目录,但是却可以正常build成功。依赖安装到哪了呢?
请问为什么我在slate项目中yarn install成功之后没有找到node_modules依赖目录,但是却可以正常build成功。依赖安装到哪了呢?
可能原因有很多哦…… 比如编辑器隐藏了node_modules 目录?
slate 是一款流行的富文本编辑器——不,与其说它是一款编辑器,不如说它是一个编辑器框架,在这个框架上,开发者可以通过插件的形式提供丰富的富文本编辑功能。slate 比较知名的用户(包括前用户)有 GitBook 和语雀,具体可以查看官网的 products 页面。
所谓“工欲善其事,必先利其器”,想要在项目中用好 slate,掌握其原理是一种事半功倍的做法。对于开发编辑器的同学来说,slate 的架构和技术选型也有不少值得学习的地方。这篇文章将会从以下几个方面探讨 slate:
slate 架构简介
slate 作为一个编辑器框架,分层设计非常明显。slate 仓库下包含四个 package:
slate (model)
先来看 slate package,这一部分是 slate 的核心,定义了编辑器的数据模型、操作这些模型的基本操作、以及创建编辑器实例对象的方法。
model 结构
slate 以树形结构来表示和存储文档内容,树的节点类型为
Node
,分为三种子类型:Element
类型含有children
属性,可以作为其他Node
的父节点Editor
可以看作是一种特殊的Element
,它既是编辑器实例类型,也是文档树的根节点Text
类型是树的叶子结点,包含文字信息用户可以自行拓展
Node
的属性,例如通过添加type
字段标识Node
的类型(paragraph, ordered list, heading 等等),或者是文本的属性(italic, bold 等等),来描述富文本中的文字和段落。我们可以通过官方的 richtext demo 来直观地感受一下 slate model 的结构。
在本地运行 slate,通过 React Dev Tool 找到
Slate
标签,参数中的editor
就是编辑器实例,右键选择它,然后点击 store as global variable,就可以在 console 中 inspect 这个对象了。可以看到它的
children
属性中有四个Element
并通过type
属性标明了类型,对应编辑器中的四个段落。第一个 paragraph 的children
中有 7 个Text
,Text
用bold
italic
这些属性描述它们的文字式样,对应普通、粗体、斜体和行内代码样式的文字。那么为什么 slate 要采用树结构来描述文档内容呢?采用树形结构描述 model 有这样一些好处:
Node
和 DOM tree Element 存在映射关系,这样在处理用户操作的时候,能够很快地从 element 映射到Node
用树形结构当然也有一些问题:
光标和选区
有了 model,还需要在 model 中定位的方法,即选区(selection),slate 的选区采用的是
Path
加 offset 的设计。Path
是一个数字类型的数组number[]
,它代表的是一个Node
和它的祖先节点,在各自的上一级祖先节点的children
数组中的 index。offset 则是对于 Text 类型的节点而言,代表光标在文本串中的 index 位置。
Path
加上 offet 即构成了Point
类型,即可表示 model 中的一个位置。两个
Point
类型即可组合为一个Range
,表示选区。比如我这样选中一段文本(我这里是从后向前选择的):
通过访问
editor
的selection
属性来查看当前的选区位置:可见,选择的起始位置
focus
在第一段的最后一个文字处,且由于第一段中 "bold" 被加粗,所以实际上有 3 个Text
的节点,因此anchor
的path
即为[1, 2]
,offset
为光标位置在第三个Text
节点中的偏移量 82。如何对 model 进行变更
有了 model 和对 model 中位置的描述,接下来的问题就是如何对 model 进行变更(mutation)了。编辑器实例提供了一系列方法(由
Editor
interface 所声明),如insertNode
insertText
等,直接供外部模块变更 model,那么 slate 内部是如何实现这些方法的呢?在阅读源代码的过程中,了解到这一点可能会对你有帮助:slate 在最近的一次重构中完全去除了类(class),所有数据结构和工具方法都是由同名的接口和对象来实现的,比如
Editor
:interface
Editor
为编辑器实例所需要实现的接口,而对象Editor
则封装了操作 interfaceEditor
的一些方法。所以,在查看Editor
的实例editor
的方法时,要注意方法实际上定义在 create-editor.ts 文件中。这可能是第一次阅读 slate 代码时最容易感到混淆的地方。通常来说,对 model 进行的变更应当是原子化(atomic)的,这就是说,应当存在一个独立的数据结构去描述对 model 发生的变更,这些描述通常包括变更的类型(type)、路径(path)和内容(payload),例如新增的文字、修改的属性等等。原子化的变更方便做 undo/redo,也方便做协同编辑(当然需要对冲突的变更做转换,其中一种方法就是有名的 operation transform, OT)。
slate 也是这么处理的,它对 model 进行变更的过程主要分为以下两步,第二步又分为四个子步骤:
Transforms
提供的一系列方法生成Operation
Operation
进入 apply 流程Operation
进行 transform首先,通过
Transforms
所提供的一系列方法生成Operation
,这些方法大致分成四种类型:NodeTransforms
:对Node
的操作方法SelectionTransforms
:对选区的操作方法TextTransforms
:对文本操作方法特殊的是
GeneralTransforms
,它并不生成Operation
而是对Operation
进行处理,只有它能直接修改 model,其他 transforms 最终都会转换成GeneralTransforms
中的一种。这些最基本的方法,也即是
Operation
类型仅有 9 个:insert_node
:插入一个 Nodeinsert_text
:插入一段文本merge_node
:将两个 Node 组合成一个move_node
:移动 Noderemove_node
:移除 Noderemove_text
:移除文本set_node
:设置 Node 属性set_selection
:设置选区位置split_node
:拆分 Node我们以
Transforms.insertText
为例(略过一些对光标位置的处理):可见
Transforms
的最后生成了一个type
为insert_text
的Operation
并调用Editor
实例的apply
方法。apply
内容如下:其中
Transforms.transform(editor, op)
就是在调用GeneralTransforms
处理Operation
。transform
方法的主体是一个 case 语句,根据Operatoin
的type
分别应用不同的处理,例如对于insertText
,其逻辑为:可以看到,这里的代码会直接操作 model,即修改
editor.children
和editor.selection
属性。model 校验
对 model 进行变更之后还需要对 model 的合法性进行校验,避免内容出错。校验的机制有两个重点,一是对脏区域的管理,一个是
withoutNormalizing
机制。许多 transform 在执行前都需要先调用
withoutNormalizing
方法判断是否需要进行合法性校验:可以看到这段代码通过栈帧(stack frame)保存了是否需要合法性校验的状态,保证 transform 运行前后是否需要合法性校验的状态是一致的。transform 可能调用别的 transform,不做这样的处理很容易导致冗余的合法性校验。
合法性校验的入口是
normalize
方法,它创建一个循环,从 model 树的叶节点自底向上地不断获取脏路径并调用nomalizeNode
检验路径所对应的节点是否合法。让我们先来看看脏路径是如何生成的(省略了不相关的部分),这一步发生在
Transforms.transform(editor, op)
之前:dirtyPaths
一共有以下两种生成机制:oldDirtypath
,这一部分根据 operation 的类型做路径转换处理getDirthPaths
方法获取normalizeNode
方法会对Node
进行合法性校验,slate 默认有以下校验规则:Elmenet
节点,需要给它插入一个voids
类型节点Element
节点进行校验合法性变更之后,就是调用
onChange
方法。这个方法 slate package 中定义的是一个空函数,实际上是为插件准备的一个“model 已经变更”的回调。到这里,对 slate model 的介绍就告一段落了。
slate 插件机制
在进一步学习其他 package 之前,我们先要学习一下 slate 的插件机制以了解各个 package 和如何与核心 package 合作的。
上一节提到的判断一个节点是否为行内节点的
isInline
方法,以及normalizeNode
方法本身都是可以被扩展,不仅如此,另外三个 package 包括 undo/redo 功能和渲染层均是以插件的形式工作的。看起来 slate 的插件机制非常强大,但它有一个非常简单的实现:覆写编辑器实例 editor 上的方法。slate-react 提供的
withReact
方法给我们做了一个很好的示范:用
withReact
修饰编辑器实例,直接覆盖实例上原本的apply
和change
方法。换句话说,slate 的插件机制就是没有插件机制!这难道就是传说中的无招胜有招?slate-history
学习了插件机制,我们再来看 undo/redo 的功能,它由 slate-history package 所实现。
实现 undo/redo 的机制一般来说有两种。第一种是存储各个时刻(例如发生变更前后)model 的快照(snapshot),在撤销操作的时候恢复到之前的快照,这种机制看起来简单,但是较为消耗内存(有 n 步操作我们就需要存储 n+1 份数据!),而且会使得协同编辑实现起来非常困难(比较两个树之间的差别的时间复杂度是 O(n^3),更不要提还有网络传输的开销)。第二种是记录变更的应用记录,在撤销操作的时候取要撤销操作的反操作,这种机制复杂一些——主要是要进行各种选区计算——但是方便做协同,且不会占用较多的内存空间。slate 即基于第二种方法进行实现。
在
withHistory
方法中,slate-history 在 editor 上创建了两个数组用来存储历史操作:它们的类型都是
Operation[][]
,即Operation
的二维数组,其中的每一项代表了一批操作(在代码上称作 batch), batch 可含有多个Operation
。我们可以通过 console 看到这一结构:
slate-history 通过覆写
apply
方法来在Operation
的 apply 流程之前插入 undo/redo 的相关逻辑,这些逻辑主要包括:Operation
,诸如改变选区位置等操作是不需要 undo 的Operation
是否需要和前一个 batch 合并,或覆盖前一个 batchundos
队列,或者插入到上一个 batch 的尾部,同时计算是否超过最大撤销步数,超过则去除首部的 batchapply
方法slate-history 还在 editor 实例上赋值了
undo
方法,用于撤销上一组操作:这个算法的主要部分就是对最后一个 batch 中所有的
Operation
取反操作然后一一 apply,再将这个 batch push 到redos
数组中。redo 方法就更简单了,这里不再赘述。
slate-react
最后我们来探究渲染和交互层,即 slate-react package。
渲染机制
我们最关注的问题当然是 model 是如何转换成视图层(view)的。经过之前的学习我们已经了解到 slate 的 model 本身就是树形结构,因此只需要递归地去遍历这棵树,同时渲染就可以了。基于 React,这样的递归渲染用几个组件就能够很容易地做到,这几个组件分别是
Editable
Children
Element
Leaf
String
和Text
。在这里举几个例子:Children
组件用来渲染 model 中类行为Editor
和Element
Node
的children
,比如最顶层的Editable
组件就会渲染Editor
的children
:注意下面的
node
参数即为编辑器实例Editor
:Children
组件会根据children
中各个Node
的类型,生成对应的ElementComponent
或者TextComponent
:ElementComponent
渲染一个Element
元素,并用Children
组件渲染其children
:Leaf
等组件的渲染也是同理,这里不再赘述。下图表示了从 model tree 到 React element 的映射,可见用树形结构来组织 model 能够很方便地渲染,且在
Node
和 HTML element 之间建立映射关系(具体可查看toSlateNode
和toSlateRange
等方法和ELEMENT_TO_NODE
NODE_TO_ELEMENT
等数据结构),这在处理光标和选择事件时将会特别方便。slate-react 还用了
React.memo
来优化渲染性能,这里不赘述。自定义渲染元素
在上面探究 slate-react 的渲染机制的过程中,我们发现有两个比较特殊的参数
renderElement
和renderLeaf
,它们从最顶层的Editable
组件开始就作为参数,一直传递到最底层的Leaf
组件,并且还会被Element
等组件在渲染时调用,它们是什么?实际上,这就是 slate-react 自定义渲染的 API,用户可以通过提供这两个参数来自行决定如何渲染 model 中的一个
Node
,例如 richtext demo 中:我们先前提到 slate 允许
Node
有自定义属性,这个 demo 就拓展了Element
节点的type
属性,让Element
能够渲染为不同的标签。光标和选区的处理
slate 没有自行实现光标和选区,而使用了浏览器
contenteditable
的能力(同时也埋下了隐患,我们会在总结部分介绍)。在
Editable
组件中,可看到对Component
元素增加了 contenteditable attribute:从这里开始,contenteditable 就负责了光标和选区的渲染和事件。slate-react 会在每次渲染的时候将 model 中的选区同步到 DOM 上:
也会在 DOM 发生选区事件的时候同步到 model 当中:
选区同步的方法这里就不介绍了,大家可以通过查阅源码自行学习。
键盘事件的处理
Editable
组件创建了一个onDOMBeforeInput
函数,用以处理beforeInput
事件,根据事件的type
调用不同的方法来修改 model。beforeInput
事件和input
事件的区别就是触发的时机不同。前者在值改变之前触发,还能通过调用preventDefault
来阻止浏览器的默认行为。slate 对快捷键的处理也很简单,通过在 div 上绑定 keydown 事件的 handler,然后根据不同的组合键调用不同的方法。slate-react 也提供了自定义这些 handler 的接口,
Editable
默认的 handler 会检测用户提供的 handler 有没有将该 keydown 事件标记为defaultPrevented
,没有才执行默认的事件处理逻辑:渲染触发
slate 在渲染的时候会向
EDITOR_TO_ON_CHANGE
中添加一个回调函数,这个函数会让key
的值加 1,触发 React 重新渲染。而这个回调函数由谁来调用呢?可以看到
withReact
对于onChange
的覆写:在 model 变更的结束阶段,从
EDITOR_TO_ON_CHANGE
里拿到回调并调用,这样就实现 model 更新,触发 React 重渲染了。总结
这篇文章分析了 slate 的架构设计和对一些关键问题的处理,包括:
等等。
至此,我们可以发现 slate 存在着这样几个主要的问题:
没有自行实现排版。slate 借助了 DOM 的排版能力,这样就使得 slate 只能呈现流式布局的文档,不能实现页眉页脚、图文混排等高级排版功能。
使用了 contenteditable 导致无法处理部分选区和输入事件。使用 contenteditable 后虽然不需要开发者去处理光标的渲染和选择事件,但是造成了另外一个问题:破坏了从 model 到 view 的单向数据流,这在使用输入法(IME)的时候会导致崩溃这样严重的错误。
我们在 React 更新渲染之前打断点,然后全选文本,输入任意内容。可以看到,在没有输入法的状态下,更新之前 DOM element 并没有被移除。
但是在有输入法的情况下,contenteditable 会将光标所选位置的 DOM element 先行清除,此时 React 中却还有对应的 Fiber Node,这样更新之后,React 就会发现需要卸载的 Fiber 所对应的 DOM element 已经不属于其父 element,从而报错。并且这一事件不能被 prevent default,所以单向数据流一定会被打破。
React 相关的 issue 从 2015 年起就挂在那里了。slate 官方对 IME 相关的问题的积极性也不高。
对于协同编辑的支持仅停留在理论可行性上。slate 使用了
Operation
,这使得协同编辑存在理论上的可能,但是对于协同编辑至关重要的 operation transform 方案(即如何处理两个有冲突的编辑操作),则没有提供实现。总的来说,slate 是一个拥有良好扩展性的轻量富文本编辑器(框架?),很适合 CMS、社交媒体这种不需要复杂排版和实时协作的简单富文本编辑场景。
希望这篇文章能够帮助大家对 slate 形成一个整体的认知,并从其技术方案中了解它的优点和局限性,从而更加得心应手地使用 slate。