从零实现H5 表格协同编辑:Yjs + WebSocket 实战

文章目录


前言

最近在做一个小程序项目,需要实现类似 Excel 的协同编辑功能。多人可以同时编辑同一张表格,实时看到彼此的修改。听起来很酷,但实现起来有不少坑。这篇文章记录了我从零到一的实现过程,以及踩过的坑和解决方案。

技术栈:

  • uniapp - 跨平台小程序框架
  • Vue 3 + TypeScript - 前端框架
  • Yjs - CRDT 协同编辑库
  • WebSocket - 实时通信
  • Node.js - WebSocket 服务器

效果

一、需求分析

核心功能

  1. 多人同时编辑同一张表格
  2. 实时同步所有用户的修改
  3. 支持基本的表格操作:
    • 单元格编辑
    • 插入/删除行列
    • 合并/取消合并单元格
    • 范围选择

技术挑战

  • 冲突解决:两个用户同时编辑怎么办?
  • 实时性:如何保证低延迟?
  • 数据一致性:如何保证所有客户端数据一致?
  • 离线支持:断网后重连如何同步?

二、技术选型

为什么选择 Yjs?

Yjs 是一个基于 CRDT (Conflict-free Replicated Data Type) 算法的协同编辑库。它的核心优势:

  1. 自动冲突解决:不需要手动处理冲突
  2. 最终一致性:保证所有客户端最终数据一致
  3. 离线支持:可以离线编辑,连接后自动同步
  4. 高性能:增量更新,只传输变化的部分

为什么用 WebSocket?

  • 双向通信:服务器可以主动推送
  • 低延迟:比 HTTP 轮询快得多
  • 实时性:适合协同编辑场景

三、架构设计

整体架构

js 复制代码
┌─────────────┐         ┌─────────────┐         ┌─────────────┐
│  客户端 A   │         │  WebSocket  │         │  客户端 B   │
│             │         │   服务器    │         │             │
│   Y.Doc     │◄───────►│   中转      │◄───────►│   Y.Doc     │
│   ↓         │         │             │         │   ↓         │
│  UI 组件    │         └─────────────┘         │  UI 组件    │
└─────────────┘                                 └─────────────┘

数据结构设计

使用 Yjs 的数据结构来表示表格:

typescript 复制代码
Y.Doc
  └─ Y.Map('table')
      ├─ metadata (Y.Map)          // 元数据
      │   ├─ title: string
      │   ├─ rowCount: number
      │   └─ colCount: number
      ├─ rows (Y.Array)            // 行数组
      │   └─ Y.Map (每一行)
      │       ├─ rowIndex: number
      │       ├─ height: number
      │       └─ cells (Y.Map)     // 单元格 Map
      │           └─ Y.Map (每个单元格)
      │               ├─ text: string
      │               ├─ formula?: string
      │               └─ format?: object
      └─ mergedCells (Y.Array)     // 合并单元格信息
          └─ Y.Map
              ├─ startRow: number
              ├─ startCol: number
              ├─ endRow: number
              └─ endCol: number

四、核心实现

1. Yjs 表格管理器

创建一个 YjsTableManager 类来封装 Yjs 操作:

typescript 复制代码
export class YjsTableManager {
  private documents: Map<string, Y.Doc> = new Map()

  // 获取或创建文档
  getDocument(sheetId: string): Y.Doc {
    let doc = this.documents.get(sheetId)
    if (!doc) {
      doc = new Y.Doc()
      this.documents.set(sheetId, doc)
      this.initializeTableStructure(doc.getMap('table'))
    }
    return doc
  }

  // 设置单元格值
  setCellValue(
    tableMap: Y.Map<any>,
    row: number,
    col: number,
    value: CellValue
  ): void {
    const doc = tableMap.doc
    
    // 使用事务保证原子性
    doc.transact(() => {
      const rows = tableMap.get('rows') as Y.Array<Y.Map<any>>
      
      // 确保行存在
      while (rows.length <= row) {
        const newRow = new Y.Map()
        newRow.set('rowIndex', rows.length)
        newRow.set('cells', new Y.Map())
        rows.push([newRow])
      }
      
      // 获取单元格并更新
      const rowMap = rows.get(row)
      const cells = rowMap.get('cells') as Y.Map<any>
      let cellMap = cells.get(col.toString())
      
      if (!cellMap) {
        cellMap = new Y.Map()
        cells.set(col.toString(), cellMap)
      }
      
      cellMap.set('text', value.text)
    })
  }
}

2. WebSocket 通信层

实现一个 WebSocketProviderWrapper 来处理 WebSocket 通信:

typescript 复制代码
export class WebSocketProviderWrapper {
  private ws: WebSocket | null = null
  private config: WebSocketProviderConfig | null = null

  connect(config: WebSocketProviderConfig): void {
    const { serverUrl, roomName, doc } = config
    const url = `${serverUrl}/${roomName}`
    
    this.ws = new WebSocket(url)
    this.ws.binaryType = 'arraybuffer'

    // 连接成功后发送同步请求
    this.ws.onopen = () => {
      this._sendSyncStep1()
    }

    // 接收消息
    this.ws.onmessage = (event) => {
      this._handleMessage(event.data)
    }

    // 监听文档更新
    doc.on('update', this._handleDocUpdate)
  }

  // 处理文档更新(发送给服务器)
  private _handleDocUpdate = (update: Uint8Array, origin: any): void {
    // 只发送本地更新,避免循环
    if (origin !== this && this.ws?.readyState === WebSocket.OPEN) {
      const encoder = encoding.createEncoder()
      encoding.writeVarUint(encoder, 0)  // 消息类型:sync
      syncProtocol.writeUpdate(encoder, update)
      this._send(encoding.toUint8Array(encoder))
    }
  }

  // 处理接收到的消息(应用到文档)
  private _handleMessage(data: ArrayBuffer): void {
    const decoder = decoding.createDecoder(new Uint8Array(data))
    const messageType = decoding.readVarUint(decoder)

    if (messageType === 0) {  // Sync 消息
      const encoder = encoding.createEncoder()
      encoding.writeVarUint(encoder, 0)
      
      syncProtocol.readSyncMessage(
        decoder,
        encoder,
        this.config.doc,
        this  // origin 标记为 WebSocketProvider
      )
      
      // 发送响应
      const response = encoding.toUint8Array(encoder)
      if (response.length > 1) {
        this._send(response)
      }
    }
  }
}

3. WebSocket 服务器

使用 Node.js 实现一个简单的 WebSocket 服务器:

javascript 复制代码
const WebSocket = require('ws')

const wss = new WebSocket.Server({ port: 1234 })
const rooms = new Map()

wss.on('connection', (ws, req) => {
  const roomName = req.url.slice(1)
  
  // 加入房间
  if (!rooms.has(roomName)) {
    rooms.set(roomName, new Set())
  }
  const room = rooms.get(roomName)
  room.add(ws)

  console.log(`客户端加入房间: ${roomName},当前人数: ${room.size}`)

  // 转发消息给房间内其他客户端
  ws.on('message', (message) => {
    room.forEach(client => {
      if (client !== ws && client.readyState === WebSocket.OPEN) {
        client.send(message)
      }
    })
  })

  // 离开房间
  ws.on('close', () => {
    room.delete(ws)
    if (room.size === 0) {
      rooms.delete(roomName)
    }
  })
})

4. Vue 组件集成

在 Vue 组件中集成 Yjs 和 WebSocket:

js 复制代码
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { YjsTableManager } from '../utils/YjsTableManager'
import { WebSocketProviderWrapper } from '../utils/WebSocketProvider'

const tableManager = new YjsTableManager()
const wsProvider = new WebSocketProviderWrapper()
const sheetId = 'demo-sheet'
let doc: any = null
let tableMap: any = null

// 响应式数据
const cellData = reactive<Record<string, string>>({})
const connectionStatus = ref<'connected' | 'disconnected' | 'connecting'>('disconnected')

onMounted(() => {
  // 初始化文档
  doc = tableManager.getDocument(sheetId)
  tableMap = tableManager.getTableMap(doc)
  
  // 加载初始数据
  loadAllCells()
  
  // 监听文档更新(关键!)
  doc.on('update', (update: Uint8Array, origin: any) => {
    console.log('文档更新,origin:', origin)
    loadAllCells()  // 重新加载数据到 UI
  })
  
  // 连接 WebSocket
  wsProvider.connect({
    serverUrl: 'ws://localhost:1234',
    roomName: sheetId,
    doc,
    onStatusChange: (status) => {
      connectionStatus.value = status
    }
  })
})

// 加载所有单元格数据
const loadAllCells = () => {
  for (let row = 0; row < rowCount.value; row++) {
    for (let col = 0; col < columnCount.value; col++) {
      const key = `${row}-${col}`
      const cell = tableManager.getCellValue(tableMap, row, col)
      cellData[key] = cell?.text || ''
    }
  }
}

// 保存单元格
const finishEdit = () => {
  if (editingCell.value) {
    const { row, col } = editingCell.value
    tableManager.setCellValue(tableMap, row, col, {
      text: editValue.value
    })
    // 立即更新本地缓存
    cellData[`${row}-${col}`] = editValue.value
  }
}

onUnmounted(() => {
  wsProvider.disconnect()
})
</script>

五、踩过的坑

坑 1:UI 不更新

问题:客户端收到 WebSocket 消息,但表格不更新。

原因 :最初使用 tableMap.observe() 监听更新,但这只能监听本地直接修改,无法监听远程更新。

typescript 复制代码
// ❌ 错误做法
tableManager.observeTableUpdates(tableMap, () => {
  loadAllCells()
})

解决 :改为监听 doc.on('update') 事件,可以捕获所有更新。

typescript 复制代码
// ✅ 正确做法
doc.on('update', (update, origin) => {
  loadAllCells()
})

坑 2:无限循环

问题:发送更新后,收到自己发送的消息,又触发发送,导致无限循环。

解决 :使用 origin 参数区分更新来源。

typescript 复制代码
doc.on('update', (update, origin) => {
  // 只发送本地更新
  if (origin !== this) {
    ws.send(update)
  }
})

坑 3:合并单元格冲突

问题:合并单元格时,如果范围重叠会导致显示错误。

解决:在合并前检查是否有重叠。

typescript 复制代码
mergeCells(range) {
  // 检查是否与已有合并单元格重叠
  for (const merged of mergedCells) {
    const hasOverlap = !(
      range.endRow < merged.startRow ||
      range.startRow > merged.endRow ||
      range.endCol < merged.startCol ||
      range.startCol > merged.endCol
    )
    
    if (hasOverlap) {
      throw new Error('合并范围重叠')
    }
  }
  
  // 执行合并...
}

六、工作流程详解

完整的数据流

js 复制代码
用户编辑单元格
    ↓
setCellValue()
    ↓
Y.Doc.transact() 修改数据
    ↓
触发 'update' 事件 (origin = undefined)
    ↓
WebSocketProvider 检测到本地更新
    ↓
发送到 WebSocket 服务器
    ↓
服务器广播给房间内其他客户端
    ↓
其他客户端接收消息
    ↓
syncProtocol.readSyncMessage() 应用更新
    ↓
触发 'update' 事件 (origin = WebSocketProvider)
    ↓
loadAllCells() 重新加载数据
    ↓
Vue 响应式系统更新 UI

CRDT 的魔力

CRDT 算法保证了即使两个用户同时编辑,也不会产生冲突:

css 复制代码
// 客户端 A 和 B 同时编辑不同单元格
// A: setCellValue(0, 0, 'Hello')
// B: setCellValue(0, 1, 'World')

// 两个更新会自动合并,不会冲突
// 最终结果:A1='Hello', B1='World'

即使编辑同一个单元格,CRDT 也会根据时间戳等信息自动解决冲突,保证最终一致性。

七、性能优化

1. 增量更新

Yjs 只传输变化的部分,不是整个文档:

typescript 复制代码
// 只传输修改的单元格数据,不是整张表
cellMap.set('text', 'new value')
// 生成的 update 只有几十字节

2. 批量操作

使用事务将多个操作合并:

typescript 复制代码
doc.transact(() => {
  // 多个操作只生成一个 update
  setCellValue(0, 0, 'A')
  setCellValue(0, 1, 'B')
  setCellValue(0, 2, 'C')
})

3. 断线重连

实现指数退避算法:

typescript 复制代码
private _handleDisconnect(): void {
  // 1s, 2s, 4s, 8s, 16s, 30s
  const delay = Math.min(Math.pow(2, this.reconnectAttempts) * 1000, 30000)
  
  setTimeout(() => {
    this._connect()
  }, delay)
}

八、测试

单元测试

使用 Vitest 测试核心功能:

typescript 复制代码
describe('YjsTableManager', () => {
  it('应该正确设置和获取单元格值', () => {
    const manager = new YjsTableManager()
    const doc = manager.getDocument('test')
    const tableMap = manager.getTableMap(doc)
    
    manager.setCellValue(tableMap, 0, 0, { text: 'Hello' })
    const value = manager.getCellValue(tableMap, 0, 0)
    
    expect(value.text).toBe('Hello')
  })
  
  it('应该正确合并单元格', () => {
    // ...
  })
})

集成测试

打开两个浏览器窗口,测试实时同步:

  1. 窗口 A 编辑单元格
  2. 窗口 B 应该立即看到更新
  3. 同时编辑不同单元格,都应该成功
  4. 断开连接后编辑,重连后应该自动同步

九、总结

收获

  1. CRDT 算法:理解了分布式一致性的实现原理
  2. WebSocket:掌握了实时通信的最佳实践
  3. Yjs:学会了使用成熟的协同编辑库
  4. 架构设计:分层设计让代码更清晰可维护

未来优化

  1. 权限控制:添加用户身份验证和权限管理
  2. 历史记录:实现撤销/重做功能
  3. 富文本:支持单元格内的富文本编辑
  4. 公式计算:实现 Excel 公式功能
  5. 性能优化:虚拟滚动支持大表格

参考资源

结语

实现协同编辑功能看似复杂,但有了 Yjs 这样的成熟库,很多底层细节都被封装好了。关键是理解 CRDT 的原理,正确使用 Yjs 的 API,以及处理好 WebSocket 通信。


关键词:协同编辑、Yjs、CRDT、WebSocket、Vue3、uniapp、实时同步

相关推荐
mr_LuoWei20092 小时前
自定义的中文脚本解释器来实现对excel自动化处理(一)
python·自动化·excel
捧 花2 小时前
Go + Gin 实现 HTTPS 与 WebSocket 实时通信
websocket·golang·https·go·gin
吴声子夜歌2 小时前
小程序——跳转API
服务器·前端·小程序
softbangong2 小时前
901-excel编辑工具
microsoft·自动化·excel·办公自动化·数据处理·excel操作·excel工具
qq_12498707532 小时前
基于springboot的个性化服装搭配推荐小程序(源码+论文+部署+安装)
spring boot·后端·spring·微信小程序·小程序·毕业设计·毕业设计源码
吴声子夜歌2 小时前
小程序——转发API
java·前端·小程序
CHU7290352 小时前
宠物寄养小程序前端设计:以用户为中心,构建温暖贴心的服务生态
小程序·宠物
万岳科技程序员小金2 小时前
同城外卖系统源码开发:外卖APP与小程序平台搭建方案详解
大数据·小程序·软件开发·同城外卖系统源码·外卖app开发·外卖小程序开发·外卖软件开发
CHU7290352 小时前
就医陪诊预约小程序:贴心陪伴,让就医之路更顺畅的功能设计
小程序