2026 年,Figma 被 Adobe 收购后市值飙升至 500 亿美元,其核心技术支柱之一就是基于 CRDT(Conflict-free Replicated Data Type,无冲突复制数据类型)的实时协作引擎。与此同时,Linear、Notion、Obsidian 等明星产品纷纷拥抱「本地优先(Local-First)」架构,让用户在断网状态下依然能流畅工作,联网后自动同步。对于开发者而言,理解 CRDT 已经不再是学术兴趣,而是构建下一代协作应用的核心能力。
🧬 一、CRDT 核心原理与数据结构
什么是 CRDT?
传统的协同编辑方案(如 OT,Operational Transformation)需要一个中心服务器来仲裁冲突。CRDT 则是一种分布式数据结构,它从数学上保证:无论操作以什么顺序到达,最终所有节点的状态都会收敛一致,完全不需要中心仲裁。
这听起来像魔法,但原理其实很直观。CRDT 有两种主要类型:
- State-based CRDT(CvRDT):节点间传递完整状态,通过一个 merge 函数合并
- Operation-based CRDT(CmRDT):节点间传递操作,操作必须满足交换律(commutative)
💡 **提示:**大多数实际应用采用的是 Operation-based CRDT 的变体,因为它传输的数据量更小,但需要一个可靠的消息广播层(如 WebSocket 或 WebRTC)。
三种基础 CRDT 数据结构
理解 CRDT,从三种基础结构开始:
1. G-Counter(只增计数器)
每个节点维护自己的计数,读取时取总和。永远不会冲突:
// G-Counter 实现:每个节点独立计数,读取时求和
class GCounter {
private counts: Map<string, number> = new Map()
constructor(private nodeId: string) {}
increment(): void {
const current = this.counts.get(this.nodeId) || 0
this.counts.set(this.nodeId, current + 1)
}
value(): number {
let sum = 0
for (const v of this.counts.values()) {
sum += v
}
return sum
}
merge(other: GCounter): void {
for (const [nodeId, count] of other.counts) {
const local = this.counts.get(nodeId) || 0
this.counts.set(nodeId, Math.max(local, count))
}
}
}
// 使用示例
const nodeA = new GCounter('A')
const nodeB = new GCounter('B')
nodeA.increment()
nodeA.increment()
nodeB.increment()
// 两边分别增加后,merge 即可收敛
nodeA.merge(nodeB)
console.log(nodeA.value()) // 3 — 无论 merge 顺序如何,结果一致
2. LWW-Register(最后写入胜出寄存器)
用时间戳解决冲突,时间戳最大的值胜出:
// LWW-Register:用逻辑时钟解决单值冲突
class LWWRegister<T> {
private value: T
private timestamp: number
constructor(initial: T, timestamp: number = 0) {
this.value = initial
this.timestamp = timestamp
}
set(newValue: T, timestamp: number): void {
if (timestamp > this.timestamp) {
this.value = newValue
this.timestamp = timestamp
}
}
get(): T {
return this.value
}
merge(other: LWWRegister<T>): void {
if (other.timestamp > this.timestamp) {
this.value = other.value
this.timestamp = other.timestamp
}
}
}
3. OR-Set(可观察移除集合)
这是最实用的 CRDT 之一——支持「添加」和「移除」操作,且不会丢失并发添加的元素:
// OR-Set 简化实现:每个元素带唯一 tag,移除只影响已知 tag
type Tag = { nodeId: string; counter: number }
class ORSet<T> {
private elements: Map<T, Set<string>> = new Map()
private tombstones: Map<T, Set<string>> = new Map()
private counter = 0
private nodeId: string
constructor(nodeId: string) {
this.nodeId = nodeId
}
add(element: T): void {
const tag = `${this.nodeId}:${++this.counter}`
if (!this.elements.has(element)) {
this.elements.set(element, new Set())
}
this.elements.get(element)!.add(tag)
}
remove(element: T): void {
const tags = this.elements.get(element)
if (tags) {
if (!this.tombstones.has(element)) {
this.tombstones.set(element, new Set())
}
for (const tag of tags) {
this.tombstones.get(element)!.add(tag)
}
this.elements.delete(element)
}
}
lookup(element: T): boolean {
const tags = this.elements.get(element)
if (!tags) return false
const tombstones = this.tombstones.get(element)
if (!tombstones) return true
// 只要有一个 tag 不在墓碑中,元素就存在
for (const tag of tags) {
if (!tombstones.has(tag)) return true
}
return false
}
merge(other: ORSet<T>): void {
for (const [el, tags] of other.elements) {
if (!this.elements.has(el)) this.elements.set(el, new Set())
for (const tag of tags) this.elements.get(el)!.add(tag)
}
for (const [el, tags] of other.tombstones) {
if (!this.tombstones.has(el)) this.tombstones.set(el, new Set())
for (const tag of tags) this.tombstones.get(el)!.add(tag)
}
}
values(): T[] {
const result: T[] = []
for (const el of this.elements.keys()) {
if (this.lookup(el)) result.push(el)
}
return result
}
}
📌 **记住:**CRDT 的核心数学性质是「交换律」和「幂等性」。merge(A, B) === merge(B, A),且 merge(A, A) === A。这两个性质保证了最终一致性。
⚔️ 二、Yjs vs Automerge 深度对比
实际项目中,自己从零实现 CRDT 是不现实的。两个最成熟的开源库是 Yjs 和 Automerge。以下是基于 2025-2026 年最新版本的深度对比:
| 维度 | Yjs (v13+) | Automerge (v2+) |
|---|---|---|
| 核心算法 | YATA (Yet Another Transformation Approach) | RGA + 自定义路径复制 |
| 包体积 | ~60KB (yjs) + ~20KB (y-protocols) | ~150KB (automerge + wasm) |
| 文档内存占用 | 极低,共享结构体(共享子文档) | 中等,完整 CRDT 树 |
| 编辑性能 | 极快,O(log n) 操作 | 快,O(log n) 操作 |
| 同步协议 | y-protocols (WebSocket/WebRTC) | 自带 sync 协议 |
| 编辑器集成 | 原生支持 ProseMirror/CodeMirror/Monaco | 需要手动桥接 |
| 持久化 | y-indexeddb / 自定义 | 自带 Automerge.save() 二进制格式 |
| 生态成熟度 | ⭐⭐⭐⭐⭐ 最成熟 | ⭐⭐⭐ 快速成长 |
| 历史追踪 | 基础(需要手动记录) | 内置 Automerge.getHistory() |
| 二进制数据支持 | 通过 Y.Array 扩展 | 原生 Uint8Array 支持 |
性能实测数据
在 10000 次连续字符插入的基准测试中(单线程,Node.js v20):
| 指标 | Yjs | Automerge |
|---|---|---|
| 插入耗时 | ~45ms | ~120ms |
| 合并两个文档 | ~8ms | ~25ms |
| 序列化大小 (10K ops) | ~12KB | ~35KB |
| 内存峰值 | ~15MB | ~28MB |
⚡ **关键结论:**如果场景是文本协同编辑,Yjs 性能和生态全面碾压。如果需要复杂的结构化数据(如嵌套对象、数组)且看重版本历史,Automerge 更合适。
实战:用 Yjs 构建协同编辑器
# 安装依赖
npm install yjs y-websocket y-prosemirror
npm install @tiptap/core @tiptap/starter-kit
// collaborative-editor.ts — 基于 Yjs + Tiptap 的协同编辑器核心代码
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { Tiptap } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import Collaboration from '@tiptap/extension-collaboration'
// 1. 创建 Yjs 文档
const ydoc = new Y.Doc()
// 2. 连接 WebSocket 协同服务
const wsProvider = new WebsocketProvider(
'wss://your-collab-server.com',
'document-room-id',
ydoc
)
// 3. 监听连接状态
wsProvider.on('status', (event: { status: string }) => {
console.log('连接状态:', event.status) // 'connected' | 'disconnected'
})
// 4. 本地持久化(IndexedDB)
import { IndexeddbPersistence } from 'y-indexeddb'
const indexeddbProvider = new IndexeddbPersistence('doc-room-id', ydoc)
indexeddbProvider.on('synced', () => {
console.log('本地数据加载完成,可离线使用')
})
// 5. 初始化 Tiptap 编辑器
const editor = new Tiptap({
element: document.querySelector('#editor')!,
extensions: [
StarterKit.configure({
history: false, // 禁用内置历史,使用 Yjs 的
}),
Collaboration.configure({
document: ydoc,
field: 'content',
}),
],
})
// 6. 监听远程变更
ydoc.on('update', (update: Uint8Array, origin: any) => {
if (origin !== 'local') {
console.log('收到远程变更,字节数:', update.length)
}
})
// 7. 断网 → 离线编辑 → 联网自动同步
// Yjs + y-indexeddb 天然支持,无需额外代码
⚠️ **警告:**y-websocket 的默认服务端实现(y-websocket/bin)仅适用于开发环境。生产环境请使用 Hocuspocus、Liveblocks 或自建的 WebSocket 服务。
🏗️ 三、Local-First 架构设计实战
架构核心原则
Local-First 不只是「离线可用」。Martin Kleppmann 等人在 2019 年的论文中定义了七个理想属性:
- ✅ 高速响应:读写操作走本地,无需网络延迟
- ✅ 多设备同步:通过 CRDT 实现最终一致性
- ✅ 离线支持:断网后继续工作
- ✅ 协作能力:多人实时编辑同一文档
- ✅ 数据持久化:数据存在用户设备,不依赖云服务
- ✅ 安全性:端到端加密,服务端看不到明文
- ✅ 用户掌控数据:用户可以随时导出和迁移
完整数据流设计
// local-first-store.ts — 一个简洁的 Local-First 状态管理方案
import * as Y from 'yjs'
import { IndexeddbPersistence } from 'y-indexeddb'
import { WebsocketProvider } from 'y-websocket'
interface LocalFirstConfig {
docId: string
wsUrl: string
onSyncStatus?: (status: 'offline' | 'syncing' | 'synced') => void
}
class LocalFirstStore {
public doc: Y.Doc
private idb: IndexeddbPersistence
private ws: WebsocketProvider
private syncStatus: 'offline' | 'syncing' | 'synced' = 'offline'
constructor(config: LocalFirstConfig) {
this.doc = new Y.Doc()
// 本地层:IndexedDB 持久化
this.idb = new IndexeddbPersistence(config.docId, this.doc)
// 网络层:WebSocket 同步
this.ws = new WebsocketProvider(config.wsUrl, config.docId, this.doc)
// 状态追踪
this.ws.on('status', ({ status }: { status: string }) => {
this.syncStatus = status === 'connected' ? 'syncing' : 'offline'
config.onSyncStatus?.(this.syncStatus)
})
// 连接后等待首次同步完成
this.ws.on('sync', (isSynced: boolean) => {
if (isSynced) {
this.syncStatus = 'synced'
config.onSyncStatus?.('synced')
}
})
// 页面关闭前确保数据保存
window.addEventListener('beforeunload', () => {
this.idb.destroy()
this.ws.destroy()
})
}
// 获取共享文本
getText(name: string): Y.Text {
return this.doc.getText(name)
}
// 获取共享数组
getArray<T>(name: string): Y.Array<T> {
return this.doc.getArray<T>(name)
}
// 获取共享 Map
getMap<T>(name: string): Y.Map<T> {
return this.doc.getMap<T>(name)
}
// 获取文档大小(用于监控)
getDocSize(): number {
return Y.encodeStateAsUpdate(this.doc).byteLength
}
}
// 使用示例
const store = new LocalFirstStore({
docId: 'my-project-board',
wsUrl: 'wss://collab.example.com',
onSyncStatus: (status) => {
document.getElementById('status')!.textContent =
{ offline: '🔴 离线', syncing: '🟡 同步中', synced: '🟢 已同步' }[status]
},
})
// 创建看板数据结构
const columns = store.getArray<{ id: string; title: string }>('columns')
const cards = store.getMap<Y.Array<{ id: string; text: string; assignee: string }>>('cards')
// 所有操作都是 CRDT 安全的,多端自动同步
避坑指南:Local-First 开发常见陷阱
🔴 坑 1:文档膨胀
CRDT 会为每个元素保留元数据(节点 ID、时间戳等)。经过数万次编辑后,文档体积可能膨胀到原始数据的 5-10 倍。
解决方案:
// 定期压缩文档(删除已合并的墓碑数据)
function compactDoc(ydoc: Y.Doc): Uint8Array {
const stateVector = Y.encodeStateVector(ydoc)
const fullState = Y.encodeStateAsUpdate(ydoc)
// 创建新文档,只导入最新状态(不含历史元数据)
const compacted = new Y.Doc()
Y.applyUpdate(compacted, fullState)
return Y.encodeStateAsUpdate(compacted)
}
🔴 坑 2:ID 生成冲突
在离线环境下,Date.now() 和 Math.random() 在多设备上可能产生碰撞。必须使用确定性 ID:
// ✅ 推荐:使用 nanoid 或 ULID,带设备前缀避免碰撞
import { nanoid } from 'nanoid'
const DEVICE_PREFIX = crypto.randomUUID().slice(0, 8) // 设备唯一前缀
function generateId(): string {
return `${DEVICE_PREFIX}-${nanoid(12)}`
}
// ❌ 避免:纯时间戳或随机数
// const id = Date.now().toString() // 多设备可能碰撞
// const id = Math.random().toString() // 概率上会碰撞
🔴 坑 3:权限控制缺失
CRDT 天然是「所有人可以修改一切」的模型。如果你需要字段级权限,必须在应用层实现:
// 应用层权限守卫:在 CRDT 操作之上加一层权限检查
class PermissionGuard {
private permissions: Map<string, string[]> = new Map()
setFieldPermission(field: string, allowedUsers: string[]): void {
this.permissions.set(field, allowedUsers)
}
canEdit(userId: string, field: string): boolean {
const allowed = this.permissions.get(field)
return !allowed || allowed.includes(userId)
}
// 包装 Y.Map 的 set 方法,只在有权限时执行
guardMap<T>(userId: string, ymap: Y.Map<T>): Y.Map<T> {
const originalSet = ymap.set.bind(ymap)
ymap.set = (key: string, value: T) => {
if (this.canEdit(userId, key)) {
return originalSet(key, value)
}
console.warn(`用户 ${userId} 无权修改字段 ${key}`)
return ymap
}
return ymap
}
}
💡 **提示:**如果你的应用需要严格的权限控制和审计日志,可以考虑「CRDT + 中心服务器验证」的混合架构。服务器作为权威节点验证操作合法性,客户端仍然使用 CRDT 做本地编辑和冲突解决。
📐 四、选型决策与实战建议
什么时候该用 CRDT?
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 文档协同编辑(富文本/Markdown) | ✅ Yjs + Tiptap/ProseMirror | 生态成熟,性能最优 |
| 看板/表格等结构化数据 | ✅ Yjs 或 Automerge | 结构化 CRDT 天然适配 |
| 简单的表单字段同步 | ❌ LWW-Register 或 Last-Write-Wins | CRDT 过于复杂 |
| 需要严格因果一致性 | ❌ 需要额外逻辑层 | CRDT 只保证最终一致 |
| 低带宽 IoT 设备同步 | ⚠️ 考虑 CRDT + 压缩 | 注意文档膨胀问题 |
| 游戏状态同步 | ❌ 用帧同步或状态同步库 | CRDT 延迟不可控 |
技术栈推荐
入门级方案(快速上手):
- Yjs + y-websocket + y-indexeddb + Tiptap
- 适合:MVP、小团队协作工具
生产级方案(高可用):
- Yjs + Hocuspocus(WebSocket 服务)+ PostgreSQL 持久化
- 适合:SaaS 产品、企业协作工具
全栈方案(数据主权):
- Automerge + 自建 sync 服务 + E2E 加密
- 适合:注重隐私的应用、去中心化场景
⚡ **关键结论:**2026 年,Yjs 仍然是文本协作领域的首选,但 Local-First 架构的价值远不止协同编辑。它让用户真正拥有数据、让应用在任何网络条件下都能工作——这才是「以用户为中心」的软件该有的样子。
🔧 相关工具推荐
| 工具 | 用途 | 地址 |
|---|---|---|
| Yjs | CRDT 框架,性能最佳 | yjs.dev |
| Automerge | CRDT 框架,功能丰富 | automerge.org |
| Hocuspocus | 生产级 Yjs WebSocket 服务 | tiptap.dev/hocuspocus |
| Liveblocks | 托管式实时协作基础设施 | liveblocks.io |
| ElectricSQL | Local-First PostgreSQL 同步 | electricsql.com |
| PowerSync | 移动端 Local-First 同步 | powersync.com |
| Zero | Rocicorp 出品的 Local-First 同步引擎 | zerosync.dev |
Local-First 不是银弹,但它代表了一种对用户更友好的软件哲学。当你的用户在飞机上、在地铁里、在网络不稳定的偏远地区依然能顺畅工作时,他们会感谢你选择了这条技术路线。