前端实时协同编辑与 CRDT 冲突解决实战:从 OT 到 Yjs 底层原理(完整版)
引言
实时协同编辑是前端最难的领域之一,难的不是"同步",而是并发冲突下的最终一致性:
- 两人同时在同一位置插入文字,谁在前?
- 一人删除段落,另一人正在其中打字
- 离线编辑后重连,如何合并而不丢内容
- 光标位置如何在并发编辑中保持正确
本文从原理到落地,构建一套可自研的协同编辑体系:
- 协同的三种模型:锁、OT、CRDT
- OT 的核心思想与不可交换性问题
- CRDT 的两大流派:状态型(CvRDT)与操作型(CmRDT)
- Yjs 底层:YATA 算法、Item、双向链表
- 自研迷你 CRDT 序列(可运行)
- 富文本协同:结构化 CRDT 与嵌套
- 光标/选区同步(Awareness)
- 离线编辑与增量同步
- 性能:文档分片、快照、垃圾回收
一、协同编辑的三种模型
1.1 悲观锁
css
用户A锁定段落1 → 编辑 → 释放 → 用户B才能编辑
- 优点:零冲突
- 缺点:体验差,无法实时
1.2 OT(Operational Transformation)
Google Docs 早期方案。核心:对操作做变换,使其可交换。
css
用户A: insert(0, "X")
用户B: insert(0, "Y")
服务端收到 A 先:文档 "X"
B 的操作需要变换:insert(0,"Y") → insert(1,"Y")
结果:"XY"
- 优点:服务端权威、文档小
- 缺点:变换函数复杂、必须中心服务器、N 方并发易错
1.3 CRDT(Conflict-free Replicated Data Type)
无中心、可交换、可离线。核心:让数据结构本身满足交换律。
arduino
每个字符有唯一 ID(clientId + counter)
插入时记录"在谁之后"
合并时按 ID 排序,天然确定
- 优点:去中心、离线友好、P2P 可行
- 缺点:元数据大、需要垃圾回收
结论:现代协同编辑(Figma、Linear、Notion)主流选 CRDT。
二、OT 的不可交换性问题
2.1 经典三方冲突
css
初始: "abc"
A: delete(1) → 删除 'b',期望 "ac"
B: insert(1, "X") → 在 'b' 前插入,期望 "aXbc"
合并顺序A→B: "ac" 上执行 insert(1,"X") → "aXc" ❌(X 跑到 c 前)
合并顺序B→A: "aXbc" 上执行 delete(1) → "aXc" ✅
OT 需要 transform(delete, insert) 函数修正位置。N 方并发时,变换函数需要满足 TP1 性质(任意两操作变换后仍可交换),实现极难。
2.2 CRDT 的解法
给每个字符唯一 ID,插入时声明"在哪个 ID 之后":
less
初始: a(id:1) b(id:2) c(id:3)
A: delete b(id:2)
B: insert X(after id:1)
合并: a(1) → X(after:1) → c(3)
结果: "aXc" ✅ 与顺序无关
关键:位置不再是数字索引,而是相对 ID 的引用。
三、CRDT 两大流派
3.1 状态型(CvRDT)
每个副本维护完整状态,合并函数满足交换/结合/幂等:
typescript
// G-Counter(只增计数器)
type GCounter = { [clientId: string]: number };
function merge(a: GCounter, b: GCounter): GCounter {
const result: GCounter = { ...a };
for (const [id, val] of Object.entries(b)) {
result[id] = Math.max(result[id] ?? 0, val);
}
return result;
}
function value(counter: GCounter): number {
return Object.values(counter).reduce((s, v) => s + v, 0);
}
- 优点:简单、天然收敛
- 缺点:状态随历史增长
3.2 操作型(CmRDT)
只同步操作,要求操作可交换:
lua
type Op =
| { type: 'insert'; id: string; after: string | null; char: string }
| { type: 'delete'; id: string };
- 优点:传输量小
- 缺点:需要可靠投递(因果序)
Yjs 是操作型 + 状态向量的混合体。
四、Yjs 底层:YATA 算法
4.1 核心数据结构
Yjs 的文档本质是双向链表 + 唯一 ID:
yaml
Item {
id: { client: number, clock: number }
left: Item | null
right: Item | null
origin: ID | null // 插入时左侧的 ID
rightOrigin: ID | null // 插入时右侧的 ID
content: string
deleted: boolean
}
4.2 插入规则
当两个客户端在同一位置并发插入:
ini
初始: [A(1,1)]
A 插入 X: origin=(1,1), rightOrigin=null
B 插入 Y: origin=(1,1), rightOrigin=null
YATA 规则:
1. 比较 origin 相同 → 比较 clientId
2. clientId 小的排前面
结果确定:假设 A.client=1, B.client=2,则 [A, X, Y]。
4.3 为什么不需要 transform
因为位置用 ID 引用而非索引 。插入操作携带 origin,无论何时到达,都能找到正确位置。
五、自研迷你 CRDT 序列
5.1 数据结构
kotlin
// crdt/Sequence.ts
export interface ItemID {
client: string;
clock: number;
}
export interface Item {
id: ItemID;
origin: ItemID | null; // 左邻居
rightOrigin: ItemID | null; // 右邻居
content: string;
deleted: boolean;
}
export class CRDTSequence {
private items: Item[] = []; // 简化:用数组存储,按插入顺序
private clock = 0;
private clientId: string;
constructor(clientId: string) {
this.clientId = clientId;
}
private nextId(): ItemID {
return { client: this.clientId, clock: this.clock++ };
}
insert(index: number, content: string): Item {
const left = index > 0 ? this.items[index - 1] : null;
const right = index < this.items.length ? this.items[index] : null;
const item: Item = {
id: this.nextId(),
origin: left?.id ?? null,
rightOrigin: right?.id ?? null,
content,
deleted: false
};
this.items.splice(index, 0, item);
return item;
}
delete(index: number): Item | null {
const item = this.items[index];
if (!item) return null;
item.deleted = true;
return item;
}
toString(): string {
return this.items.filter(i => !i.deleted).map(i => i.content).join('');
}
// 远程操作合并
applyRemote(item: Item): void {
if (this.items.some(i => this.sameId(i.id, item.id))) return;
// 根据 origin 找插入位置
const insertIdx = this.findInsertIndex(item);
this.items.splice(insertIdx, 0, { ...item });
// 更新时钟
if (item.id.client === this.clientId) {
this.clock = Math.max(this.clock, item.id.clock + 1);
}
}
private findInsertIndex(item: Item): number {
if (!item.origin && !item.rightOrigin) {
// 无参照,按 clientId 排序
return this.items.findIndex(i => i.id.client > item.id.client);
}
if (item.origin) {
const originIdx = this.items.findIndex(i => this.sameId(i.id, item.origin!));
if (originIdx >= 0) {
// 从 origin 右侧开始找,处理并发插入的排序
let idx = originIdx + 1;
while (idx < this.items.length) {
const candidate = this.items[idx];
if (candidate.origin && this.sameId(candidate.origin, item.origin)) {
// 同 origin,按 clientId 排序
if (candidate.id.client > item.id.client) break;
} else if (candidate.origin && !this.sameId(candidate.origin, item.origin)) {
break;
}
idx++;
}
return idx;
}
}
return this.items.length;
}
private sameId(a: ItemID, b: ItemID): boolean {
return a.client === b.client && a.clock === b.clock;
}
}
5.2 测试并发收敛
ini
// crdt/__tests__/sequence.test.ts
import { CRDTSequence } from '../Sequence';
test('并发插入收敛', () => {
const a = new CRDTSequence('A');
const b = new CRDTSequence('B');
a.insert(0, 'hello');
b.applyRemote(a.items[0]);
const itemA = a.insert(5, ' world');
const itemB = b.insert(5, '!');
// 交叉同步
a.applyRemote(itemB);
b.applyRemote(itemA);
expect(a.toString()).toBe(b.toString());
});
test('并发删除与插入', () => {
const a = new CRDTSequence('A');
const b = new CRDTSequence('B');
a.insert(0, 'abc');
const base = a.items[0];
b.applyRemote(base);
const del = a.delete(1);
const ins = b.insert(1, 'X');
b.applyRemote({ ...del!, deleted: true });
a.applyRemote(ins);
expect(a.toString()).toBe('aXc');
expect(b.toString()).toBe('aXc');
});
核心验证:任意操作顺序,最终字符串一致。
六、富文本协同(结构化 CRDT)
纯文本用序列 CRDT,富文本需要嵌套结构:
段落
├─ 文本(带格式标记)
├─ 图片
└─ 列表
6.1 用 Yjs 的 Y.XmlFragment
ini
import * as Y from 'yjs';
const doc = new Y.Doc();
const fragment = doc.getXmlFragment('prosemirror');
// 段落节点
const para = new Y.XmlElement('paragraph');
const text = new Y.XmlText();
text.insert(0, 'Hello', { bold: true });
para.insert(0, [text]);
fragment.insert(0, [para]);
6.2 与 ProseMirror 集成
ini
import { ySyncPlugin, yCursorPlugin } from 'y-prosemirror';
const plugins = [
ySyncPlugin(fragment),
yCursorPlugin(awareness)
];
ProseMirror 的文档树与 Y.XmlFragment 双向同步,冲突由 Yjs 解决。
七、光标与选区同步(Awareness)
Awareness 是临时状态,不进文档历史:
javascript
// awareness.ts
import { Awareness } from 'y-protocols/awareness';
const awareness = new Awareness(doc);
awareness.setLocalStateField('user', {
name: 'Alice',
color: '#ff0000'
});
awareness.setLocalStateField('cursor', {
anchor: 10,
head: 15
});
awareness.on('update', ({ added, updated, removed }) => {
// 更新远程光标 UI
const states = awareness.getStates();
renderCursors(states);
});
注意事项:
- 光标位置是索引,文本变化时需要映射
- Yjs 的 RelativePosition 可让位置"跟随"内容移动
ini
import { createRelativePositionFromTypeIndex, createAbsolutePositionFromRelativePosition } from 'yjs';
const relPos = createRelativePositionFromTypeIndex(ytext, 10);
// 文本变化后
const absPos = createAbsolutePositionFromRelativePosition(relPos, doc);
八、离线编辑与增量同步
8.1 状态向量(State Vector)
Yjs 用状态向量描述"我知道哪些操作":
ini
const sv = Y.encodeStateVector(doc);
// Uint8Array: 每个 client 的最大 clock
// 只请求缺失的操作
const update = Y.encodeStateAsUpdate(remoteDoc, sv);
Y.applyUpdate(localDoc, update);
8.2 离线缓存
csharp
// 持久化到 IndexedDB
const update = Y.encodeStateAsUpdate(doc);
await idb.set('doc-update', update);
// 恢复
const saved = await idb.get('doc-update');
if (saved) Y.applyUpdate(doc, saved);
8.3 同步协议
typescript
// sync.ts
class SyncProvider {
private doc: Y.Doc;
private ws: WebSocket;
private synced = false;
constructor(doc: Y.Doc, url: string) {
this.doc = doc;
this.ws = new WebSocket(url);
this.ws.onopen = () => {
// 发送本地状态向量
this.ws.send(JSON.stringify({
type: 'sync-step-1',
sv: Array.from(Y.encodeStateVector(doc))
}));
};
this.ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'sync-step-2') {
// 服务端返回缺失操作
Y.applyUpdate(doc, new Uint8Array(msg.update));
this.synced = true;
// 发送本地增量
this.ws.send(JSON.stringify({
type: 'update',
update: Array.from(Y.encodeStateAsUpdate(doc))
}));
} else if (msg.type === 'update') {
Y.applyUpdate(doc, new Uint8Array(msg.update));
}
};
// 本地变更广播
doc.on('update', (update: Uint8Array) => {
if (this.synced) {
this.ws.send(JSON.stringify({
type: 'update',
update: Array.from(update)
}));
}
});
}
}
九、性能优化
9.1 文档分片
大文档拆成多个 Y.Doc:
ini
const docMeta = new Y.Doc();
const docBody = new Y.Doc();
9.2 快照与垃圾回收
Yjs 的已删除 Item 会保留(用于因果追踪)。定期 Y.encodeStateAsUpdate 后重建文档可回收。
ini
// 压缩:合并历史
const snapshot = Y.snapshot(doc);
const newDoc = Y.createDocFromSnapshot(doc, snapshot);
9.3 批量合并
javascript
doc.transact(() => {
// 多个操作合并为一个 update
ytext.insert(0, 'a');
ytext.insert(1, 'b');
});
9.4 大文档虚拟化
编辑器只渲染视口内节点,CRDT 层保持全量。
十、实战:协同白板(Yjs + Canvas)
typescript
// board.ts
import * as Y from 'yjs';
const doc = new Y.Doc();
const shapes = doc.getMap('shapes'); // id → { type, x, y, w, h, color }
// 添加图形
function addRect(id: string, rect: any) {
shapes.set(id, { type: 'rect', ...rect });
}
// 监听变化重绘
shapes.observe(() => {
redraw();
});
// 拖拽移动(高频操作节流)
let lastUpdate = 0;
function onDrag(id: string, x: number, y: number) {
const now = Date.now();
if (now - lastUpdate < 16) return; // 60fps
lastUpdate = now;
doc.transact(() => {
const shape = shapes.get(id);
if (shape) shapes.set(id, { ...shape, x, y });
});
}
核心:Map 的并发 set 由 Yjs 按 clientId 决胜,天然收敛。
十一、协同编辑的常见坑
| 坑 | 原因 | 解法 |
|---|---|---|
| 光标乱跳 | 用索引而非相对位置 | RelativePosition |
| 离线后重复内容 | 未去重 | 状态向量同步 |
| 内存暴涨 | 删除项未回收 | 定期重建文档 |
| 富文本格式丢失 | 纯文本 CRDT | 结构化 CRDT |
| 撤销影响他人 | 全局撤销栈 | 按用户分栈撤销 |
| 初始化慢 | 全量加载 | 增量 + 快照 |
按用户撤销
javascript
import { UndoManager } from 'yjs';
const undoManager = new UndoManager(ytext, {
trackedOrigins: new Set([userId])
});
undoManager.undo(); // 只撤销当前用户的操作
十二、工程化 Checklist
- ✅ 选型明确:OT vs CRDT(现代选 CRDT)
- ✅ 唯一 ID 用 clientId + clock
- ✅ 位置用相对引用,不用索引
- ✅ 光标用 RelativePosition
- ✅ 离线缓存 + 状态向量增量同步
- ✅ 文档分片,避免单文档过大
- ✅ 定期快照回收历史
- ✅ 撤销按用户隔离
- ✅ 并发收敛有自动化测试
- ✅ 网络层有重连与幂等
十三、总结
实时协同编辑的本质:
把"冲突"从运行时问题,转化为数据结构问题。
- OT:用变换函数消除冲突,依赖中心服务器
- CRDT:用唯一 ID + 相对引用让操作天然可交换,去中心、可离线
Yjs 的 YATA 算法是工程化的最佳实践:双向链表 + origin 引用,让插入在任何到达顺序下都收敛。
从纯文本到富文本、从编辑到白板、从在线到离线,CRDT 提供了一套数学上保证最终一致的底座。前端要做的,是把这套底座与编辑器、UI、网络、性能打磨到生产级。