富文本编辑器 Quill.js 系列一:Delta 文档结构

前言

近期在整理富文本 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 算法逻辑为:

  1. 对比 pickLength 与当前 index 对应 ops 的大小,若大,则进入步骤 2,否则进入步骤 3;
  2. index++,直接拿去当前 op 信息,并返回;
  3. 取 [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 中有几条原则,决定计算规则:

  1. insert/delete/retail,类型相同时选择性直接合并;
  2. insert 优先于 delete/retail;
  3. 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 文档模型》,敬请期待!

参考