Slate 是一个完全可订制的富文本编辑器框架,其所有的逻辑都是通过插件来实现的,用户拥有高度的自由,不会被 slate 多定制的规则所约束。
为什么要编写 slate,作者在其编写文档中这样写道,“在发明 Slate 之前,我尝试了大量的富文本编辑器库,我发现使用它们构建简单的 demo 是没有问题的,但是当你开始构建类似于 Medium,Dropbox Paper 或者 Google Docs 这样的项目,你会遇到深层次的问题:编辑器的 "schema" 是硬编码的,编程式转换文档是非常复杂的,对 HTML,MarkDown 等内容的序列化支持看起来像是事后加上的,重新发明一个视图层似乎是效率低并且有局限的,协同编辑不是预先设计好的,代码仓库是庞大的,并非小而可复用的,无法构建复杂,嵌套的文档。”
在 Slate 的设计理念中,插件是“一等公民”,可以通过插件定制所需要的任何功能,而不受到约束;同时 slate 保持了与 DOM 相同的数据模型,这使得在 DOM 上能做的操作,在 slate 中也可以实现;slate 在设计之处就做了明确的边界划分,将核心和定制版边界描述的十分清晰;同时 slate 在设计之初就考虑到了协同,使用者不需要在接入协同的时候去做彻底重构,可以简单的实现接入。
slate 从设计上隔离了核心和扩展部分,核心代码为上图 slate core 部分其体量十分小,整个核心部分不到 1w 行代码量,Plugin 部分为基于 Slate Core 进行的扩展。slate 和 prosemirror 一样,不能开箱即用,需要开发者对其进行简单的组合才能实现编辑。
selection,transform,node,text:这些提供了 slate 编辑器的核心数据处理能力,在包结构中通属于 transforms 包。
interface:主要是基于 transform 封装了上层接口能够被外部调用,其中包含了 EditorInterface,OperationInterface 等。
create-editor:在 Core 的 interface 基础上再做了一层封装,提供给用户创建编辑器和调用相关接口。
slate-history:通过 withHistory 提供编辑器 undo,redo 的能力。
slate-react:react 扩展包,如果要用 react 编写编辑器需要使用到这个独立库,其中包含了相关渲染逻辑,其中重写了部分 slate 原有的 editor。
slate-hyperscript:hyperscript 支持扩展包。
由于 slate 的设计的特殊性,其渲染层是以插件的形式实现的,其核心模块可以视为单纯的数据层 + 可扩展的接口。
当编辑富文本的时候,用户可能会插入文本,删除文本,分隔段落,添加格式等等。这些编辑行为都可以用两个概念来说明:命令和操作。作者在设计这一模块的时候,将其设计得十分灵活易于扩展。
命令(commands)是代表用户特定意图的高级操作。它们是 editor 接口的辅助函数。这些 commands 其在内核中对应的就是 create-editor 模块的代码,具体详情可以从代码层面去了解。
这是一个简单的示意图,slate 将其所有的 commands 都平铺分布在 editor 对象上。
slate 的 Commands 可以大致分为两类:
slate 本身提供的核心实现;
扩展实现;
其中核心实现中又分为 base Commands 和 Query Commands。
和所有编辑器内核一样,slate 也内置了一些基础的 commands 命令,主要是对编辑器的一些基础处理能力,比如插入文字,删除文字,插入节点删除节点等。
例如:我们需要插入一段文字,editor.insertText('test'),就是在对应的光标所在位置追加一个 test。
除了基础的编辑命令之外,slate 还内置了丰富的查询接口,用于获取当前文档的相关信息。下图就是现有 Slate 库内置的一些 Query Commands,主要是查询类和判断类的 commands。
slate 有一点强大之处就在于其能够支持高度自由化的扩展,用户能够通过 slate 设定的规则实现任意扩展,对不同的场景提供定制化的能力。
例如,需要扩展一个加粗功能,用户可以通过操作已经设定好的 Transforms 的方式实现自己实现一个加粗的效果,然后通过 Plugin 的方式绑定到对应的 editor 上去。
import { Editor, Element } from "slate";
const customEditor = {
...Editor,
isBold() {
Transforms.insertNodes(editor, element);
},
};
customEditor.isBold();
通过上述代码,我们实现了对 editor 的扩展,提供了 editor 新的方法。
这里我们需要理解一下 commands 和 plugin 的边界,其实 plugin 里面包含了自定义的 commands,commands 是 plugin 的组成部分。
接口层由 2 部分组成,分别为:create-editor 文件和 interface 包。interface 包负责将存在于 transforms 的方法暴露给外部使用,create-editor 负责实现基础的编辑能力,以及初步的数据处理。
createEditor() 所创建的是默认接口,如果此时存在插件的话,会在 createEditor 基础上再次封装一层,扩展 editor 接口的实现,具体实例如下方代码所示:
const editor = useMemo(() => withHistory(withReact(createEditor())), []);
上述代码是对基础 editor 的一个扩展,加入了 react 渲染和 undo,redo 插件,这也是 slate 的一大特色,外部依赖插件化,这是值得我们去学习的。
在逻辑上接口层不包含任何的数据处理逻辑,只是对接收的数据信息筛选转发给内核处理。我们以 insertText 为例:
// 获取选区
const { selection, marks } = editor;
if (selection) {
if (Range.isCollapsed(selection)) {
const inline = Editor.above(editor, {
match: (n) => Editor.isInline(editor, n),
mode: "highest",
});
if (inline) {
const [, inlinePath] = inline;
if (Editor.isEnd(editor, selection.anchor, inlinePath)) {
const point = Editor.after(editor, inlinePath)!;
Transforms.setSelection(editor, {
anchor: point,
focus: point,
});
}
}
}
// 如果存在属性,则就走插入节点的逻辑,反之走插入文本的逻辑
if (marks) {
const node = { text, ...marks };
Transforms.insertNodes(editor, node);
} else {
Transforms.insertText(editor, text);
}
editor.marks = null;
}
},
上述代码便是 insertText 的逻辑,接口层只是区分了各种不同情况的处理。同时接口层的设计和 commands 也是一样的,包含了基本的操作方法和 getter 接口。
在 slate 官网中提到,其拥有近似于浏览器 DOM 的 API,其模型为基于 DOM 的一颗嵌套树,其命名,事件定义均符合浏览器标准,能够让开发者很轻易的理解和上手,同时也降低了浏览器处理变更产生的成本。
在 slate 中树的节点被分为 3 种类型:
export type Node = Editor | Element | Text;
export interface Element {
children: Node[];
[key: string]: unknown;
}
export interface Text {
text: string;
[key: string]: unknown;
}
Element 类型含有 children 属性,可以作为其他 Node 的父节点
Editor 可以看作是一种特殊的 Element ,它既是编辑器实例类型,也是文档树的根节点
Text 类型是树的叶子结点,包含文字信息
用户可以自行拓展 Node 的属性,例如通过添加 type 字段标识 Node 的类型(paragraph, ordered list, heading 等等),或者是文本的属性(italic, bold 等等),来描述富文本中的文字和段落。
我们可以通过官网的 demo 直观的了解到 slate model 的接口:
bold 属性就是通过 [key:string]:unknown;
的形式加入的。
由于其是用树形结构实现的 model,同时也带来了一些问题:
对于协同编辑的冲突处理,树的解决方案比线性 model 复杂,我们需要关注如何从 JSON 的结构上来解决冲突,如果是 JSON 结构来解决,那么实现一个能够针对不同层级下数据进行协同的算法就是至关重要,同时也是难度非常大的事情。
持久化 model/创建编辑器的时候需要进行序列化/反序列化。
当 commands 命令被调用的时候,最终会由对应的 transform 进行执行 ,在 slate 中定义了四种类型的 transform,将分情况进行调用。
export const Transforms = {
...GeneralTransforms,
...NodeTransforms,
...SelectionTransforms,
...TextTransforms,
};
NodeTransforms:对 Node 的操作方法
SelectionTransforms:对选区的操作方法
TextTransforms:对文本操作方法
其中比较特殊的就是 GeneralTransforms,其不生成 operation,其作用是直接对 model 进行操作,其余的所有的 transform 都需要依赖于 GeneralTransforms 去操作 model,示意图如下图所示。
在具体实现上,这里我们同样以 insertText 方法为例:
判断传入位置是否合法。
传入位置转换,修正。
如果传入的不是空元素,生成 operation,触发 Apply。
operation 的结构如下所示:
{
type: 'insert_text',
path,
offset,
text,
}
operation 是在当调用 command 和 transform 时候执行的粒度更小的操作。单个 command 可能导致许多更低级的 operation 被应用到编辑器。和 command 不同的地方是,operation 是不可扩展的。在 slate 中作者预先定义了 9 种 Operation 供开发者使用。
Operation 详细对照表:
类型 type | 携带信息 payload |
---|---|
插入文本:Insert Text | 所在节点(path),插入内容(text),偏移量(offset),文本含有格式(marks) |
删除文本:Remove Text | 所在节点(path),删除内容(text),偏移量(offset),文本含有格式(marks) |
设置文本格式:Set Mark | 所在节点(path),偏移量(offset),区间长度(length),格式(mark),格式属性(properties) |
添加文本格式:Add Mark | 所在节点(path),偏移量(offset),区间长度(length),添加格式(mark) |
删除文本格式:Remove Mark | 所在节点(path),偏移量(offset),区间长度(length),删除格式(mark) |
设置节点属性:Set Node | 所在节点(path),被设置节点(node),节点属性(properties) |
插入节点:Insert Node | 插入位置(path),插入节点(node) |
删除节点:Remove Node | 删除位置(path),删除节点(node) |
移动节点:Move Node | 移动位置(path),移动目的地(newPath) |
合并节点:Merge Node | 待合并的节点(path),合并目的地位置(position),合并后节点属性(properties) |
断开节点:Split Node | 断开的节点(path),分裂位置(position),分裂后节点属性(properties) |
前一章节提到,我们的操作通过对应的 transform 转换之后,会传到 GeneralTransforms 文件中对应的处理方法之中,在该文件作者定义了一下 9 种 op 的应用实现。
本节我们同样以 insertText operation 为例:
case 'insert_text': {
const { path, offset, text } = op;
if (text.length === 0) break;
const node = Node.leaf(editor, path);
const before = node.text.slice(0, offset);
const after = node.text.slice(offset);
node.text = before + text + after;
if (selection) {
for (const [point, key] of Range.points(selection)) {
selection[key] = Point.transform(point, op)!;
}
}
break;
}
其实通过这块逻辑,我们可以看出 slate 对于相同节点内的处理是十分粗暴的,直接根据 offset 进行截取和合并,实现操作应用。
为了避免内容出错,校验是每一位编辑器开发者都必须关注的重点,在 slate 中我们可以关注两个重点:脏区管理,Normalizing 机制。
说到 Normalizing 机制,就要必须要提的是 slate 内部所指定的约束机制(其为所有 slate 操作都必须遵循的规则):
所有 Element 节点最后必须包含至少一个 Text 节点。
两个相邻的有同样属性的文本会被合并。
块节点要么只能包含其他块节点,要么包含行内节点与文本节点。
顶级的编辑器节点只能包含块节点。
这些默认约束都是强制性的,因为它们保证 Slate 文档有 更好的 可预测性。下图这块代码边描述了这四大约束。
在 apply 函数处理冲突的时候最关键的部分在 selection, slate 会根据 selection 提供的选区域范围进行定位。这里 anchor 对应了我们的 startIndex,focus 对应我们的 endIndex。
其中 offset 属性为对应的偏移量。path 是引用一个位置的最底层方式(第一个数字代表段落,第二个数字代表段落所属于的节点)。
关于 Path 变量比较形象的描述如下图所示:
协同严格来说已经不属于 slate 内核中的一部分,所以我将其放在了独立的段落进行描述。
slate 自身是不提供协同相关的能力,现有协同库均是第三方开发者依赖 slate 的插件特性开发而来,均为 slate 插件。这里我们选取了比较主流的 yjs 来进行讲解。
yjs-slate 的用法和其他 slate 插件一样,通过包裹 createEditor 方法实现扩展,用户可以通过 withYjs() 创建一个具备协同能力的 editor 对象。slate-yjs 分为 2 部分:一部分是 YjsEditor 用于接入 Yjs 协同框架,另一部分是 cursorEditor 主要是用于接入 webscoket 能力。
为了实现协同 yjs 利用 slate 的特性重写了 editor 的部分能力,为其迅速适配了 Yjs 的结构,下面就是 slate-yjs 中提供的 applySlateOp 的方法,该方法是将本地的 slate 的数据结构转换为能够在网络中通信的 yjs 的数据结构。其中参数 editor 为 withYjsEditor 扩展之后的新 editor,operations 为 slate 原有 Operation 的结构。
发送成功之后,对方主机会收到一个 Yjs Event,通过 ApplyYjsEvent 去执行应用。
由于 slate 的 model 设计是一个树形结构,这给了接入 yjs 天然的优势,slate model 可以不经过任何转换,直接以 JSON 结构的形式通过 yjs 进行协同操作。这也是为啥我们在查看 yjs-slate 源码的时候为对 slate model 做更多处理的原因,其具体的处理仍然依赖于 slate 核心库去做。下面 yjs 对应字符串操作时的逻辑。
let offset = 0;
// 开始处理协同结果
Editor.withoutNormalizing(editor, () => {
// 拿到协同过来的delta,实际上delta和我们的delta有点像,这是yjs中定义的数据。
event.changes.delta.forEach((delta) => {
// 分情况处理结果
if ("retain" in delta) {
offset += delta.retain ?? 0;
}
if ("delete" in delta) {
Transforms.insertText(editor, "", {
at: {
anchor: { path: targetPath, offset },
focus: { path: targetPath, offset: offset + (delta.delete ?? 0) },
},
});
}
if ("insert" in delta) {
invariant(
typeof delta.insert === "string",
`Unexpected text insert content type: expected string, got ${typeof delta.insert}`
);
// 直接把结果数据执行transform
Transforms.insertText(editor, delta.insert, {
at: { path: targetPath, offset },
});
// 偏移更新
offset += delta.insert.length;
}
});
});
delta 的结构和我们的 delta 有些相似,一个 delta 可以包含多个 op,下图就是一个简单的 delta 示意图。
为什么 slate 敢说其模型天然支持协同,我想原因不外乎以下几点:
其一是 slate 的插件能力设计得足够强大,让我们扩展协同的时候非常的便利,具体可以参考 slate-react,slate-history 的实现。
其二是模型足够简单,通过上面的分析就可以看出其实 slate 核心的 model 十分的简单,不存在什么复杂的结构,我们不需要为了接入某个协同去做复杂的改动,这样对于协同接入会省去改造和适配的成本。
这里我们需要注意的一点,slate 虽说天然支持协同,但是我认为其支持的协同是 json 格式的协同,如果说此时切换成了 easy sync 也许效率也不会太高了。
slate 核心不具备渲染能力,官方 slate 所提供的渲染能力是依赖于 slate-react 库实现,和协同一样都是插件形式提供。
其渲染和 model 一一对应,通过 model 的层次结构来实现不同的组件的渲染,slate 所实现的渲染结构如下所示。
slate | Prosemirror | |
---|---|---|
Commands API | 提供基础的 API 能力,并允许可以通过插件形式扩展。 | 提供了一些基础的编辑命令的组合,可以通过插件形式扩展更多能力。 |
插件化 | 覆写编辑器实例 editor 上的方法,实现任意扩展。 | 用来以多种不同的方式扩展编辑行为和编辑状态。 |
Model | 树形结构基本与 DOM 相同 | 树形结构 |
Operation | slate 提供了 9 种 op 操作来实现最底层的原子操作(操作 Text 和 Node) | 在 prosemirror op 操作又被定义为 step,每一个 transform 都可以被拆分为多个 step 去执行。 |
协同 | 不内置协同算法,数据模型天然支持协同 | 提供了协同算法的模块 |
总结 | slate 的核心是不具备渲染能力的,渲染依赖于 slate-react 实现,slate 的核心更像是单独的数据层,如果需要其他的编辑能力就比如依赖于 react 去做,同时 slate 可以通过插件去实现定制化扩展的效果。 | 整体阅读下来,感觉就是 prosemirror 实现了一个对标 react 的框架,prosemirror 具备自己的虚拟数据层,同时也实现了对应的更新逻辑,同时 prosemirror 更像是一款框架,开发者不能开箱即用,需要一点一点搭建。 |
slate 从技术上来说感觉能被我们所借鉴的不多,但其设计思想却值得我们去细细琢磨。
设计规范:slate 的开发者严格坚持核心与扩展分开,核心模块由 slate 开发者自己控制,与外界支持严格区分,其余扩展模块均以插件依赖库的形式提供。
扩展性:slate 提供的 commands 可扩展性高,开发者可以用较小的成本实现插件的扩展,现有 slate-history,slate-react 就很好的说明了这一点。
Model 设计:slate 的模型设计和 prosemirror 一样都设计了自己的虚拟 DOM,slate 的设计是一个仿 DOM 的模型,这样设计天然运行效率高。
模块化:slate 的核心库十分简洁,甚至可以说是只有数据层,同理 prosemirror 虽然被划分为了很多模块,但是其每一个模块都十分独立,能够脱离整体独立运行,我们 word 可以参考其思路进行拆分改造,这样既优化了我们的代码质量,又能做到完成局部的开源,而不暴露整体。
JSON 协同:无论是 slate,prosemirror 还是 Google doc,其都已经和我们以前接触的基于线性协同有所不同,实际上更多的需要去思考如何通过 JSON 解决冲突。
通过上述分析,我们可以知道,slate 是一款优秀的编辑器,是一款值得我们去学习的编辑器,尽管 contenteditable 的形式已经无法满足现在我们的需求,但是其在架构和数据设计方面是优秀的,有很多值得我们所参考的点,比如 all in plugin,model 向浏览器 DOM 靠近,模块与模块之间高度独立,坚持核心与扩展区分保障核心库的独立性和稳定性,这些都是值得我们去学习的。
以上就是我对 slate 编辑框架的学习和总结,欢迎有兴趣的大佬们一起交流学习,同时如果错误或描述不到位的地方也欢迎指正。
https://docs.slatejs.org/walkthroughs/01-installing-slate:slate 官方文档
https://slate.xheldon.com/Introduction.html:slate 中文文档
https://bitphinix.github.io/slate-yjs-example/:slate + yjs 实现协同编辑 demo
https://github.com/yjs/yjs:yjs 协同框架
https://www.yuque.com/arvinxx-fe/slate/flop3i:slate 文章集合
https://github.com/bitphinix/slate-yjs:slate-yjs 解决方案
https://github.com/BitPhinix/slate-yjs-example:slate-yjs demo 源代码
https://zhuanlan.zhihu.com/p/262209236:slate 架构设计分析
关于AlloyTeam
AlloyTeam 是国内影响力最大的前端团队之一,核心成员来自前 WebQQ 前端团队。 AlloyTeam负责过WebQQ、QQ群、兴趣部落、腾讯文档等大型Web项目,积累了许多丰富宝贵的Web开发经验。 这里技术氛围好,领导nice、钱景好,无论你是身经百战的资深工程师,还是即将从学校步入社会的新人,只要你热爱挑战,希望前端技术和我们飞速提高,这里将是最适合你的地方。 加入我们,请将简历发送至 alloyteam@qq.com,或直接在公众号留言~ 期待您的回复😁
最近文章: