npx skills add https://github.com/epicenterhq/epicenter --skill yjs相关技能:查看基于 Yjs 构建的工作空间抽象
workspace-api。
在以下场景中使用此模式:
Yjs 提供了六种共享类型。你主要会用到其中三种:
Y.Map - 键值对(类似于 JavaScript Map)Y.Array - 有序列表(类似于 JavaScript Array)Y.Text - 带格式的富文本另外三种(Y.XmlElement、、)用于富文本编辑器集成。
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
Y.XmlFragmentY.XmlText每个 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 不知道这些操作是相关的。
问题:多个写入者更新同一个键会导致写入丢失。
// 错误:两个客户端都读取 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;
}
问题:使用删除+插入进行拖放重新排序会导致重复和更新丢失。
// 错误:"移动" = 删除 + 插入 = 损坏
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);
}
问题:将整个对象存储在一个键下意味着任何属性更改都会与其他任何属性更改冲突。
// 错误: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 的墓碑会永久保留键。每次 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 具有相同的内容,没有历史开销
并非如此。更高的 clientID 胜出,而不是更晚的时间戳。围绕此进行设计,或使用 y-lwwmap 添加显式时间戳。
数组位置适用于仅追加的数据(日志、聊天)。用户可重新排序的列表需要分数索引。
Y 类型在使用前必须添加到文档中:
// 错误:孤立的 Y.Map
const orphan = new Y.Map();
orphan.set('key', 'value'); // 有效但不同步
// 正确:附加到文档
const attached = doc.getMap('myMap');
attached.set('key', 'value'); // 同步到对等端
Y 类型存储 JSON 可序列化的数据。不能存储函数、类实例或循环引用。
// 这会创建一个新项目,而不是移动的项目
yarray.delete(0);
yarray.push([sameItem]); // 内部是不同的 Y.Map 实例
对“已移动”项目的任何并发编辑都会丢失,因为你删除了原始项目。
Y.js 共享类型(Y.Map、Y.Text、Y.XmlFragment、Y.Array)是实现细节,应保持在类型化 API 之后。当消费者代码穿透抽象层来操作原始共享类型时,就会产生难以更改的耦合。
模式:如果一个模块为了编辑器绑定而返回 Y.js 共享类型(例如,handle.asText() 返回 Y.Text),这是有意的——消费者需要实时的 CRDT 引用。但如果消费者代码正在构造、转换或突变应由所属模块封装的 Y.js 类型,那就是泄漏。
// 错误:消费者通过句柄进行原始 Y.Text 突变
const entry = handle.currentEntry;
if (entry?.type === 'text') {
handle.batch(() => entry.content.insert(entry.content.length, text));
}
// 正确:时间线拥有追加操作
handle.append(text);
// 错误:消费者构造 Y.Maps 来调用内部 CSV 辅助函数
import { parseSheetFromCsv } from '@epicenter/workspace';
const columns = new Y.Map<Y.Map<string>>();
const rows = new Y.Map<Y.Map<string>>();
parseSheetFromCsv(csv, columns, rows);
// 正确:使用句柄的写入方法,它封装了 CSV 解析
handle.write(csv); // 模式感知,内部处理工作表
这些是 Y.js 内部实现泄漏的代码异味指标:
as Y.Map、as Y.Text、as Y.XmlFragment 意味着有人在使用无类型数据并强制其成形。类型化 API 不完整。if (entry.type === 'text') ... else if (entry.type === 'sheet') 意味着消费者知道内部内容模式,而这些模式应由抽象层处理。handle.batch(() => ytext.insert(...)) 意味着消费者正在执行 CRDT 操作,而这些操作应该是句柄上的一个方法。Y.Map<Y.Map<string>> 参数的函数会强制消费者拥有原始 Y.js 引用来调用它们。ydoc.getArray()/ydoc.getMap():消费者代码访问原始 Y.Doc 来读写数据,绕过了 table/kv/timeline API。三个层次,每个都有明确的 Y.js 暴露:
┌──────────────────────────────────────────────────────┐
│ 消费者代码(应用、功能) │
│ • 使用 handle.read()、handle.write()、tables.*.set()│
│ • 可以绑定到来自 as*() 的 Y.Text/Y.XmlFragment │
│ • 绝不构造 Y.js 类型 │
│ • 绝不转换到 Y.js 类型 │
│ • 绝不在原始类型上调用 .insert()/.delete() │
├──────────────────────────────────────────────────────┤
│ 格式桥接器(markdown、工作表转换器) │
│ • 接受 Y.js 类型作为参数(它们是桥接器) │
│ • 在 Y.js ↔ 字符串/JSON 之间转换 │
│ • 靠近所属模块 │
├──────────────────────────────────────────────────────┤
│ 时间线 / 表格 / KV 内部实现 │
│ • 构造和管理 Y.js 共享类型 │
│ • 拥有 Y.Doc 布局(数组键、映射结构) │
│ • 暴露隐藏 CRDT 细节的类型化 API │
└──────────────────────────────────────────────────────┘
审查代码时,请问:“这个消费者能否仅使用类型化 API 完成其工作?” 如果可以,但它却在使用原始 Y.js 类型,那就是一个值得修复的泄漏。
查看文章 docs/articles/yjs-abstraction-leaks-cost-more-than-the-abstraction.md 以获取包含真实示例的完整模式。
console.log(doc.toJSON()); // 整个文档的纯 JSON 表示
// 查看谁会在冲突中胜出
console.log('我的 ID:', doc.clientID);
如果文档意外增长,请检查:
每周安装次数
79
仓库
GitHub 星标数
4.3K
首次出现
2026年1月28日
安全审计
安装于
codex67
gemini-cli66
opencode64
github-copilot63
amp60
kimi-cli60
Related Skills : See
workspace-apifor the workspace abstraction built on Yjs.
Use this pattern when you need to:
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 formattingThe other three (Y.XmlElement, Y.XmlFragment, Y.XmlText) are for rich text editor integrations.
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.
const doc = new Y.Doc();
console.log(doc.clientID); // Random number like 1090160253
From dmonad (Yjs creator):
"The 'winner' is decided by
ydoc.clientIDof the document (which is a generated number). The higher clientID wins."
The actual comparison in source (updates.js#L357):
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).
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.
Problem : Multiple writers updating the same key causes lost writes.
// 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.
// 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;
}
Problem : Drag-and-drop reordering with delete+insert causes duplicates and lost updates.
// 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.
// 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);
}
Problem : Storing entire objects under one key means any property change conflicts with any other.
// 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.
// 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
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:
// 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.
If your architecture uses versioned snapshots, you get free compaction:
// 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
It doesn't. Higher clientID wins, not later timestamp. Design around this or add explicit timestamps with y-lwwmap.
Array position is for append-only data (logs, chat). User-reorderable lists need fractional indexing.
Y types must be added to a document before use:
// 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 types store JSON-serializable data. No functions, no class instances, no circular references.
// 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.
Y.js shared types (Y.Map, Y.Text, Y.XmlFragment, Y.Array) are implementation details that should stay behind typed APIs. When consumer code reaches through an abstraction to manipulate raw shared types, it creates coupling that's hard to change later.
The pattern : If a module returns Y.js shared types for editor binding (e.g., handle.asText() returns Y.Text), that's intentional—the consumer needs the live CRDT reference. But if consumer code is constructing , casting , or mutating Y.js types that the owning module should encapsulate, that's a leak.
// BAD: consumer reaches through handle to do raw Y.Text mutation
const entry = handle.currentEntry;
if (entry?.type === 'text') {
handle.batch(() => entry.content.insert(entry.content.length, text));
}
// GOOD: timeline owns the append operation
handle.append(text);
// BAD: consumer constructs Y.Maps to call an internal CSV helper
import { parseSheetFromCsv } from '@epicenter/workspace';
const columns = new Y.Map<Y.Map<string>>();
const rows = new Y.Map<Y.Map<string>>();
parseSheetFromCsv(csv, columns, rows);
// GOOD: use the handle's write method, which encapsulates CSV parsing
handle.write(csv); // mode-aware, handles sheet internally
These are code smell indicators that Y.js internals are leaking:
as Y.Map, as Y.Text, as Y.XmlFragment outside the owning module means someone is working with untyped data and forcing it into shape. The typed API is incomplete.if (entry.type === 'text') ... else if (entry.type === 'sheet') in consumer code means the consumer knows about internal content modes that the abstraction should handle.handle.batch(() => ytext.insert(...)) means the consumer is doing CRDT operations that should be a method on the handle.Y.Map<Y.Map<string>> parameters on a public API force consumers to have raw Y.js references to call them.ydoc.getArray()/ outside infrastructure: Consumer code accessing the raw Y.Doc to read/write data bypasses the table/kv/timeline APIs.Three layers, each with clear Y.js exposure:
┌──────────────────────────────────────────────────────┐
│ Consumer Code (apps, features) │
│ • Uses handle.read(), handle.write(), tables.*.set()│
│ • MAY bind to Y.Text/Y.XmlFragment from as*() │
│ • NEVER constructs Y.js types │
│ • NEVER casts to Y.js types │
│ • NEVER calls .insert()/.delete() on raw types │
├──────────────────────────────────────────────────────┤
│ Format Bridges (markdown, sheet converters) │
│ • Accepts Y.js types as parameters (they're bridges)│
│ • Converts between Y.js ↔ string/JSON │
│ • Lives close to the owning module │
├──────────────────────────────────────────────────────┤
│ Timeline / Table / KV Internals │
│ • Constructs and manages Y.js shared types │
│ • Owns the Y.Doc layout (array keys, map structure) │
│ • Exposes typed APIs that hide the CRDT details │
└──────────────────────────────────────────────────────┘
When reviewing code, ask: "Could this consumer do its job with only the typed API?" If yes and it's using raw Y.js types instead, that's a leak worth fixing.
See the article docs/articles/yjs-abstraction-leaks-cost-more-than-the-abstraction.md for the full pattern with real examples.
console.log(doc.toJSON()); // Full document as plain JSON
// See who would win a conflict
console.log('My ID:', doc.clientID);
If documents grow unexpectedly, check for:
Weekly Installs
79
Repository
GitHub Stars
4.3K
First Seen
Jan 28, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
codex67
gemini-cli66
opencode64
github-copilot63
amp60
kimi-cli60
Flutter应用架构设计指南:分层结构、数据层实现与最佳实践
4,800 周安装
ydoc.getMap()