前言
我们一直在研究 Yjs 协同及 quill 文本编辑器的相关协同功能,但并未对底层数据传输格式(Y.Text)及 quill.delta 做研究,我们要知其所以然,今天就简单分析下 Delta 数据结构 是如何工作的,以及跳出框架,如何定义 Delta 数据结构,进行网络传输、数据识别,在实际的案例中运用 Delta 进行协同处理。
Delta数据结构基础理论
在当今分布式系统与协同应用日益普及的背景下,高效、可靠地同步数据成为了技术挑战的核心。Delta 数据结构(又称增量数据结构 )作为一种专门表示和传输数据变化而非完整数据集的格式,正成为解决这一挑战的关键技术。无论是协同编辑、数据湖架构还是分布式系统,Delta结构都通过仅记录和传输变更内容,显著提升了系统性能和可扩展性。
Delta数据结构是一种表示数据变化的格式,它捕获并存储对数据集的增量修改,而不是完整的数据副本。其核心思想在于,在大多数业务场景中,数据的变更是渐进式的,传输整个数据集既低效又不必要。通过只同步变更部分,Delta结构能够减少网络带宽消耗、降低存储成本,并提高数据同步的实时性。
从本质上看,Delta结构通常由一个操作序列组成,每个操作描述了如何从数据的一个状态转变到下一个状态。这些操作最常见的类型包括"insert"(插入)、"delete"(删除)和"update"(更新),以及retain(格式保留操作)。与传输完整数据集相比,Delta结构更像是一组指令,告诉系统如何从状态A转换到状态B。
Delta数据结构的设计理念
这里参考的是 Quill Delta 的设计格式 Designing the Delta Format
最初的富文本编辑器缺乏表达自身内容的规范,大多数富文本编辑器甚至不知道自己的编辑区域里有什么内容。这些编辑器只是将用户 HTML 传递过去,并承担了解析和解释这个 HTML 的负担。HTML会跟随浏览器不同,而产生不同的解析效果,导致用户有不同的编辑体验。那么,我们该如何解决该问题呢?
从最基础的纯文本开始,将编辑器的内容转换为字符串存储,但是当一段文本是粗体时,就需要额外的信息进行标注。
而数组是唯一的有序数据类型,使用对象数组,可以省去很多数据处理的麻烦,也允许我们利用JSON格式以兼容多种不同的格式。
            
            
              ts
              
              
            
          
          const content = [
  { text: 'Hello' },
  { text: 'World', bold: true }
];
        如果我们想要,可以为主对象添加斜体、下划线和其他格式;但将 text 与所有这些内容分开更清晰,因此我们将格式组织在一个字段下,该字段命名为 attributes:
            
            
              ts
              
              
            
          
          const content = [
  { text: 'Hello' },
  { text: 'World', attributes: { bold: true } }
];
        Compact
虽然上诉已经是一个简单的 Delta 格式,但是其结果也是不可预测的,因为上述 "Hello World" 示例可以表示得不同,所以我们无法预测会生成哪种形式:
            
            
              ts
              
              
            
          
          const content = [
  { text: 'Hel' },
  { text: 'lo' },
  { text: 'World', attributes: { bold: true } }
];
        为了规避这个问题,需要添加一定约束,Delta 必须是紧凑的。有了这个约束,上述表示不是有效的 Delta,因为它可以通过之前的示例更紧凑地表示,其中 "Hel" 和 "lo" 不是分开的。同样,我们也不能有 { bold: false, italic: true, underline: null } ,因为{ italic: true }更紧凑,应该规避一些无效的属性。
Canonical
上诉的例子中,我们虽然没有定义 bold 的具体行为,仅是作为文本的某种格式使用,我们完全可以使用其他名称,如 weighted 或 strong ,或者使用不同的值范围,例如数值或描述性的权重范围。CSS 就是一个例子,其中大多数这些模糊性都在发挥作用。如果我们看到页面上加粗的文本,我们无法预测其规则集是 font-weight: bold 还是 font-weight: 700 ,这使得解析 CSS 以辨析其含义的任务变得更加复杂。
虽然 Delta 不能定义具体的属性集及其含义,但是 Delta 增加了一个额外的约定,即 Delta 属性使用必须是规范的,如果两个相等的 Delta 结构,那么,他们所表示的内容及展示的结果也应该保持一致。 即有下列结构:
            
            
              ts
              
              
            
          
          const content = [{
  text: "Mystery",
  attributes: {
    a: true,
    b: true
  }
}];
        那么,a b 所表达的含义不同,所生成的实际效果也将不同,但是不能从字面获得 a b 所表达的具体含义,具体命名和展示结果由实现者自行决定,这在一定程度上给予实现者最大的自由及拓展性。而 Quill 也给我们提供了一些常见的规范:
- 使用六个字符的十六进制值来表示颜色,而不是 RGB;
 - 只有一种表示换行的方式,那就是 \n ,不是 \r 或 \r\n;
 text: "Hello World"明确表示"Hello"和"World"之间恰好有两个空格
Line Formatting
行格式影响整行的内容展示效果,因此需要对行内容做额外的约束,使得符合紧凑性及规范化。一个看似合理的文本居中结构如下:
            
            
              ts
              
              
            
          
          const content = [
  { text: "Hello", attributes: { align: "center" } },
  { text: "\nWorld" }
];
        但如果用户删除了换行符呢?得到如下Delta:
            
            
              ts
              
              
            
          
          const content = [
  { text: "Hello", attributes: { align: "center" } },
  { text: "World" }
];
        问:现在这行还是居中的吗? 如果不居中,那么,属性对象没有意义,将违反紧凑性原则,应该合并两个字符串:
            
            
              ts
              
              
            
          
          const content = [
  { text: "Hello World" },
];
        如果仍然居中,那么将违反规范约束,因为如果两个相等的 Delta 结构,那么,他们所表示的内容及展示的结果也应该保持一致,那么不同的结构所表示的结构一定不同。
那么,该如何处理这种行结构的复杂的结构呢?不能简单地直接删除换行符,要么删除行属性,要么将它们扩展以填充该行的所有字符。
有新的结构如下,如果我们删除换行符,那么结果因该居中还是居右展示?
            
            
              ts
              
              
            
          
          const content = [
  { text: "Hello", attributes: { align: "center" } },
  { text: "\n" },
  { text: "World", attributes: { align: "right" } }
];
        我们不清楚最终生成的行是居中对齐还是右对齐,具体落地时,可以删除两者之一,或者设定某种排序规则来优先考虑其中一个,但我们的 Delta 变得越来越复杂,在这个方向上越来越难以处理。为解决这个问题,Quill "添加" 新行到所有文档中,并且始终以 "\n" 结束 Deltas,如下:
            
            
              ts
              
              
            
          
          // Hello World on two lines
const content = [
  { text: "Hello" },
  { text: "\n", attributes: { align: "center" } },
  { text: "World" },
  { text: "\n", attributes: { align: "right" } }   // Deltas must end with newline
];
        这样的结构,在处理删除行时,能保证展示结果的一致性,满足 Delta 对紧凑及规范的要求。
Embedded Content
上诉讨论的都是文本的格式设置,但是对于嵌入内容呢?例如图片、超链接。由于嵌入内容有不同类型,我们的选择只需要包含类型信息,然后是实际内容。这里有很多合理的选项,但我们将使用一个对象,其唯一键是嵌入类型,值是内容表示,这可能具有任何类型或值。
            
            
              ts
              
              
            
          
          const img = {
  image: {
    url: 'https://quilljs.com/logo.png'
  }
};
const f = {
  formula: 'e=mc^2'
};
        与文本类似,图像也可能具有一些定义性特征和一些暂时性特征。我们使用 attributes 来表示文本内容,同样可以使用相同的 attributes 字段来表示图像。但由于这个原因,我们可以保持我们一直在使用的总体结构,但应该将我们的 text 键重命名为更通用的名称,我们将选择名称 insert ,将所有这些放在一起,我们得到:
            
            
              ts
              
              
            
          
          const content = [
{ insert: 'Hello' },
{ insert: 'World',  attributes: { bold: true }}, 
{ insert: { image: 'https://exclamation.com/mark.png'},  attributes: { width: '100' }}
];
        Describing Changes
正如 Delta 这个名字所暗示的,我们的格式可以描述文档的变更,也可以描述文档本身,事实上,我们可以将文档视为从空文档到我们所描述的文档所需要进行的变更。使用 Delta 来描述变更也是我们之前将 text 重命名为 insert 的原因,我们将 Delta 数组中的每个元素称为一个操作:
Delete
要描述删除文本,我们需要知道要删除的位置和字符数量。要删除嵌入内容,除了需要了解嵌入内容的长度外,不需要特殊处理,无论图像由多少像素组成,视频有多长,演示文稿中有多少张幻灯片,嵌入内容的长度都是一。
            
            
              ts
              
              
            
          
          const delta = [
    { delete: { index: 4, length: 1 },},
    { delete: { index: 12, length: 3 },},
]
        Insert
现在 Deltas 可以描述对非空文档的更改,使用 { insert: "Hello Word" } 来表示一个插入操作。
Format
与删除操作类似,我们需要指定要格式化的文本范围,以及格式变更本身。格式化信息存在于 attributes 对象中,因此一个简单的解决方案是提供一个额外的 attributes 对象来与现有的对象合并。这种合并是浅层的,以保持简单性。
            
            
              ts
              
              
            
          
          const delta = [{
  format: { index: 4, length: 1 },
  attributes: { bold: true }
}];
        注意: 正因Delta 格式的灵活性,每一个 Delta 操作,都可以赋予任何属性的格式,在应用层处理时,例如 视频加粗、图像持续时间等非本嵌入内容的属性。
Pitfalls
首先,我们应该明确,在应用任何操作之前,索引必须指向其在文档中的位置。否则,后续的操作可能会删除之前的插入内容、取消之前的格式化等,这将违反紧凑性原则。操作也必须严格排序以满足我们的规范约束。按索引、长度和类型排序是完成这一目标的一种有效方法。
Retain
如果我们暂时放下紧凑性的要求,我们可以描述一个更简单的格式来表示插入、删除和格式化:
- 一个 Delta 将包含至少与被修改文档一样长的操作
 - 每个操作将描述该索引处的字符会发生什么
 - 可选的插入操作可能会使 Delta 的长度超过它所描述的文档
 
这需要创建一个新的操作,其含义仅为保持此字符不变,我们称其为 retain:
            
            
              ts
              
              
            
          
          // Starting with "HelloWorld",
// bold "Hello", and insert a space right after it
const change = [
  { format: true, attributes: { bold: true } },  // H
  { format: true, attributes: { bold: true } },  // e
  { format: true, attributes: { bold: true } },  // l
  { format: true, attributes: { bold: true } },  // l
  { format: true, attributes: { bold: true } },  // o
  { insert: ' ' },
  { retain: true },  // W
  { retain: true },  // o
  { retain: true },  // r
  { retain: true },  // l
  { retain: true }   // d
]
        由于每个字符都有描述,因此不再需要显式的索引和长度。这使得表示重叠范围和顺序错误的索引变得不可能。因此,我们可以进行简单的优化,合并相邻的相等操作,重新引入长度。如果最后一个操作是 retain ,我们可以直接删除它,因为它只是指示对文档的其余部分不做任何操作:
            
            
              ts
              
              
            
          
          const change = [
  { format: 5, attributes: { bold: true } }
  { insert: ' ' }
]
        此操作与上面是等效的。
retain  在某种程度上只是 format  的一种特殊情况。例如,{ format: 1, attributes: {} }和 { retain: 1 } 之间没有实际区别,压缩会删除空的 attributes 对象,留下我们只有 { format: 1 } ,从而创建了一个规范化冲突。因此,在我们的示例中,我们将简单地合并 format 和 retain ,并保留名称 retain 。
现在我们有一个非常接近当前标准格式的 Delta。
Ops
目前我们有一个易于使用的 JSON 数组来描述富文本。这在存储和传输层非常出色,应用程序可以从更多功能中受益。我们可以通过将 Deltas 实现为一个类来添加这些功能,这个类可以轻松地从 JSON 初始化或导出到 JSON,并提供相关的方法。
在 Delta 初始阶段,无法对 Array 进行子类化。因此,Delta 以对象形式表示,其中包含一个名为 ops 的属性,该属性存储了我们讨论过的操作数组。
            
            
              ts
              
              
            
          
          const delta = {
    ops: [
        { insert: "Hello" },
        { insert: "World", attributes: { bold: true } },
        { insert: { image: "https://exclamation.com/mark.png" }, attributes: { width: "100" } },
    ],
};
        以上便是 Delta 的设计理念。
Delta 数据结构操作API
Construction
- 创建一个新的 Delta 对象,其参数支持三种类型,如下
 
            
            
              ts
              
              
            
          
          new Delta()
new Delta(ops) // 直接传入操作数组 
new Delta(delta) // 传入带有 ops 的对象,该键被设定为一个操作数组
        注意: 使用 ops 或 delta 构建时不会进行有效性/合理性检查,新 delta 的内部 ops 数组也将直接从 ops 或 delta.ops 赋值,而不会进行深度复制。
            
            
              ts
              
              
            
          
          // 直接传入操作数组
const delta = new Delta([
  { insert: 'Hello World' },
  { insert: '!', attributes: { bold: true }}
]);
// 传入带有 ops 的对象,该对象的值被设定为操作数组
const packet = JSON.stringify(delta);
const other = new Delta(JSON.parse(packet));
// 构建空的 Delta 对象
const chained = new Delta().insert('Hello World').insert('!', { bold: true });
        insert()
- 追加一个插入操作,支持链式调用
 
            
            
              ts
              
              
            
          
          // 插入文本
delta.insert('Text', { bold: true, color: '#ccc' });
// 插入嵌入对象
delta.insert({ image: 'https://octodex.github.com/images/labtocat.png' });
        delete()
- 追加一个删除操作,支持链式调用
 
            
            
              ts
              
              
            
          
          delta.delete(length)
        retain()
- 添加一个保留操作,支持链式调用
 
            
            
              ts
              
              
            
          
          // length 保留字符长度 attributes 可选的属性
retain(length, attributes)
// Example
delta.retain(4).retain(5, { color: '#0c6' });
        下面是综合案例:
            
            
              ts
              
              
            
          
          // 最初的 Delta
// {
//   ops: [
//     { insert: 'Gandalf', attributes: { bold: true } },
//     { insert: ' the ' },
//     { insert: 'Grey', attributes: { color: '#cccccc' } }
//   ]
// }
// 可以使用 retain delete 实现想要的效果
{
  ops: [
    // Unbold and italicize "Gandalf"
    { retain: 7, attributes: { bold: null, italic: true } },
    // Keep " the " as is
    { retain: 5 },
    // Insert "White" formatted with color #fff
    { insert: 'White', attributes: { color: '#fff' } },
    // Delete "Grey"
    { delete: 4 }
  ]
}
        concat()
- 将两个文档的操作进行连接
 
            
            
              ts
              
              
            
          
          delta.concat(other)
// Example
const a = new Delta().insert('Hello');
const b = new Delta().insert('!', { bold: true });
const concat = a.concat(b); // 连接操作是有顺序的
// Result
// {
//   ops: [
//     { insert: 'Hello' },
//     { insert: '!', attributes: { bold: true } }
//   ]
// }
        diff()
- 返回两个文档的差异
 
            
            
              ts
              
              
            
          
          diff(other)
diff(other, index) // 传入 index 则从 index 开始检索
// Example
const a = new Delta().insert('Hello');
const b = new Delta().insert('Hello!');
const diff = a.diff(b);  
// Result
// { ops: [{ retain: 5 }, { insert: '!' }] }
        eachLine()
- 遍历 Delta
 
            
            
              ts
              
              
            
          
          // predicate 每一行执行的函数 newline 用什么区分行,默认是 \n
eachLine(predicate, newline)
// Example  
const delta = new Delta().insert('Hello\n\n')
                         .insert('World')
                         .insert({ image: 'octocat.png' })
                         .insert('\n', { align: 'right' })
                         .insert('!');
delta.eachLine((line, attributes, i) => {
  console.log(line, attributes, i);
  // Can return false to exit loop early
});
// Result
// { ops: [{ insert: 'Hello' }] }, {}, 0
// { ops: [] }, {}, 1
// { ops: [{ insert: 'World' }, { insert: { image: 'octocat.png' } }] }, { align: 'right' }, 2
// { ops: [{ insert: '!' }] }, {}, 3
        
 
invert()
- 创建一个"反向操作",生成一个与原操作相反的操作,当我们把这个反向操作应用到结果上时,可以恢复到原始状态
 
            
            
              ts
              
              
            
          
          invert(base) // base 用于反转的文档
// Example
const base = new Delta().insert('Hello\n')
                        .insert('World');
const delta = new Delta().retain(6, { bold: true })
                         .insert('!')
                         .delete(5);
const inverted = delta.invert(base);
// Result
// { ops: [
//   { retain: 6, attributes: { bold: null } },
//   { insert: 'World' },
//   { delete: 1 }
// ]}
        具体的步骤分析如下:
const base = new Delta().insert("Hello\n").insert("World");创建了Hello\nWord字符;- 记住上诉步骤的结果,
invert就是要生成一个操作,执行后,能恢复到上诉步骤; retain(6, { bold: true }).insert("!").delete(5)给Hello\n加粗,插入!,删除Word;invert方法就是要生成一个完全相反的操作,把*Hello\n*!(* 表示加粗)变回Hello\nWorld;- 初始状态(
base):Hello\nWord - 执行的操作:
retain(6, { bold: true }).insert("!").delete(5)⇒"Hello\n"(带粗体)+ "!" - 反向操作:
取消加粗前6个字符&删除插入的"!"&恢复被删除的"World" - 所以 
invert的操作结果是: 
            
            
              ts
              
              
            
          
          { ops: [
  { retain: 6, attributes: { bold: null } },
  { insert: 'World' },
  { delete: 1 }
]}
        filter()
- 过滤操作
 
            
            
              ts
              
              
            
          
          filter(predicate) // 返回 true 以保留该操作,否则返回 false
// Example
const delta = new Delta().insert('Hello', { bold: true })
                         .insert({ image: 'https://octodex.github.com/images/labtocat.png' })
                         .insert('World!');
const text = delta
  .filter((op) => typeof op.insert === 'string')
  .map((op) => op.insert)
  .join('');
// Result
// HelloWord!
        forEach()
- 遍历
 
length()
- 返回 Delta 的操作长度
 
map()
- 返回一个新数组
 
            
            
              ts
              
              
            
          
          const delta = new Delta().insert('Hello', { bold: true })
                         .insert({ image: 'https://octodex.github.com/images/labtocat.png' })
                         .insert('World!');
const text = delta
  .map((op) => {
    if (typeof op.insert === 'string') {
      return op.insert;
    } else {
      return '';
    }
  })
  .join('');
// Result
// HelloWord!
        partition()
- 创建一个包含两个数组的数组,第一个数组包含通过给定函数的操作,第二个数组包含失败的操作
 
            
            
              ts
              
              
            
          
          const delta = new Delta().insert('Hello', { bold: true })
                         .insert({ image: 'https://octodex.github.com/images/labtocat.png' })
                         .insert('World!');
const results = delta.partition((op) => typeof op.insert === 'string');
const passed = results[0];  // [{ insert: 'Hello', attributes: { bold: true }},
                            //  { insert: 'World'}]
const failed = results[1];  // [{ insert: { image: 'https://octodex.github.com/images/labtocat.png' }}]
        reduce()
- 创建累加器
 
            
            
              ts
              
              
            
          
          // predicate - 每次迭代调用的函数,返回累积值
// initialValue - 传递给谓词第一次调用的初始值
reduce(predicate, initialValue) 
// Example
const delta = new Delta().insert('Hello', { bold: true })
                         .insert({ image: 'https://octodex.github.com/images/labtocat.png' })
                         .insert('World!');
const length = delta.reduce((length, op) => (
  length + (op.insert.length || 1);
), 0);
        slice()
- 返回Delta操作子集
 
            
            
              ts
              
              
            
          
          slice()
slice(start) // start 默认为0 
slice(start, end) // end 默认为剩余操作,也就是 delta 的长度
// Example
const delta = new Delta().insert('Hello', { bold: true }).insert(' World');
const copy = delta.slice();
// Result
// {
//   ops: [
//     { insert: 'Hello', attributes: { bold: true } },
//     { insert: ' World' }
//   ]
// }
const world = delta.slice(6); // { ops: [{ insert: 'World' }] }
const space = delta.slice(5, 6); // { ops: [{ insert: ' ' }] }
        compose()
- 返回一个与先应用自身 Delta 的操作,然后应用另一个 Delta 的操作等效的 Delta
 
            
            
              ts
              
              
            
          
          const a = new Delta().insert('abc');
const b = new Delta().retain(1).delete(1);
const composed = a.compose(b);  // composed == new Delta().insert('ac');
// 也就是  a.compose(b) 与 new Delta().insert('ac') 操作等效
        transform()
- 将给定的 Delta 与自己的操作进行转换
 
            
            
              ts
              
              
            
          
          const a = new Delta().insert('a');
const b = new Delta().insert('b').retain(5).insert('c');
a.transform(b, true);  // new Delta().retain(1).insert('b').retain(5).insert('c');
a.transform(b, false); // new Delta().insert('b').retain(6).insert('c');
        transformPosition()
- 将索引与差值进行转换,可用于表示光标/选择位置。
 
            
            
              ts
              
              
            
          
          const delta = new Delta().retain(5).insert('a');
delta.transformPosition(4); // 4
delta.transformPosition(5); // 6
        总结
Delta数据结构作为一种高效的增量数据表示格式,在现代协同应用和分布式系统中发挥着关键作用。通过只记录和传输数据变更而非完整数据集,Delta结构显著提升了系统性能和可扩展性。
核心价值:
- 
高效性:仅同步变更内容,减少网络带宽消耗 - 
灵活性:支持文本、嵌入内容等多种数据类型 - 
可靠性:通过紧凑性和规范化保证数据一致性 
设计亮点:
- 
采用操作序列(insert、delete、retain、format)描述数据变化
 - 
通过紧凑性和规范化约束确保数据表达的确定性
 - 
创新的行格式化处理和嵌入内容支持
 
应用优势:
- 
在协同编辑中实现实时数据同步
 - 
支持操作合成、转换和反转等高级功能
 - 
提供丰富API支持复杂业务场景
 
Delta数据结构不仅是技术实现的工具,更是构建高效、可靠协同系统的设计哲学,为现代分布式应用提供了坚实的数据同步基础。