实时协作编辑实现原理与技术方案

HTMLPAGE 团队
20分钟 分钟阅读

深入讲解多人实时协作编辑的核心技术原理,包括 OT 算法、CRDT 数据结构、冲突解决策略和 WebSocket 通信实现。

#实时协作 #OT算法 #CRDT #WebSocket #协同编辑

协作编辑的核心挑战

当多个用户同时编辑同一文档时,如何确保每个人看到的内容一致?这是协作编辑的核心问题。

挑战说明解决方案
并发冲突多人同时修改同一位置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
}
对比OTCRDT
复杂度变换逻辑复杂数据结构复杂
服务器依赖需要中心协调可完全去中心化
空间开销较小较大(需要唯一ID)
代表产品Google DocsFigma, 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()
  }
}

最佳实践

  1. 选择合适的算法:小团队协作用 OT 足够,大规模用 CRDT
  2. 优化更新频率:合并快速连续的操作,减少网络负载
  3. 实现 Undo/Redo:协作环境下的撤销逻辑更复杂
  4. 处理网络故障:支持离线编辑和重连同步
  5. 使用成熟库:除非有特殊需求,优先使用 Yjs、Automerge 等

实时协作是复杂的工程问题,建议在理解原理的基础上,优先使用经过验证的开源方案。