前言
近期在整理富文本 Quill.js 相关的资料,并应用到项目上。由于富文本可学习与应用的场景蛮多的,并且有一定的复杂度,故会整理相对比较系统的系列文章,来让大家能快速上手,并了解原理。
基本介绍
Delta 是用于描述富文本文档结构的内容与变更。由于其描述的通用性,quill.js 将其独立维护。它的数据结构是基于 JSON 格式的,方便服务间进行互解析,例如 一份描述富文本格式的 数据,可很方便的渲染于 Web 与 Android or iOS。相比于复杂和带有歧义的 HTML,其更简单纯粹。
一个 Delta 实例用 Array 来描述变更,这些操作包含 insert
delete
retain
,通过这些操作,来达到增删改的目的。注意 Delta 本身是不包含当前操作 Index 的,所有操作都是从头开始,这让整体逻辑更加纯净。跳过或保持一些内容通过 retain
来操作即可。
示例
我们通过官方的示例,来表达下 Delta 的数据操作。
// Document with text "Gandalf the Grey"
// with "Gandalf" bolded, and "Grey" in grey
const delta = new Delta([
{ insert: 'Gandalf', attributes: { bold: true } },
{ insert: ' the ' },
{ insert: 'Grey', attributes: { color: '#ccc' } }
]);
// Change intended to be applied to above:
// Keep the first 12 characters, insert a white 'White'
// and delete the next four characters ('Grey')
const death = new Delta().retain(12)
.insert('White', { color: '#fff' })
.delete(4);
// {
// ops: [
// { retain: 12 },
// { insert: 'White', attributes: { color: '#fff' } },
// { delete: 4 }
// ]
// }
// Applying the above:
const restored = delta.compose(death);
// {
// ops: [
// { insert: 'Gandalf', attributes: { bold: true } },
// { insert: ' the ' },
// { insert: 'White', attributes: { color: '#fff' } }
// ]
// }
我们构造了文档的信息:delta
,又构造了对此结构的一些操作,新增和 删除,最终通过 compose
函数对 此操作进行应用。
基于此示例,我们了解到了 Delta 的基本操作,更多 API 参见:Delta README 。本篇文章不会介绍具体 API 的用法,后续咱们一起来看下 Delta 的设计思想,并深入了解下文档描述对象的 diff, compose 等操作算法是如何实现的。
设计思想
数据结构
Delta 中数据插入类型分为 纯文本 与 embed 嵌入式内容。通过 attributes 区分不同的表现形式,应用场景例如我们常见的 标题、加粗、斜体、列表等。
var delta = {
ops: [{
insert: 'Hello'
}, {
insert: 'World',
attributes: { bold: true }
}, {
insert: {
image: 'https://exclamation.com/mark.png'
},
attributes: { width: '100' }
}]
};
扁平化数据
Delta 中数据都是扁平的,不存在子节点一说。那他如何表达 文档 dom 树结构的?
关键在于 Quill 假定富文本不存在块元素的嵌套,即一行中不能同时存在标题和列表。遇到换行符则新建一行作为块级别 tag open,直到遇到下一个换行符,作为 tag close。
紧凑
我们必须约定 Delta 中的数据格式标识形式,否则同一种数据可能存在不同表示,例如表示 “Hello Word”的不紧凑形式:
var ops = [
{ insert: 'Hel' },
{ insert: 'lo ' },
{ insert: 'World', attributes: { bold: true } }
];
为了解决这个问题,Delta 约定,数据格式必须是紧凑的,同一个富文本仅有一种数据表示形式。它的目的也很明确,就是方便我们以编程的形式,简单的比较 2 个 Delta 实例是否相等,也让应用程序更易于理解和维护。
核心算法
Push
Delta 中 ops 属性用于储存数据的最终表现,以数组形式表示,这个上文中也有提现。但是直接使用数组中的方法肯定是不行的,它需要处理富文本操作的一些场景,核心主要是这些:
- 连续的 insert/retain/delete 需要判断做合并操作,对于 insert/retain 则是 attrs 相同且 insert 都为 string 即可合并,例如 new Delta().insert('A').insert('B'),需要合并成 insert: 'AB';
- delete 之后的 insert/retain,需要做顺序调整,delete 永远在最后;
- 其他场景则直接 push ops 即可。
这主要是为了 ops 数据格式的一致性,为了之后的各种操作运算做逻辑统一化处理。
Slice
截取 ops 中的某部分内容,这是比较有意思的地方,因为 start, end 是基于富文本整体的开始结束,而不是 ops 数组的下标索引。拿以下格式举例:
const ops = [
{
insert: 'Hello'
},
{
insert: 'World',
attributes: { bold: true }
}
]
整体 delta length 为 10,假设我们要取 delta.slice(2,6),结果为:
const ops = [
{
insert: 'llo'
},
{
insert: 'Wo',
attributes: { bold: true }
}
]
重点是需要对 ops 进行迭代遍历,支持截断遍历,名词解释:
- index: ops 数组下标;
- offset: 单个 op 操作获取的偏移量,例如单个 op insert: 'abcd',offset 则表示此 op string 的开始索引;
- pickLength:所需长度
OpIterator 算法逻辑为:
- 对比 pickLength 与当前 index 对应 ops 的大小,若大,则进入步骤 2,否则进入步骤 3;
- index++,直接拿去当前 op 信息,并返回;
- 取 [offset, picLength] 之前长度 op 的数据返回;
基于上述 Iterator 可以很方便的进行各种截断操作。
Diff 与 Compose
对比文档 a 与文档 b 的不同 diff ,有以下等式:
a.compose(diff) == b
a.diff(b) == diff
Diff 用来表示 a 变换到 b需要做的 delta 操作,以单测举例 Diff:
const a = new Delta().insert('A');
const b = new Delta().insert('AB');
const expected = new Delta().retain(1).insert('B');
expect(a.diff(b)).toEqual(expected);
compose 中有几条原则,决定计算规则:
- insert/delete/retail,类型相同时选择性直接合并;
- insert 优先于 delete/retail;
- delete、retain 同时存在时,按先后顺序即可,对于 retain -> delete 场景,可直接忽略 retain 操作;
以上 3 条,决定了 9 种排列组合方式。
diff 是基于 fast-diff (Node.js 纯文本 diff 算法实现,暂不展开),将 ops 结构转换成纯文本进行对比,只关注 insert string 的部分,其他一律使用占位符替代。更多参考:https://www.npmjs.com/package/fast-diff。
这些 api 在 Quill 中的其中一个重要应用场景是历史记录,例如 v1 -> v2,想要做撤回操作时,只需要做如下:
v2.compose(v2.diff(v1)),即可回退到 v1 版本,所以 dom 操作也只关注变动的内容,所以效率非常高。
总结
回顾一下,我们本篇文章开始初步了解了富文本编辑器 Quill.js 的核心组成 Delta,其功能是表达富文本的数据格式与数组操作。后深入了解了下设计思想,了解其约定规范,同样的文章内容,仅有一种数据格式的表现形式,这样做会让代码实现更简单与利于维护。最后我们通过几个核心的 Api,介绍了其中的算法实现,了解算法过程。
本篇文章为 Quill.js 系列文章的首篇内容,后续文章:《富文本编辑器 Quill.js 系列二:Parchment 文档模型》,敬请期待!