文章目录
-
- 前言
- 效果
- 一、需求分析
- 二、技术选型
-
- [为什么选择 Yjs?](#为什么选择 Yjs?)
- [为什么用 WebSocket?](#为什么用 WebSocket?)
- 三、架构设计
- 四、核心实现
-
- [1. Yjs 表格管理器](#1. Yjs 表格管理器)
- [2. WebSocket 通信层](#2. WebSocket 通信层)
- [3. WebSocket 服务器](#3. WebSocket 服务器)
- [4. Vue 组件集成](#4. Vue 组件集成)
- 五、踩过的坑
-
- [坑 1:UI 不更新](#坑 1:UI 不更新)
- [坑 2:无限循环](#坑 2:无限循环)
- [坑 3:合并单元格冲突](#坑 3:合并单元格冲突)
- 六、工作流程详解
-
- 完整的数据流
- [CRDT 的魔力](#CRDT 的魔力)
- 七、性能优化
-
- [1. 增量更新](#1. 增量更新)
- [2. 批量操作](#2. 批量操作)
- [3. 断线重连](#3. 断线重连)
- 八、测试
- 九、总结
- 结语
前言
最近在做一个小程序项目,需要实现类似 Excel 的协同编辑功能。多人可以同时编辑同一张表格,实时看到彼此的修改。听起来很酷,但实现起来有不少坑。这篇文章记录了我从零到一的实现过程,以及踩过的坑和解决方案。
技术栈:
- uniapp - 跨平台小程序框架
- Vue 3 + TypeScript - 前端框架
- Yjs - CRDT 协同编辑库
- WebSocket - 实时通信
- Node.js - WebSocket 服务器
效果

一、需求分析
核心功能
- 多人同时编辑同一张表格
- 实时同步所有用户的修改
- 支持基本的表格操作:
- 单元格编辑
- 插入/删除行列
- 合并/取消合并单元格
- 范围选择
技术挑战
- 冲突解决:两个用户同时编辑怎么办?
- 实时性:如何保证低延迟?
- 数据一致性:如何保证所有客户端数据一致?
- 离线支持:断网后重连如何同步?
二、技术选型
为什么选择 Yjs?
Yjs 是一个基于 CRDT (Conflict-free Replicated Data Type) 算法的协同编辑库。它的核心优势:
- 自动冲突解决:不需要手动处理冲突
- 最终一致性:保证所有客户端最终数据一致
- 离线支持:可以离线编辑,连接后自动同步
- 高性能:增量更新,只传输变化的部分
为什么用 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('应该正确合并单元格', () => {
// ...
})
})
集成测试
打开两个浏览器窗口,测试实时同步:
- 窗口 A 编辑单元格
- 窗口 B 应该立即看到更新
- 同时编辑不同单元格,都应该成功
- 断开连接后编辑,重连后应该自动同步
九、总结
收获
- CRDT 算法:理解了分布式一致性的实现原理
- WebSocket:掌握了实时通信的最佳实践
- Yjs:学会了使用成熟的协同编辑库
- 架构设计:分层设计让代码更清晰可维护
未来优化
- 权限控制:添加用户身份验证和权限管理
- 历史记录:实现撤销/重做功能
- 富文本:支持单元格内的富文本编辑
- 公式计算:实现 Excel 公式功能
- 性能优化:虚拟滚动支持大表格
参考资源
结语
实现协同编辑功能看似复杂,但有了 Yjs 这样的成熟库,很多底层细节都被封装好了。关键是理解 CRDT 的原理,正确使用 Yjs 的 API,以及处理好 WebSocket 通信。
关键词:协同编辑、Yjs、CRDT、WebSocket、Vue3、uniapp、实时同步