前端实时协同编辑与 CRDT 冲突解决实战:从 OT 到 Yjs 底层原理(完整版)

前端实时协同编辑与 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、网络、性能打磨到生产级。

相关推荐
陈随易13 分钟前
神级 Skill,让 AI 用 HTML 代替 Markdown 回复
前端·后端·程序员
eidnxndrdkdns21 分钟前
PE 自校验的三个坑:恒失败、基址变化、以及被漏在外面的检测器
前端
90后的晨仔25 分钟前
解决 Android 构建报错:Unable to delete file .../file-map.txt
android·前端
Yuhano30 分钟前
W4. RAG 让 Agent 拥有"外部记忆"
前端·aigc
用户308169987648533 分钟前
gzip 和 br 压同一份 JSON:7.6MB 压到 175KB,差 3.8 倍
前端
掘金者阿豪43 分钟前
电商视觉要被 Agent 重做了:一个平台跑通主图、详情页、实拍、复刻和视频
前端
爱勇宝1 小时前
《道德经》第 13 章:别让评价系统控制你的心
前端·后端·产品
掘金者阿豪1 小时前
集中式与分布式数据库怎么选?从架构原理到电科金仓的实践思考
前端·后端
墨林陌1 小时前
AI 热点日报(2026-11-06):乌镇峰会闭幕发布全球开源AI倡议,腾讯混元Hy4发布即开源
前端