name: yjs description: Yjs CRDT 模式、共享类型、冲突解决和元数据结构。在构建协作应用、处理 Y.Map/Y.Array/Y.Text、实现拖放重排序或优化文档存储时使用。 metadata: author: epicenter version: ‘1.0’
Yjs CRDT 模式
核心概念
共享类型
Yjs 提供六种共享类型。主要使用三种:
Y.Map- 键值对(类似于 JavaScript Map)Y.Array- 有序列表(类似于 JavaScript Array)Y.Text- 带格式的富文本
其他三种(Y.XmlElement、Y.XmlFragment、Y.XmlText)用于富文本编辑器集成。
客户端 ID
每个 Y.Doc 在创建时获得一个随机的 clientID。此 ID 用于冲突解决——当两个客户端同时写入同一键时,较高的 clientID 获胜,而不是较晚的时间戳。
const doc = new Y.Doc();
console.log(doc.clientID); // 随机数字,如 1090160253
来自 dmonad(Yjs 创建者):
“获胜者由文档的
ydoc.clientID决定(这是一个生成的数字)。较高的 clientID 获胜。”
源代码中的实际比较(updates.js#L357):
return dec2.curr.id.client - dec1.curr.id.client; // 较高的 clientID 获胜
这是确定性的(所有客户端收敛到相同状态)但不直观(后来的编辑可能丢失)。
共享类型无法移动
一旦将共享类型添加到文档中,它就永远无法移动。数组中的“移动”项目实际上是删除 + 插入。Yjs 不知道这些操作相关。
关键模式
1. 单写入者键(计数器、投票、在线状态)
问题:多个写入者更新同一键会导致写入丢失。
// 错误:两个客户端都读取 5,都写入 6,一次点击丢失
function increment(ymap) {
const count = ymap.get('count') || 0;
ymap.set('count', count + 1);
}
解决方案:按 clientID 分区。每个写入者拥有自己的键。
// 正确:每个客户端写入自己的键
function increment(ymap) {
const key = ymap.doc.clientID;
const count = ymap.get(key) || 0;
ymap.set(key, count + 1);
}
function getCount(ymap) {
let sum = 0;
for (const value of ymap.values()) {
sum += value;
}
return sum;
}
2. 分数索引(重排序)
问题:使用删除+插入进行拖放重排序会导致重复和更新丢失。
// 错误:“移动” = 删除 + 插入 = 损坏
function move(yarray, from, to) {
const [item] = yarray.delete(from, 1);
yarray.insert(to, [item]);
}
解决方案:添加 index 属性。按索引排序。重排序 = 更新属性。
// 正确:通过更改索引属性重排序
function move(yarray, from, to) {
const sorted = [...yarray].sort((a, b) => a.get('index') - b.get('index'));
const item = sorted[from];
const earlier = from > to;
const before = sorted[earlier ? to - 1 : to];
const after = sorted[earlier ? to : to + 1];
const start = before?.get('index') ?? 0;
const end = after?.get('index') ?? 1;
// 添加随机性以防止冲突
const index = (end - start) * (Math.random() + Number.MIN_VALUE) + start;
item.set('index', index);
}
3. 嵌套结构以避免冲突
问题:将整个对象存储在一个键下意味着任何属性更改都会与其他属性冲突。
// 错误:Alice 更改 nullable,Bob 更改 default,一个丢失
schema.set('title', {
type: 'text',
nullable: true,
default: 'Untitled',
});
解决方案:使用嵌套 Y.Maps,使每个属性是单独的键。
// 正确:每个属性独立
const titleSchema = schema.get('title'); // Y.Map
titleSchema.set('type', 'text');
titleSchema.set('nullable', true);
titleSchema.set('default', 'Untitled');
// Alice 和 Bob 编辑不同键 = 无冲突
存储优化
Y.Map 与 Y.Array 用于键值数据
Y.Map 墓碑永久保留键。每次 ymap.set(key, value) 都会创建新的内部项目并墓碑化前一个。
对于高周转键值数据(频繁更新的行),考虑使用 yjs/y-utility 中的 YKeyValue:
// YKeyValue 在 Y.Array 中存储 {key, val} 对
// 删除是结构性的,不是每键墓碑
import { YKeyValue } from 'y-utility/y-keyvalue';
const kv = new YKeyValue(yarray);
kv.set('myKey', { data: 'value' });
何时使用 Y.Map:有限键,值很少更改(设置、配置)。 何时使用 YKeyValue:许多键,频繁更新,存储敏感。
基于纪元的压缩
如果您的架构使用版本化快照,则免费获得压缩:
// 通过重新编码当前状态压缩 Y.Doc
const snapshot = Y.encodeStateAsUpdate(doc);
const freshDoc = new Y.Doc({ guid: doc.guid });
Y.applyUpdate(freshDoc, snapshot);
// freshDoc 具有相同内容,无历史开销
常见错误
1. 假设“最后写入获胜”意味着时间戳
不是。较高的 clientID 获胜,而不是较晚的时间戳。围绕此设计或使用 y-lwwmap 添加显式时间戳。
2. 使用 Y.Array 位置进行用户控制的顺序
数组位置适用于仅追加数据(日志、聊天)。用户可重排序列表需要分数索引。
3. 忘记文档集成
Y 类型必须在使用前添加到文档中:
// 错误:孤儿 Y.Map
const orphan = new Y.Map();
orphan.set('key', 'value'); // 工作但不同步
// 正确:附加到文档
const attached = doc.getMap('myMap');
attached.set('key', 'value'); // 同步到对等端
4. 存储不可序列化的值
Y 类型存储 JSON 可序列化数据。无函数、无类实例、无循环引用。
5. 期望移动保留身份
// 这创建了 NEW 项目,不是移动的项目
yarray.delete(0);
yarray.push([sameItem]); // 内部不同的 Y.Map 实例
对“移动”项目的任何并发编辑都会丢失,因为您删除了原始项目。
调试技巧
检查文档状态
console.log(doc.toJSON()); // 完整文档作为普通 JSON
检查客户端 ID
// 查看谁会在冲突中获胜
console.log('我的 ID:', doc.clientID);
观察墓碑膨胀
如果文档意外增长,检查:
- 频繁的 Y.Map 键覆盖
- 数组上的“移动”操作
- 缺少纪元压缩
参考文献
- Learn Yjs - 交互式教程
- Yjs 文档 - API 参考
- Yjs INTERNALS.md - Yjs 内部工作原理
- GitHub issue #520 - 与 dmonad 的冲突解决讨论
- yjs/y-utility - YKeyValue 和助手
- y-lwwmap - 基于时间戳的 LWW
- fractional-indexing - 生产库
- YATA paper - 学术基础