delta格式原理及其应用

介绍

delta格式[1]是一种数据结构,以json的形式表示,用来记录文档变更,同时也能表达整个文档的内容和格式。相比于git用来进行版本管理,delta是针对富文本内容的轻量化方案,在文档处理程序中比较常见,比如当前github star比较🔥的AppFlowy,它的编辑器[2]也是用的quill delta表示内容的。

如下示例是quill-delta提供的样例,其中展示了insertdeleteretaincompose四种操作,内容从灰甘道夫变成了白甘道夫。也就是说:一个Delta是有多个Delta操作组成的。虽然从名字上看Delta是表示文档的改变,但其实也可以表示一个新文档的内容:一个新文档总是可以通过一系列insert操作构成的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操作

一个Delta操作由操作码和属性构成。形式如下

{
    op_name: value,
    attributes: {}
}

表示从文档当前位置进行的操作。由于Delta是描述文档级别的改动,因此一般来讲,总是从文档开始位置描述Delta。比如

new Delta().insert("hello").delete(2).retain(1).insert("world")

就对应在0位置插入hello

delta只是定义了一个宽松的内容结构,比如属性字典,并没有规定字典中的每个项目应该怎么解释,比如加粗bold应该渲染成几号字体,这些都是由各自的前端实现完成。

insert

插入操作用于在文档的当前位置插入一个内容,可以是字符串,也可以是多媒体,如果是字符串,长度为字符串的长度,如果是多媒体,长度固定为1。

// Insert a bolded "Text"
{ 
    insert: "Text", 
    attributes: { bold: true }
}
// Insert a link
{ 
    insert: "Google", 
    attributes: { link: 'https://www.google.com' }
}
// Insert an embed
{
    insert: { image: 'https://octodex.github.com/images/labtocat.png' }, 
    attributes: { alt: "Lab Octocat" }
}
// Insert another embed
{
    insert: { video: 'https://www.youtube.com/watch?v=dMH0bHeiRNg' },
    attributes: {width: 420,height: 315}
}

delete

删除内容,用数字表示接下来要删除的内容长度。

比如{ delete: 3},表示删除接下来的3个长度的内容。

retain

保留内容,用数字表示接下来要保留的内容长度。

比如{ retain: 2},表示保留接下来的2个长度的内容。

compose

这里compose的含义是:组合多个delta操作,形成最终的文档改变。比如连续的两次插入,有可能(属性相同)可以组装成一次插入。

compose操作的几条原则:

  1. insert/delete/retain,类型相同时直接合并
  2. insert优先于delete/retain
  3. delete、retain同时存在,则按照先后顺序进行

a.compose(b)表示a操作在前,b操作在后的一次组合。

举例:

const a = new Delta().insert('abc');
const diff = new Delta().retain(1).delete(1);
// b = {insert: "ac"}
const b = a.compose(diff);

文件diff

有同一个文件的两个版本v1和v2,方法delta=v1.diff(v2)生成v1到v2的改变,满足v2=v1.compose(delta)

diff 算法是基于 fast-diff (Node.js 纯文本 diff 算法实现)实现。

文件回退:

invertDelta = delta.invert(v2) //  获取基于v2文档的delta的反操作
v2.apply(insertDelta) // 将delta应用到文档

transform

compose操作用于组合连续的delta操作,顾名思义,它不会改变delta本身,因而适合无冲突的编辑场景(比如同一个人对文档的连续多次编辑)。

和compose操作组合delta不同,transform操作用于文档内容发生冲突的场景,通过转换delta消除冲突,从而确保文件内容一致性和完整性。用a.transform(b)表示基于a转换b。

const a = new Delta().insert('a');
const b = new Delta().insert('b').retain(5).insert('c');

// a优先
a.transform(b, true);  // new Delta().retain(1).insert('b').retain(5).insert('c');
// b优先
a.transform(b, false); // new Delta().insert('b').retain(6).insert('c');

应用

版本控制

假设文档A经过编辑后变成了文档B,那么满足以下关系delta = A.diff(B)A.compose(delta)=B。如果需要回退文档,参考文件回退

协作编辑

在线文档编辑平台如 Google Docs 和 Microsoft Office 365 允许多个用户同时对同一个文档进行编辑。Delta 格式使得实时同步成为可能,每个用户的更改都能立即反映给其他参与者,同时不会导致数据丢失或混乱。这主要依赖于transform操作。

在多设备同步中的应用

在基于网盘的多设备内容同步工具中,比如sync vault(v0.5以上)。首先通过transform操作解决多设备上对同一篇文档编辑带来的内容一致性问题;其次为每个设备保存一份本地编辑产生的delta,用来进行版本管理。

参考

[1] npm package: quill-delta, https://www.npmjs.com/package/quill-delta

[2] AppFlowy Editor: https://pub.dev/packages/appflowy_editor