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
架构等,后续文章会慢慢进行阐述。
参考资料
Quill编辑器实现原理初探的更多相关文章
- Python源代码剖析笔记3-Python运行原理初探
Python源代码剖析笔记3-Python执行原理初探 本文简书地址:http://www.jianshu.com/p/03af86845c95 之前写了几篇源代码剖析笔记,然而慢慢觉得没有从一个宏观 ...
- SpringBoot-02 运行原理初探
SpringBoot-02 运行原理初探 本篇文章根据b站狂神编写 pom.xml 2.1.父依赖 其中它主要是依赖一个父项目,主要是管理项目的资源过滤及插件! <parent> < ...
- Quill编辑器介绍及扩展
从这里进入官网. 能找到这个NB的编辑器是因为公司项目需要一个可视化的cms编辑器,类似微信公众号编辑文章.可以插入各种卡片,模块,问题,图片等等.然后插入的内容还需要能删除,拖拽等等.所以采用vue ...
- Git 内部原理--初探 .git
说到Git大家应该都非常熟悉,几乎每天都会用到它.在日常使用过程中,我们貌似并不需要关注其内部的原理,只需要记住那几个常用的命令,就可以说自己是会Git的人了.可是,事实真的是这样子的吗?今天我们就来 ...
- Spring自定义属性编辑器及原理解释.md
bean的自动装配解释 手动解决方式 自动注入解决方式 bean的自动装配解释 之前有构造注入和设值注入,但是也是手动的 autowire ="byname" 这里要注意自动装配的 ...
- Robotium原理初探
本文转载于:http://blog.csdn.net/jack_chen3/article/details/41927395 测试框架图: Android测试环境的核心是Instrumentation ...
- 机器学习中模型泛化能力和过拟合现象(overfitting)的矛盾、以及其主要缓解方法正则化技术原理初探
1. 偏差与方差 - 机器学习算法泛化性能分析 在一个项目中,我们通过设计和训练得到了一个model,该model的泛化可能很好,也可能不尽如人意,其背后的决定因素是什么呢?或者说我们可以从哪些方面去 ...
- SHA-256算法和区块链原理初探
组内技术分享的内容,目前网上相关资料很多,但读起来都不太合自己的习惯,于是自己整理并编写一篇简洁并便于(自己)理解和分享的文章. 因为之前对密码学没有专门研究,自己的体会或理解会特别标注为" ...
- nginx、swoole高并发原理初探
阅前热身 为了更加形象的说明同步异步.阻塞非阻塞,我们以小明去买奶茶为例. 同步与异步 同步与异步的重点在消息通知的方式上,也就是调用结果通知的方式. 同步:当一个同步调用发出去后,调用者要一直等待调 ...
- Spring学习之旅(三)Spring工作原理初探
详细的废话相信很多书籍视频资料都已经很多了,这里说几个小编个人认为对于理解Spring框架很重要的点.欢迎批评指正. 1)Spring的控制反转 先说说“依赖”,在面向对象程序设计中,类A中用到了类B ...
随机推荐
- Jmeter——结合Allure展示测试报告
在平时用jmeter做测试时,生成报告的模板,不是特别好.大家应该也知道allure报告,页面美观. 先来看效果图,报告首页,如下所示: 报告详情信息,如下所示: 运行run.py文件,运行成功,如下 ...
- JavaEE Day01 基础加强
今日内容 1.Junit单元测试(测试类) 2.反射 3.注解 一.Junit单元测试 1.概述 测试分类 1. 黑盒测试:不需要写代码,给输入值,看程序是否能够输出期望的值 2.白盒测试:需要写代码 ...
- 【Java SE进阶】Day08 File类、递归
一.File类 1.概述java.io.File 文件和路径的抽象表示 用于文件和目录的创建.查找和删除等 分类 file--文件 directory--文件夹/目录 path--路径 2.静态成员变 ...
- Windows10下python3和python2同时安装(二)python2.exe、python3.exe和pip2、pip3设置
Windows10下python3和python2同时安装(二) python2.exe.python3.exe和pip2.pip3设置 说明:安装安装python3和python2请参考本系列教程( ...
- 源码解读之TypeScript类型覆盖检测工具type-coverage
因为团队内部开启了一个持续的前端代码质量改进计划,其中一个专项就是TS类型覆盖率,期间用到了type-coverage这个仓库,所以借这篇文章分享一下这个工具,并顺便从源码阅读的角度来分析一下该工具的 ...
- C/S UDP通信实践踩坑记录与对于ICMP的进一步认识
背景 最近有个业务场景需要服务端(简称S)与客户端(简称C)设计一套基于UDP的通信协议--要求尽可能快的前提下可容忍一定丢包率,得以比较深入地学习和了解UDP通信和实践,在开发调试期间先后碰到了C端 ...
- java基础篇——异常
异常的三种类型 1.检查型异常:通常是由用户错误或者问题引起,是程序员无法预见的,例如用户要打开一个不存在的文件... 2.运行时异常:最有可能被程序员忽略的异常,可以在编译时被忽略,例如无限递归调用 ...
- 普冉PY32系列(二) Ubuntu GCC Toolchain和VSCode开发环境
目录 普冉PY32系列(一) PY32F0系列32位Cortex M0+ MCU简介 普冉PY32系列(二) Ubuntu GCC Toolchain和VSCode开发环境 以下介绍PY32F0系列在 ...
- win32com操作word 第三集:Range精讲(一)
本课程<win32com操作word API精讲&项目实战>,本公众号以文字分享为主,B站与视频号则发布视频分享,ID均为:一灯编程 本集开始,将会深入Document接口.打开或 ...
- C++Vector源码解析(侯捷STL)
vector是动态空间,随着元素的加入,它的内部机制会自行扩充空间以容纳新的元素.vector的实现技术,关键在于对大小的控制以及重新配置时的数据移动效率. 一.vector的数据结构 vector采 ...