yjs

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Yjs CRDT Patterns

Yjs CRDT模式

Core Concepts

核心概念

Shared Types

共享类型

Yjs provides six shared types. You'll mostly use three:
  • Y.Map
    - Key-value pairs (like JavaScript Map)
  • Y.Array
    - Ordered lists (like JavaScript Array)
  • Y.Text
    - Rich text with formatting
The other three (
Y.XmlElement
,
Y.XmlFragment
,
Y.XmlText
) are for rich text editor integrations.
Yjs 提供六种共享类型,其中最常用的有三种:
  • Y.Map
    - 键值对(类似 JavaScript Map)
  • Y.Array
    - 有序列表(类似 JavaScript Array)
  • Y.Text
    - 带格式的富文本
另外三种(
Y.XmlElement
Y.XmlFragment
Y.XmlText
)用于富文本编辑器集成。

Client ID

Client ID

Every Y.Doc gets a random
clientID
on creation. This ID is used for conflict resolution—when two clients write to the same key simultaneously, the higher clientID wins, not the later timestamp.
typescript
const doc = new Y.Doc();
console.log(doc.clientID); // Random number like 1090160253
From dmonad (Yjs creator):
"The 'winner' is decided by
ydoc.clientID
of the document (which is a generated number). The higher clientID wins."
The actual comparison in source (updates.js#L357):
javascript
return dec2.curr.id.client - dec1.curr.id.client; // Higher clientID wins
This is deterministic (all clients converge to same state) but not intuitive (later edits can lose).
每个 Y.Doc 在创建时会生成一个随机的
clientID
。该ID用于冲突解决——当两个客户端同时写入同一个键时,clientID 更大的一方获胜,而不是根据时间戳先后。
typescript
const doc = new Y.Doc();
console.log(doc.clientID); // Random number like 1090160253
来自 Yjs 创始人 dmonad:
“‘获胜方’由文档的
ydoc.clientID
(一个生成的数字)决定,clientID 更大的一方获胜。”
源码中的实际比较逻辑(updates.js#L357):
javascript
return dec2.curr.id.client - dec1.curr.id.client; // Higher clientID wins
这种机制是确定性的(所有客户端最终会收敛到同一状态),但不符合直觉(较晚的编辑可能会被覆盖)。

Shared Types Cannot Move

共享类型不可移动

Once you add a shared type to a document, it can never be moved. "Moving" an item in an array is actually delete + insert. Yjs doesn't know these operations are related.
一旦将共享类型添加到文档中,就永远无法移动。在数组中“移动”一个元素实际上是先删除再插入,Yjs 无法识别这些操作是相关联的。

Critical Patterns

关键模式

1. Single-Writer Keys (Counters, Votes, Presence)

1. 单写入者键(计数器、投票、在线状态)

Problem: Multiple writers updating the same key causes lost writes.
typescript
// BAD: Both clients read 5, both write 6, one click lost
function increment(ymap) {
	const count = ymap.get('count') || 0;
	ymap.set('count', count + 1);
}
Solution: Partition by clientID. Each writer owns their key.
typescript
// GOOD: Each client writes to their own key
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;
}
问题:多个写入者更新同一个键会导致写入丢失。
typescript
// BAD: Both clients read 5, both write 6, one click lost
function increment(ymap) {
	const count = ymap.get('count') || 0;
	ymap.set('count', count + 1);
}
解决方案:按 clientID 划分,每个写入者拥有自己的键。
typescript
// GOOD: Each client writes to their own key
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. Fractional Indexing (Reordering)

2. 分数索引(重排序)

Problem: Drag-and-drop reordering with delete+insert causes duplicates and lost updates.
typescript
// BAD: "Move" = delete + insert = broken
function move(yarray, from, to) {
	const [item] = yarray.delete(from, 1);
	yarray.insert(to, [item]);
}
Solution: Add an
index
property. Sort by index. Reordering = updating a property.
typescript
// GOOD: Reorder by changing index property
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;

	// Add randomness to prevent collisions
	const index = (end - start) * (Math.random() + Number.MIN_VALUE) + start;
	item.set('index', index);
}
问题:通过删除+插入实现拖放重排序会导致重复和更新丢失。
typescript
// BAD: "Move" = delete + insert = broken
function move(yarray, from, to) {
	const [item] = yarray.delete(from, 1);
	yarray.insert(to, [item]);
}
解决方案:添加
index
属性,按 index 排序。重排序即更新属性。
typescript
// GOOD: Reorder by changing index property
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;

	// Add randomness to prevent collisions
	const index = (end - start) * (Math.random() + Number.MIN_VALUE) + start;
	item.set('index', index);
}

3. Nested Structures for Conflict Avoidance

3. 嵌套结构避免冲突

Problem: Storing entire objects under one key means any property change conflicts with any other.
typescript
// BAD: Alice changes nullable, Bob changes default, one loses
schema.set('title', {
	type: 'text',
	nullable: true,
	default: 'Untitled',
});
Solution: Use nested Y.Maps so each property is a separate key.
typescript
// GOOD: Each property is independent
const titleSchema = schema.get('title'); // Y.Map
titleSchema.set('type', 'text');
titleSchema.set('nullable', true);
titleSchema.set('default', 'Untitled');
// Alice and Bob edit different keys = no conflict
问题:将整个对象存储在一个键下意味着任何属性的更改都会与其他属性的更改产生冲突。
typescript
// BAD: Alice changes nullable, Bob changes default, one loses
schema.set('title', {
	type: 'text',
	nullable: true,
	default: 'Untitled',
});
解决方案:使用嵌套 Y.Map,使每个属性成为独立的键。
typescript
// GOOD: Each property is independent
const titleSchema = schema.get('title'); // Y.Map
titleSchema.set('type', 'text');
titleSchema.set('nullable', true);
titleSchema.set('default', 'Untitled');
// Alice and Bob edit different keys = no conflict

Storage Optimization

存储优化

Y.Map vs Y.Array for Key-Value Data

键值数据的 Y.Map 与 Y.Array 对比

Y.Map
tombstones retain the key forever. Every
ymap.set(key, value)
creates a new internal item and tombstones the previous one.
For high-churn key-value data (frequently updated rows), consider
YKeyValue
from
yjs/y-utility
:
typescript
// YKeyValue stores {key, val} pairs in Y.Array
// Deletions are structural, not per-key tombstones
import { YKeyValue } from 'y-utility/y-keyvalue';

const kv = new YKeyValue(yarray);
kv.set('myKey', { data: 'value' });
When to use Y.Map: Bounded keys, rarely changing values (settings, config). When to use YKeyValue: Many keys, frequent updates, storage-sensitive.
Y.Map
的墓碑会永久保留键。每次调用
ymap.set(key, value)
都会创建一个新的内部项,并为之前的项添加墓碑。
对于高更新频率的键值数据(频繁更新的行),可以考虑使用
yjs/y-utility
中的
YKeyValue
typescript
// YKeyValue stores {key, val} pairs in Y.Array
// Deletions are structural, not per-key tombstones
import { YKeyValue } from 'y-utility/y-keyvalue';

const kv = new YKeyValue(yarray);
kv.set('myKey', { data: 'value' });
何时使用 Y.Map:键数量固定、值很少更改的场景(如设置、配置)。 何时使用 YKeyValue:键数量多、更新频繁、对存储敏感的场景。

Epoch-Based Compaction

基于纪元的压缩

If your architecture uses versioned snapshots, you get free compaction:
typescript
// Compact a Y.Doc by re-encoding current state
const snapshot = Y.encodeStateAsUpdate(doc);
const freshDoc = new Y.Doc({ guid: doc.guid });
Y.applyUpdate(freshDoc, snapshot);
// freshDoc has same content, no history overhead
如果你的架构使用版本化快照,那么可以免费获得压缩功能:
typescript
// Compact a Y.Doc by re-encoding current state
const snapshot = Y.encodeStateAsUpdate(doc);
const freshDoc = new Y.Doc({ guid: doc.guid });
Y.applyUpdate(freshDoc, snapshot);
// freshDoc has same content, no history overhead

Common Mistakes

常见错误

1. Assuming "Last Write Wins" Means Timestamps

1. 假设“最后写入获胜”指的是时间戳

It doesn't. Higher clientID wins, not later timestamp. Design around this or add explicit timestamps with
y-lwwmap
.
事实并非如此。获胜的是 clientID 更大的一方,而不是时间戳更新的一方。需要围绕这一点进行设计,或使用
y-lwwmap
添加显式时间戳。

2. Using Y.Array Position for User-Controlled Order

2. 使用 Y.Array 位置实现用户可控的排序

Array position is for append-only data (logs, chat). User-reorderable lists need fractional indexing.
数组位置适用于仅追加的数据(如日志、聊天)。用户可重排的列表需要使用分数索引。

3. Forgetting Document Integration

3. 忘记文档集成

Y types must be added to a document before use:
typescript
// BAD: Orphan Y.Map
const orphan = new Y.Map();
orphan.set('key', 'value'); // Works but doesn't sync

// GOOD: Attached to document
const attached = doc.getMap('myMap');
attached.set('key', 'value'); // Syncs to peers
Y 类型必须先添加到文档中才能使用:
typescript
// BAD: Orphan Y.Map
const orphan = new Y.Map();
orphan.set('key', 'value'); // Works but doesn't sync

// GOOD: Attached to document
const attached = doc.getMap('myMap');
attached.set('key', 'value'); // Syncs to peers

4. Storing Non-Serializable Values

4. 存储不可序列化的值

Y types store JSON-serializable data. No functions, no class instances, no circular references.
Y 类型仅存储可 JSON 序列化的数据。不能存储函数、类实例或循环引用。

5. Expecting Moves to Preserve Identity

5. 期望移动操作保留标识

typescript
// This creates a NEW item, not a moved item
yarray.delete(0);
yarray.push([sameItem]); // Different Y.Map instance internally
Any concurrent edits to the "moved" item are lost because you deleted the original.
typescript
// This creates a NEW item, not a moved item
yarray.delete(0);
yarray.push([sameItem]); // Different Y.Map instance internally
对“移动”项的任何并发编辑都会丢失,因为你删除了原始项。

Debugging Tips

调试技巧

Inspect Document State

检查文档状态

typescript
console.log(doc.toJSON()); // Full document as plain JSON
typescript
console.log(doc.toJSON()); // Full document as plain JSON

Check Client IDs

查看 Client ID

typescript
// See who would win a conflict
console.log('My ID:', doc.clientID);
typescript
// See who would win a conflict
console.log('My ID:', doc.clientID);

Watch for Tombstone Bloat

监控墓碑膨胀

If documents grow unexpectedly, check for:
  • Frequent Y.Map key overwrites
  • "Move" operations on arrays
  • Missing epoch compaction
如果文档体积意外增长,请检查以下情况:
  • 频繁覆盖 Y.Map 的键
  • 对数组执行“移动”操作
  • 缺少纪元压缩

References

参考资料