Yjs 中的 Y.Text 是一种 CRDT(Conflict-free replicated data types),用于处理分布式环境中的实时协作文本编辑。Y.Text 允许多个用户同时在文本任意位置插入和删除,无需处理冲突或数据不一致。
Y.Text 的实现涉及两个概念:
- 操作的唯一标识符: 在 Yjs 中,每个插入或删除操作都由全局唯一标识符标识。该标识符由生成操作的用户唯一标识符和递增的逻辑时钟值组成,因此两个用户在同一位置操作时,操作仍可区分。
- 操作的部分排序: Yjs 使用唯一标识符为操作定义部分排序。若操作 A 的标识符小于操作 B 的标识符,则操作 A 在操作 B 之前。同一位置发生多个操作时,这个排序用于处理冲突,标识符较小的操作先执行。
插入操作为每个字符分配全局唯一标识符(ID)。ID 由生成操作的客户端唯一 ID 和该客户端递增的逻辑时钟组成。网络延迟存在时,不同客户端的操作仍可区分。
删除操作会记录每个字符的删除信息,并与插入操作一起传播到所有副本。副本收到删除操作后,从文档中删除对应字符。
Y.Text 没有明确的“更新”操作,而是通过删除和插入组合实现更新。例如,替换某个位置的字符时,先删除该字符,再插入新字符。
同一位置发生两个或更多操作时,Yjs 比较操作 ID 以确定顺序。ID 全局唯一且存在定义好的顺序,因此所有副本都会得到相同结果,不受接收操作顺序影响。
并发操作到达时,Yjs 依据上述规则确定顺序。只要所有副本都收到所有操作,它们最终会达到相同状态。
Yjs 还通过操作日志保存插入和删除操作。操作日志保存的内容可用于重建当前文档状态。
下面是用于说明这些概念的粗略实现:
class YText {
constructor() {
this.ops = []; // 操作日志
this.text = []; // 文本的当前状态
this.clientId = Math.random().toString(36).substring(2); // 随机生成客户端ID
this.clock = 0; // 逻辑时钟
}
insert(index, char) {
const id = { clientId: this.clientId, clock: this.clock++ };
this.ops.push({ type: 'insert', id, index, char });
this.text.splice(index, 0, { id, char });
}
delete(index) {
const { id } = this.text[index];
this.ops.push({ type: 'delete', id, index });
this.text.splice(index, 1);
}
// 处理来自其他客户端的操作
receive(operation) {
if (operation.type === 'insert') {
this.text.splice(operation.index, 0, { id: operation.id, char: operation.char });
} else if (operation.type === 'delete') {
const index = this.text.findIndex(c => compareIds(c.id, operation.id) === 0);
if (index >= 0) {
this.text.splice(index, 1);
}
}
// 对文本进行重新排序
this.text.sort((a, b) => compareIds(a.id, b.id));
}
}
// ID比较函数
function compareIds(id1, id2) {
if (id1.clientId < id2.clientId) {
return -1;
} else if (id1.clientId > id2.clientId) {
return 1;
} else {
return id1.clock - id2.clock;
}
}
示例假设操作按顺序接收,不处理网络延迟和乱序操作。实际的 Yjs 需要使用更多逻辑和数据结构处理这些情况,包括状态向量、删除集和操作转换。
这个示例未覆盖乱序操作、网络分区等问题;Yjs 的实际实现需要处理这些情况,仍需结合源码分析。
