协作编辑的核心挑战
当多个用户同时编辑同一文档时,如何确保每个人看到的内容一致?这是协作编辑的核心问题。
| 挑战 | 说明 | 解决方案 |
|---|---|---|
| 并发冲突 | 多人同时修改同一位置 | OT/CRDT 算法 |
| 网络延迟 | 操作到达顺序不确定 | 操作变换/向量时钟 |
| 意图保留 | 用户的编辑意图不能丢失 | 智能合并策略 |
| 状态同步 | 所有客户端最终一致 | 确定性算法 |
两种主流算法
OT(操作变换)
OT 是 Google Docs 等产品使用的经典方案。核心思想是:当本地操作与远程操作冲突时,对操作进行变换,使其能正确应用。
// 操作定义
interface Operation {
type: 'insert' | 'delete'
position: number
char?: string // insert 时
count?: number // delete 时
}
// 操作变换函数
function transform(op1: Operation, op2: Operation): Operation {
// op2 是已执行的远程操作,op1 需要相应调整
if (op1.type === 'insert' && op2.type === 'insert') {
if (op1.position <= op2.position) {
return op1 // 位置在前,无需调整
} else {
return { ...op1, position: op1.position + 1 } // 位置后移
}
}
if (op1.type === 'insert' && op2.type === 'delete') {
if (op1.position <= op2.position) {
return op1
} else {
return { ...op1, position: op1.position - op2.count }
}
}
// 其他情况的变换逻辑...
}
关键说明: OT 的优点是操作简洁,但变换函数的正确性证明非常复杂,特别是多人并发场景。
CRDT(无冲突复制数据类型)
CRDT 是一种数据结构设计,保证任意顺序的操作合并后结果一致,无需中心服务器协调。
// 基于位置的 CRDT 字符表示
interface CRDTChar {
id: string // 全局唯一ID
char: string
position: number[] // 分数位置,支持无限细分
author: string
deleted: boolean // 墓碑删除
}
// 生成唯一位置
function generatePosition(
before: number[],
after: number[]
): number[] {
const position: number[] = []
for (let i = 0; ; i++) {
const low = before[i] ?? 0
const high = after[i] ?? Number.MAX_SAFE_INTEGER
if (high - low > 1) {
position.push(Math.floor((low + high) / 2))
break
} else {
position.push(low)
}
}
return position
}
| 对比 | OT | CRDT |
|---|---|---|
| 复杂度 | 变换逻辑复杂 | 数据结构复杂 |
| 服务器依赖 | 需要中心协调 | 可完全去中心化 |
| 空间开销 | 较小 | 较大(需要唯一ID) |
| 代表产品 | Google Docs | Figma, Yjs |
通信层实现
WebSocket 连接管理
class CollaborationClient {
private socket: WebSocket
private pendingOps: Operation[] = []
private version = 0
connect(documentId: string) {
this.socket = new WebSocket(`wss://api.example.com/collab/${documentId}`)
this.socket.onmessage = (event) => {
const message = JSON.parse(event.data)
this.handleMessage(message)
}
this.socket.onclose = () => {
// 自动重连
setTimeout(() => this.connect(documentId), 1000)
}
}
sendOperation(op: Operation) {
this.pendingOps.push(op)
if (this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({
type: 'operation',
operation: op,
baseVersion: this.version
}))
}
}
private handleMessage(message: any) {
switch (message.type) {
case 'operation':
// 变换并应用远程操作
this.applyRemoteOperation(message.operation)
break
case 'ack':
// 确认本地操作
this.pendingOps.shift()
this.version = message.version
break
case 'sync':
// 完整文档同步
this.syncDocument(message.document)
break
}
}
}
光标位置同步
协作编辑还需要显示其他用户的光标位置:
interface UserCursor {
userId: string
userName: string
color: string
position: number
selection?: { start: number; end: number }
}
// 广播光标位置变化
function broadcastCursor(position: number, selection?: Selection) {
socket.send(JSON.stringify({
type: 'cursor',
position,
selection: selection ? {
start: selection.anchorOffset,
end: selection.focusOffset
} : undefined
}))
}
// 渲染其他用户光标
function renderRemoteCursors(cursors: UserCursor[]) {
cursors.forEach(cursor => {
const cursorEl = document.createElement('div')
cursorEl.className = 'remote-cursor'
cursorEl.style.borderLeftColor = cursor.color
cursorEl.style.left = `${calculateCursorPosition(cursor.position)}px`
const label = document.createElement('span')
label.textContent = cursor.userName
label.style.backgroundColor = cursor.color
cursorEl.appendChild(label)
editorContainer.appendChild(cursorEl)
})
}
使用现成方案
Yjs - 流行的 CRDT 库
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
// 创建共享文档
const doc = new Y.Doc()
const text = doc.getText('content')
// 连接协作服务器
const provider = new WebsocketProvider(
'wss://demos.yjs.dev',
'my-document-room',
doc
)
// 监听变化
text.observe(event => {
console.log('文档变化:', event.changes)
})
// 本地编辑
text.insert(0, 'Hello, ')
text.insert(7, 'World!')
// 与编辑器集成(如 Tiptap)
import { Editor } from '@tiptap/core'
import Collaboration from '@tiptap/extension-collaboration'
const editor = new Editor({
extensions: [
Collaboration.configure({
document: doc
})
]
})
离线支持与冲突解决
// 离线操作队列
class OfflineQueue {
private queue: Operation[] = []
add(op: Operation) {
this.queue.push(op)
this.persist()
}
private persist() {
localStorage.setItem('offline-ops', JSON.stringify(this.queue))
}
async sync() {
const ops = [...this.queue]
this.queue = []
for (const op of ops) {
try {
await sendToServer(op)
} catch (error) {
this.queue.unshift(op) // 失败的放回队列
throw error
}
}
this.persist()
}
}
最佳实践
- 选择合适的算法:小团队协作用 OT 足够,大规模用 CRDT
- 优化更新频率:合并快速连续的操作,减少网络负载
- 实现 Undo/Redo:协作环境下的撤销逻辑更复杂
- 处理网络故障:支持离线编辑和重连同步
- 使用成熟库:除非有特殊需求,优先使用 Yjs、Automerge 等
实时协作是复杂的工程问题,建议在理解原理的基础上,优先使用经过验证的开源方案。


