引言
上一篇文章只介绍了 quill 在存储数据时使用的模型 Delta,但并没有具体说明 DOM 与 Delta 是如何实现互相转换的。实际上,在这个过程中还存在一个中间层,那就是 Parchment。
本文将介绍 Parchment 的几个核心概念,以及 quill 数据渲染与更新的过程。
Parchment 核心概念
Parchment 被称为 quill 的文档模型,它主要包含了 Blot、Scroll、Attributor 这三种概念。Parchment 整体结构与浏览器的 Document 文档模型比较相似,它从设计上就是 Document 的一个简化版本。
1、Blot
Blot 是 Parchment 中的基本单元,就像是 Document 中的 DOM 节点一样。Parchment 文档模型把所有的元素都抽象为 Blot,且支持嵌套,整个文档就形成了一个嵌套的树状结构。
Blot 的核心定义如下:
export interface Blot {
// 整个树状结构的根节点
scroll: Root;
// 父节点
parent: Parent;
// 前一个兄弟节点
prev: Blot | null;
// 后一个兄弟节点
next: Blot | null;
// 当前 blot 对应到页面上的 dom 节点
domNode: Node;
// 一些静态属性
statics: {
blotName: string;
className?: string;
scope: Scope;
tagName: string;
};
}
这个结构与 DOM 节点的结构非常相似,整体是一个多叉树,同一层级的节点组成一个双向链表,便于在数据更新时进行增删改查操作。
<div class="ql-editor">
<h1>标题</h1>
<p>
段落的开头
<span>一个行内文本</span>
段落的结尾
</p>
<p>第二个段落</p>
</div>
比如上面的 HTML 结构,就会被抽象类似如下的 Blot 树状结构:
{
blotName: 'Scroll',
children: [
{
blotName: 'Heading',
value: 'h1',
children: [
{
blotName: 'Text'
value: '标题'
}
]
},
{
blotName: 'Paragraph',
children: [
{
blotName: 'Text',
value: '段落的开头'
},
{
blotName: 'Span',
children: [
{
blotName: 'Text',
value: '一个行内文本'
}
]
},
{
blotName: 'Text',
value: '段落的结尾'
},
]
},
{
blotName: 'Paragraph',
children: [
{
blotName: 'Text',
value: '第二个段落'
}
]
}
]
}
Blot 有很多种分类方式:
- 基于元素的展示方式,可以分为行内 Inline Blot、块级 Block Blot 两种。
- 基于元素的嵌套关系,可以分为 ParentBlot、LeafBlot 两种。
两种特殊的 Blot:
- Embed Blot:对应了 HTML 中的嵌入类型的元素(比如 img、video、iframe 等等)。在此基础上,又能扩展出 InlineEmbed、BlockEmbed 两种类型。如果要实现一个块级的图片组件,通常会在 BlockEmbed 类的基础上进行扩展。
- Container Blot:内部可以嵌套多个 Block Blot,相当于是一个多行容器。比如 quill 内置的代码块组件就是基于 Container 实现的。
2、Scroll
可以把 Scroll 与 document.body
做类比,它是整个文档的根 Blot,对应的 DOM 也是整个文档的根节点。
Scroll 的子节点可以是 Block Blot 或者 Container Blot。
它对外提供了很多方法用来操作整个 Blot 树,比如插入、删除、更新节点等等。
3、Attributor
Attributor 用于管理一个 Blot 的属性,它可以是 Style 属性(color
、font-size
等),也可以是 Class 属性,当然也可以扩展成自定义的任何属性。
它就是整个富文本编辑器中「格式」的底层实现。任何一个 Blot 都持有 Attributor 的实例,用于获取和设置属性。
举一个例子,现在有一个 <span style="color: red;">红色的文字</span>
,那么它就会被抽象为如下的 Blot 结构:
{
blotName: 'span',
value: '红色的文字',
attributes: {
color: 'red'
}
}
DOM 与 Blot 是一一对应的,class、style 等属性又能通过 Attributor 存储和管理,这样就实现了 Document 与 Parchment 之间的双向映射关系。
当然,Parchment 映射的是 Document 的一个子集,它只包含了富文本编辑器需要存储的数据,比如特定的元素、特定的属性。对于无法识别的 DOM 节点或者样式属性,可以忽略掉。
而 Parchment 与 Delta 之间则是可以做到互相转换的,Parchment 中无非只有「Blot 类型」、「Attributor 属性」这两种数据,而 Delta 已经足够表达。
Blot 的详细定义
上文提到过 Blot 的核心属性,下面将详细介绍下 Blot 定义中的一些方法。
以一个 BlockEmbed 格式的自定义 blot 为例:
class CustomBlot extends BlockEmbed {
// 创建 Blot,在此之前已经创建过 DOM 节点
constructor(scroll: Scroll, domNode: HTMLElement) {
super(domNode);
}
// 通过 value 创建 DOM 节点的静态方法
static create(value: CustomBlotValue): HTMLElement {
// 自行实现从 value 创建 DOM 的逻辑
}
static value(domNode: Element): CustomBlotValue {
// 自行实现从 DOM 节点中获取 value 的逻辑
}
// 对当前 blot 进行一次 format 操作,比如设置样式
format(name: string, value: any) {
// 自行实现 format 逻辑,比如修改当前 domNode 上的属性
}
// 文档中新增了该元素时,会调用该方法
attach() {
// 通常在这里去挂载元素、注册元素上的事件监听等逻辑
}
// 用于自定义复制粘贴时的 HTML 结构
html() {
// 默认返回的是 Blot 的 DOM
// 对于复杂的元素(比如表格、图片、文件等)很实用。
}
}
创建一个 Blot 的过程如下:
// 1、通过 value 创建 DOM 节点
const domNode = CustomBlot.create(value);
// 2、创建 Blot 实例
const blot = new CustomBlot(scroll, domNode);
Blot 上还有一些方法,比如 insertAt
、deleteAt
、insertBefore
等等,它们则是用于在 Blot 树结构中进行增删改查操作的。
quill 的数据渲染与更新
首先介绍 updateContents
方法的流程,大致可以分为以下几个步骤:
1、遍历 Delta 中的每一个操作,根据不同类型(insert、delete、retain)的参数,在 Parchment 中找到对应的 Blot,解析 value 与 attributes,创建对应的 Blot。
insert:
{
insert: {
image: {
src: 'url',
alt: 'alt'
}
},
attributes: {
width: '100px'
}
}
这个 Op 将被解析为:插入一个 value 为 {src: 'url', alt: 'alt'}
的 Image Blot,它带有 width 为 '100px'
的 Attributor。
delete
{
delete: 10
}
这个 Op 将被解析为:从当前的虚拟光标开始往后删除 10 个字符。Parchment 内部会把这段文本对应的 Blot 删除掉,如果长度不能完整对应上,将会对 Blot 做拆分和合并。
retain
{
retain: 10,
attributes: {
color: 'red'
}
}
这个 Op 将被解析为:从当前的虚拟光标开始往后数 10 个字符,把它们对应的 Blot 上添加一个 color 为 red 的 Attributor。
2、将 Parchment 结构的变更渲染更新到 DOM 中。
- 对于新增的 Blot,需要在对应的位置创建新的 DOM 并插入到文档中;
- 对于删除的 Blot,需要从文档中移除对应的 DOM 节点;
- 对于有属性变更的 Blot,则更新对应 DOM 上属性。
而对于文档的 setContents
操作实际上相当于在空文档的基础上调用 updateContents
方法。所以这里就不再详细介绍。
这就是从 Delta 到 Parchment 再到 DOM 的整个过程,对于它的反向操作,也就是 DOM 到 Delta,通常是复制粘贴场景会遇到。
在执行复制操作时,quill 会把选区内容的 HTML 写入剪贴板(单个元素的 HTML 可以通过 Blot 上的 html 方法进行自定义),在粘贴时,先转换为 Delta 数据,再转换为 Parchment 进一步生成 DOM。
这一步是由 quill 内置的 Clipboard 模块负责,它实现了一套后序遍历算法来把 HTML 转换为 Delta,本系列后续会有一篇文章详细介绍这个模块。
总结
本文主要介绍了 Parchment 的核心概念,以及它与 Delta 之间的转换逻辑。
Parchment 是 Quill 实现富文本编辑器底层逻辑的核心部分,它抽象出了 Blot 的概念,并通过 Attributor 管理 Blot 的属性,实现了 Delta 与 DOM 之间双向映射的关系。