

Quill编辑器实现原理初探 - Gerryli
source link: https://www.cnblogs.com/gerry2019/p/17154900.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Quill编辑器实现原理初探
从事前端开发的同学,对富文本编辑器都不是很陌生。但是大多数富文本编辑器都是开箱即用,很少会对其实现原理进行深入的探讨。假如静下心去细细品味,会发现想要做好一款富文本编辑器,需要对整个前端生态有较深入的理解。在某种意义上说,富文本编辑器是前端一个集大成者。
富文本编辑器根据其实现方式,业内将其划分为L0 ~ L2
,层层递进,功能的支撑也越来越强大。
阶段 | 描述 | 典型产品 |
---|---|---|
L0 | 视图层基于contenteditable ,逻辑层基于document.execCommand ,直接操作DOM |
UEditor 、TinyMCE |
L1 | 视图层基于contenteditable ,逻辑层对DOM 进行抽象,用数据去驱动视图更新 |
Quill 、Prosemirror 、slate 、Draft |
L2 | 自己实现内容排版,不依赖于浏览器原生操作 | Google Docs 、WPS |
L0
级编辑器,基于contenteditable
与document.execCommand
指令,直接操作DOM
,简单粗暴,所见即所得,其优点是简单,我们只需要聚焦在视图层,document.execCommand
自身也提供一些操作指令,可以满足基本的文本操作需求,个性化的需求也可以通过封装自定义指令来满足;同理,缺点也很明显,只关注视图层,没有逻辑抽象,对于操作记录,文档结构变化,是黑盒,对于文档的版本管理、协同办公之类的需求,无能为力,因此,带着痛点,孕育出了L1
级编辑器。
L1
级编辑器核心亮点为增加了一层DOM
抽象,用数据去驱动视图的更新。HTML
是一门标记语言,没有较强逻辑性,而且可以层层嵌套,元素的种类又分为行内元素、行内块元素、块级元素,每个元素的表现形式又有区别,删繁就简,客观描述出每个元素的结构与行为,会让整个文档变得自主可控。字符是分散在不同的DOM
节点中,树形结构遍历的时间复杂度是O(n*h)
,这无疑是一种巨大的性能消耗,因此L1
级编辑器,用一种扁平化的数据结构去描述字符的位置、样式,这样对于字符查找、字符操作,会提升不少性能,具体实现细节也是很复杂的,后面会慢慢介绍。
L0
、L1
级编辑器,自身并没有脱离DOM
,底层还是依赖于contenteditable
,还是受限于浏览器自身,比如页面排版、焦点、选区等。但是到了L2
级编辑器,就脱离了浏览器原生操作。使用canvas
或svg
来实现内容编排,焦点、选区等操作都是自身手动去实现。这部分过于复杂,也只有Google
、WPS
之类的厂商才有实力去研发,我们不做过多的深究。
Quill
编辑器API
比较简单,概念比较清晰,上手也比prosemirror
简单,又有底层定制开发能力,使用范围较广。本文将简单介绍Quill
的一些核心概念和操作过程,实现细节在后续的文章中慢慢介绍。
Quill 基本原理
通过简介中的介绍,我们知道L1
级编辑器的几个核心概念,
document
文档数据模型(对应Quill
中的Parchment
)DOM
节点Node
的描述(对应Quill
中的Blot
)- 一种扁平化的字符位置、样式描述(对应
Quill
中的Delta
)
下文我们对以上Quill
中的概念做进一步的描述。
Delta
套用官网的话,什么是Delta
?

这段话翻译为中文为:“Deltas是一种简单而富有表现力的格式,可以用来描述Quill的内容和变化。该格式是JSON的严格子集,是人类可读的,机器很容易解析。Deltas可以描述任何Quill文档,包括所有文本和格式信息,没有HTML的歧义和复杂性。”
一个Delta
数据结构表现形式:
// 编辑器初始值
{
"ops": [
{ "insert": "Hello " },
{ "insert": "World" },
]
}
// 给World加粗后的值
// 3种动作:insert: 插入,retain:保留, delete:删除
{
"ops": [
{ "retain": 6 },
{ "retain": 5, "attributes": { "bold": true } }
]
}
这个能力使文档协同编辑成为了可能。最简单的协同编辑,通过以下几步操作即可:
- 监听编辑器文本改变
text-change
,获取数据改变的描述Delta
- 通过
websocket
将Delta
分发给每位协同编辑用户 - 调用
Quill
实例中UpdateContents
,更新协同编辑文档
Delta
对于文档的位置、样式描述,极大的简化文档操作,最原始的文档查找替换,需要深度优先遍历,还需要递归查找,十分不便,有了Delta
,它精准的描述了每个字符的位置,我们就可以像处理纯文本一样处理富文本。
Parchment
与Blot
Parchment
是document
的数据抽象,而Blot
是对Node
节点的抽象。也就是说,Parchment
是Blot
的父级,很多个Blot
组装成一个Parchment
。
Blot
分类:
ContainerBlot
(容器节点)ScrollBlot
root
(文档的根节点,不可格式化)BlockBlot
块级(可格式化的父级节点)InlineBlot
内联(可格式化的父级节点)
ScrollBlot
的实例数据结构:
{
"domNode": {}, // 真实的DOM节点
"prev": null, // 前一个元素
"next": null, // 后一个元素
"uiNode": null,
"registry": { // 注册的信息
"attributes": {},
"classes": {},
"tags": {},
"types": {}
},
"children": { // 子元素的节点描述,为一个链表
"head": null, // 第一个元素
"tail": null, // 最后一个元素
"length": 0 // 子元素长度
},
"observer": {} // DOM监听器
}
DOM变化与Parchment之间的数据同步
文档数据描述固然好,但是真实DOM
和数据模型如何实现实时同步呢?
在ScrollBlot
中,有个MutationObserver
,去实时监测DOM
变化。当DOM
发生变化时,会根据侦测到的真实DOM
,去查找对应节点的blot
信息,真实DOM
与blot
缓存在Registry
中,以一个WeakMap
的形式存储,具体缓存可见:
// parchment\src\registry.ts
public static blots = new WeakMap<Node, Blot>();
根据MutationObserver
回调的变化信息,执行对应的blot update
,以blockBlot
为例,其update
方法如下:
//
public update(
mutations: MutationRecord[],
_context: { [key: string]: any },
): void {
// 调用ParentBlot中update方法,对新增和删除节点做逻辑同步
super.update(mutations, context);
// 更新样式的逻辑同步
const attributeChanged = mutations.some(
(mutation) =>
mutation.target === this.domNode && mutation.type === 'attributes',
);
if (attributeChanged) {
this.attributes.build();
}
}
Parchment映射成Delta的过程
有了Parchment
对DOM
的抽象,就方便对文档字符位置和样式进行扁平化的描述,以编辑器初始化为例,看看Quill
是如何获取文档模型的Delta
。
- 获取
ScrollBlot
中所有的Block
,默认从Block
开始处理,即最小颗粒度是块级元素
// editor.ts中获取delta方法
getDelta(): Delta {
return this.scroll.lines().reduce((delta, line) => {
// 以Block为维度,分别获取每行的delta描述
return delta.concat(line.delta());
}, new Delta());
}
// scroll.ts中获取所有line的方法,即Block
lines(index = 0, length = Number.MAX_VALUE): (Block | BlockEmbed)[] {
const getLines = (
blot: ParentBlot,
blotIndex: number,
blotLength: number,
) => {
let lines = [];
let lengthLeft = blotLength;
blot.children.forEachAt(
blotIndex,
blotLength,
(child, childIndex, childLength) => {
// 最小颗粒度为Block
if (isLine(child)) {
lines.push(child);
} else if (child instanceof ContainerBlot) {
lines = lines.concat(getLines(child, childIndex, lengthLeft));
}
lengthLeft -= childLength;
},
);
return lines;
};
return getLines(this, index, length);
}
- 获取每行数据的delta描述
// block.ts
delta(): Delta {
if (this.cache.delta == null) {
this.cache.delta = blockDelta(this);
}
return this.cache.delta;
}
function blockDelta(blot: BlockBlot, filter = true) {
return (
blot
// @ts-expect-error
.descendants(LeafBlot) // 获取所有叶子节点
.reduce((delta, leaf: LeafBlot) => {
if (leaf.length() === 0) { // 叶子节点的长度
return delta;
}
// 插入一个delta描述符,包含位置,样式描述
return delta.insert(leaf.value(), bubbleFormats(leaf, {}, filter));
}, new Delta())
.insert('\n', bubbleFormats(blot))
);
}
获取delta
的过程也是遍历至叶子节点,根据叶子节点的位置进行计算。
以上只是对Quill
的核心概念的简单描述,还有很多细节没有做过多的阐述,如如何注册自定义扩展、Quill
的渲染流程、Parchment
架构等,后续文章会慢慢进行阐述。
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK