从零实现H5 表格协同编辑:Yjs + WebSocket 实战
文章目录前言效果一、需求分析核心功能技术挑战二、技术选型为什么选择 Yjs为什么用 WebSocket三、架构设计整体架构数据结构设计四、核心实现1. Yjs 表格管理器2. WebSocket 通信层3. WebSocket 服务器4. Vue 组件集成五、踩过的坑坑 1UI 不更新坑 2无限循环坑 3合并单元格冲突六、工作流程详解完整的数据流CRDT 的魔力七、性能优化1. 增量更新2. 批量操作3. 断线重连八、测试单元测试集成测试九、总结收获未来优化参考资源结语前言最近在做一个小程序项目需要实现类似 Excel 的协同编辑功能。多人可以同时编辑同一张表格实时看到彼此的修改。听起来很酷但实现起来有不少坑。这篇文章记录了我从零到一的实现过程以及踩过的坑和解决方案。技术栈uniapp- 跨平台小程序框架Vue 3 TypeScript- 前端框架Yjs- CRDT 协同编辑库WebSocket- 实时通信Node.js- WebSocket 服务器效果一、需求分析核心功能多人同时编辑同一张表格实时同步所有用户的修改支持基本的表格操作单元格编辑插入/删除行列合并/取消合并单元格范围选择技术挑战冲突解决两个用户同时编辑怎么办实时性如何保证低延迟数据一致性如何保证所有客户端数据一致离线支持断网后重连如何同步二、技术选型为什么选择 YjsYjs 是一个基于 CRDT (Conflict-free Replicated Data Type) 算法的协同编辑库。它的核心优势自动冲突解决不需要手动处理冲突最终一致性保证所有客户端最终数据一致离线支持可以离线编辑连接后自动同步高性能增量更新只传输变化的部分为什么用 WebSocket双向通信服务器可以主动推送低延迟比 HTTP 轮询快得多实时性适合协同编辑场景三、架构设计整体架构┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ 客户端A│ │ WebSocket │ │ 客户端B│ │ │ │ 服务器 │ │ │ │Y.Doc │◄───────►│ 中转 │◄───────►│Y.Doc │ │ ↓ │ │ │ │ ↓ │ │UI组件 │ └─────────────┘ │UI组件 │ └─────────────┘ └─────────────┘数据结构设计使用 Yjs 的数据结构来表示表格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 操作exportclassYjsTableManager{privatedocuments:Mapstring,Y.DocnewMap()// 获取或创建文档getDocument(sheetId:string):Y.Doc{letdocthis.documents.get(sheetId)if(!doc){docnewY.Doc()this.documents.set(sheetId,doc)this.initializeTableStructure(doc.getMap(table))}returndoc}// 设置单元格值setCellValue(tableMap:Y.Mapany,row:number,col:number,value:CellValue):void{constdoctableMap.doc// 使用事务保证原子性doc.transact((){constrowstableMap.get(rows)asY.ArrayY.Mapany// 确保行存在while(rows.lengthrow){constnewRownewY.Map()newRow.set(rowIndex,rows.length)newRow.set(cells,newY.Map())rows.push([newRow])}// 获取单元格并更新constrowMaprows.get(row)constcellsrowMap.get(cells)asY.MapanyletcellMapcells.get(col.toString())if(!cellMap){cellMapnewY.Map()cells.set(col.toString(),cellMap)}cellMap.set(text,value.text)})}}2. WebSocket 通信层实现一个WebSocketProviderWrapper来处理 WebSocket 通信exportclassWebSocketProviderWrapper{privatews:WebSocket|nullnullprivateconfig:WebSocketProviderConfig|nullnullconnect(config:WebSocketProviderConfig):void{const{serverUrl,roomName,doc}configconsturl${serverUrl}/${roomName}this.wsnewWebSocket(url)this.ws.binaryTypearraybuffer// 连接成功后发送同步请求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!thisthis.ws?.readyStateWebSocket.OPEN){constencoderencoding.createEncoder()encoding.writeVarUint(encoder,0)// 消息类型syncsyncProtocol.writeUpdate(encoder,update)this._send(encoding.toUint8Array(encoder))}}// 处理接收到的消息应用到文档private_handleMessage(data:ArrayBuffer):void{constdecoderdecoding.createDecoder(newUint8Array(data))constmessageTypedecoding.readVarUint(decoder)if(messageType0){// Sync 消息constencoderencoding.createEncoder()encoding.writeVarUint(encoder,0)syncProtocol.readSyncMessage(decoder,encoder,this.config.doc,this// origin 标记为 WebSocketProvider)// 发送响应constresponseencoding.toUint8Array(encoder)if(response.length1){this._send(response)}}}}3. WebSocket 服务器使用 Node.js 实现一个简单的 WebSocket 服务器constWebSocketrequire(ws)constwssnewWebSocket.Server({port:1234})constroomsnewMap()wss.on(connection,(ws,req){constroomNamereq.url.slice(1)// 加入房间if(!rooms.has(roomName)){rooms.set(roomName,newSet())}constroomrooms.get(roomName)room.add(ws)console.log(客户端加入房间:${roomName}当前人数:${room.size})// 转发消息给房间内其他客户端ws.on(message,(message){room.forEach(client{if(client!wsclient.readyStateWebSocket.OPEN){client.send(message)}})})// 离开房间ws.on(close,(){room.delete(ws)if(room.size0){rooms.delete(roomName)}})})4. Vue 组件集成在 Vue 组件中集成 Yjs 和 WebSocketscript setup langtsimport{ref,reactive,onMounted,onUnmounted}fromvueimport{YjsTableManager}from../utils/YjsTableManagerimport{WebSocketProviderWrapper}from../utils/WebSocketProviderconsttableManagernewYjsTableManager()constwsProvidernewWebSocketProviderWrapper()constsheetIddemo-sheetletdoc:anynulllettableMap:anynull// 响应式数据constcellDatareactiveRecordstring,string({})constconnectionStatusrefconnected|disconnected|connecting(disconnected)onMounted((){// 初始化文档doctableManager.getDocument(sheetId)tableMaptableManager.getTableMap(doc)// 加载初始数据loadAllCells()// 监听文档更新关键doc.on(update,(update:Uint8Array,origin:any){console.log(文档更新origin:,origin)loadAllCells()// 重新加载数据到 UI})// 连接 WebSocketwsProvider.connect({serverUrl:ws://localhost:1234,roomName:sheetId,doc,onStatusChange:(status){connectionStatus.valuestatus}})})// 加载所有单元格数据constloadAllCells(){for(letrow0;rowrowCount.value;row){for(letcol0;colcolumnCount.value;col){constkey${row}-${col}constcelltableManager.getCellValue(tableMap,row,col)cellData[key]cell?.text||}}}// 保存单元格constfinishEdit(){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五、踩过的坑坑 1UI 不更新问题客户端收到 WebSocket 消息但表格不更新。原因最初使用tableMap.observe()监听更新但这只能监听本地直接修改无法监听远程更新。// ❌ 错误做法tableManager.observeTableUpdates(tableMap,(){loadAllCells()})解决改为监听doc.on(update)事件可以捕获所有更新。// ✅ 正确做法doc.on(update,(update,origin){loadAllCells()})坑 2无限循环问题发送更新后收到自己发送的消息又触发发送导致无限循环。解决使用origin参数区分更新来源。doc.on(update,(update,origin){// 只发送本地更新if(origin!this){ws.send(update)}})坑 3合并单元格冲突问题合并单元格时如果范围重叠会导致显示错误。解决在合并前检查是否有重叠。mergeCells(range){// 检查是否与已有合并单元格重叠for(constmergedofmergedCells){consthasOverlap!(range.endRowmerged.startRow||range.startRowmerged.endRow||range.endColmerged.startCol||range.startColmerged.endCol)if(hasOverlap){thrownewError(合并范围重叠)}}// 执行合并...}六、工作流程详解完整的数据流用户编辑单元格 ↓setCellValue()↓Y.Doc.transact()修改数据 ↓ 触发update事件(originundefined)↓ WebSocketProvider 检测到本地更新 ↓ 发送到 WebSocket 服务器 ↓ 服务器广播给房间内其他客户端 ↓ 其他客户端接收消息 ↓ syncProtocol.readSyncMessage()应用更新 ↓ 触发update事件(originWebSocketProvider)↓loadAllCells()重新加载数据 ↓ Vue 响应式系统更新UICRDT 的魔力CRDT 算法保证了即使两个用户同时编辑也不会产生冲突// 客户端 A 和 B 同时编辑不同单元格 //A:setCellValue(0,0,Hello)//B:setCellValue(0,1,World)// 两个更新会自动合并不会冲突 // 最终结果A1Hello,B1World即使编辑同一个单元格CRDT 也会根据时间戳等信息自动解决冲突保证最终一致性。七、性能优化1. 增量更新Yjs 只传输变化的部分不是整个文档// 只传输修改的单元格数据不是整张表cellMap.set(text,new value)// 生成的 update 只有几十字节2. 批量操作使用事务将多个操作合并doc.transact((){// 多个操作只生成一个 updatesetCellValue(0,0,A)setCellValue(0,1,B)setCellValue(0,2,C)})3. 断线重连实现指数退避算法private_handleDisconnect():void{// 1s, 2s, 4s, 8s, 16s, 30sconstdelayMath.min(Math.pow(2,this.reconnectAttempts)*1000,30000)setTimeout((){this._connect()},delay)}八、测试单元测试使用 Vitest 测试核心功能describe(YjsTableManager,(){it(应该正确设置和获取单元格值,(){constmanagernewYjsTableManager()constdocmanager.getDocument(test)consttableMapmanager.getTableMap(doc)manager.setCellValue(tableMap,0,0,{text:Hello})constvaluemanager.getCellValue(tableMap,0,0)expect(value.text).toBe(Hello)})it(应该正确合并单元格,(){// ...})})集成测试打开两个浏览器窗口测试实时同步窗口 A 编辑单元格窗口 B 应该立即看到更新同时编辑不同单元格都应该成功断开连接后编辑重连后应该自动同步九、总结收获CRDT 算法理解了分布式一致性的实现原理WebSocket掌握了实时通信的最佳实践Yjs学会了使用成熟的协同编辑库架构设计分层设计让代码更清晰可维护未来优化权限控制添加用户身份验证和权限管理历史记录实现撤销/重做功能富文本支持单元格内的富文本编辑公式计算实现 Excel 公式功能性能优化虚拟滚动支持大表格参考资源Yjs 官方文档CRDT 论文WebSocket MDN结语实现协同编辑功能看似复杂但有了 Yjs 这样的成熟库很多底层细节都被封装好了。关键是理解 CRDT 的原理正确使用 Yjs 的 API以及处理好 WebSocket 通信。关键词协同编辑、Yjs、CRDT、WebSocket、Vue3、uniapp、实时同步

相关新闻

报名开启|G-Star Gathering Day 合肥站

报名开启|G-Star Gathering Day 合肥站

在 AI 技术快速演进的今天,开源正在成为推动技术创新与生态发展的核心力量。从大模型到 RISC-V,从开发工具到开源社区协作模式,越来越多开发者正通过开源参与到下一代技术浪潮中 3 月 22 日,AtomGit 联合 GodotHub、iFLYTEK Astr…

2026/5/17 11:56:47 阅读更多 →
通用视觉算法平台之架构(基于qt和opencv实现),全程干货,提供软件下载

通用视觉算法平台之架构(基于qt和opencv实现),全程干货,提供软件下载

文章目录 前言一、平台核心架构设计1.硬件层:统一硬件接入底座2.核心驱动层:硬件与上层的桥梁3.算法插件层:平台的核心能力载体4. 流程引擎层:可视化流程设计与执行5.交互展示层:用户友好的操作界面 二、开发环境三、部…

2026/7/4 6:32:50 阅读更多 →
sharepoint怎么获取site的clientId、clientSecrets、clientTenant

sharepoint怎么获取site的clientId、clientSecrets、clientTenant

这是一个非常常见的概念混淆。**SharePoint 站点(Site)本身并没有 clientId、clientSecret 或 clientTenant。**这三个参数属于 **Microsoft Entra ID(原 Azure AD)中的“应用程序注册” (App Registration)**。* **Tenant ID (…

2026/7/3 18:33:26 阅读更多 →

最新新闻

Claude Code 实战:AI 结对编程如何真正提效,从简历表达讲到项目复盘

Claude Code 实战:AI 结对编程如何真正提效,从简历表达讲到项目复盘

聊《Claude Code 实战:AI 结对编程如何真正提效,从简历表达讲到项目复盘》之前,先说一句实在的:别急着背概念,先看它在真实项目里到底解决什么问题。摘要这篇面向正在评估 Claude Code 的开发者,但不会把“…

2026/7/6 0:39:26 阅读更多 →
PyTorch CRF 实战:BERT-CRF 命名实体识别 F1 值提升 5% 的 3 个关键点

PyTorch CRF 实战:BERT-CRF 命名实体识别 F1 值提升 5% 的 3 个关键点

PyTorch CRF 实战:BERT-CRF 命名实体识别 F1 值提升 5% 的 3 个关键点在自然语言处理领域,命名实体识别(NER)一直是一项基础而重要的任务。随着预训练语言模型如BERT的广泛应用,基于BERT的序列标注模型已成为NER的主流…

2026/7/6 0:37:25 阅读更多 →
终极指南:5分钟快速上手浏览器端人体姿态搜索工具

终极指南:5分钟快速上手浏览器端人体姿态搜索工具

终极指南:5分钟快速上手浏览器端人体姿态搜索工具 【免费下载链接】pose-search x6ud.github.io/pose-search 项目地址: https://gitcode.com/gh_mirrors/po/pose-search 想要在浏览器中实现专业级的人体姿态识别与动作搜索功能吗?pose-search是一…

2026/7/6 0:37:25 阅读更多 →
74HC32与PIC18F45K50实现高效键盘管理方案

74HC32与PIC18F45K50实现高效键盘管理方案

1. 为什么需要74HC32配合PIC18F45K50管理键盘?在嵌入式系统设计中,IO资源永远是稀缺品。传统2x2矩阵键盘需要占用4个IO口(2行2列),而采用74HC32或门芯片后,仅需2个IO即可实现4个按键的独立检测——这正是该…

2026/7/6 0:35:25 阅读更多 →
openEuler/QoS-Deployment-Test:从零开始编写自定义测试用例的完整指南

openEuler/QoS-Deployment-Test:从零开始编写自定义测试用例的完整指南

openEuler/QoS-Deployment-Test:从零开始编写自定义测试用例的完整指南 【免费下载链接】QoS-Deployment-Test Docker-based openEuler Online-Offline Co-scheduling Test Suite. 项目地址: https://gitcode.com/openeuler/QoS-Deployment-Test 前往项目官网…

2026/7/6 0:35:25 阅读更多 →
故障复盘——让失败“变成财富“

故障复盘——让失败“变成财富“

故障复盘——让失败"变成财富" 你有没有过考试错题本? 生活场景:错题本的作用 没有错题本 你考试考砸了: 错了3道题 订正了 忘了为什么错 下次考类似的,还是错 没有复盘,错误会重复。 有错题本 你考试考砸了: 错题记到本子上 分析错误原因 总结解题方法 …

2026/7/6 0:35:25 阅读更多 →

日新闻

H2 与 MySQL 单元测试兼容性:5 个关键 SQL 语句差异与规避方案

H2 与 MySQL 单元测试兼容性:5 个关键 SQL 语句差异与规避方案

H2与MySQL单元测试兼容性:5个关键SQL语句差异与规避方案1. 单元测试中的数据库兼容性挑战在Java开发领域,单元测试是保证代码质量的重要环节。当应用涉及数据库操作时,测试环境的搭建往往成为开发者的痛点。H2数据库因其轻量级、内存模式和快…

2026/7/6 0:01:17 阅读更多 →
Windows任务栏终极清理指南:用RBTray一键隐藏窗口到系统托盘

Windows任务栏终极清理指南:用RBTray一键隐藏窗口到系统托盘

Windows任务栏终极清理指南:用RBTray一键隐藏窗口到系统托盘 【免费下载链接】rbtray A fork of RBTray from http://sourceforge.net/p/rbtray/code/. 项目地址: https://gitcode.com/gh_mirrors/rb/rbtray 你是否厌倦了Windows任务栏上密密麻麻的图标&…

2026/7/6 0:01:17 阅读更多 →
Visual C++ 运行时库一键安装终极指南:告别DLL缺失烦恼

Visual C++ 运行时库一键安装终极指南:告别DLL缺失烦恼

Visual C 运行时库一键安装终极指南:告别DLL缺失烦恼 【免费下载链接】vcredist AIO Repack for latest Microsoft Visual C Redistributable Runtimes 项目地址: https://gitcode.com/gh_mirrors/vc/vcredist 你是否曾经遇到过这样的情况:下载了…

2026/7/6 0:05:19 阅读更多 →

周新闻

B站视频下载神器BiliTools:5分钟学会轻松保存任何B站内容

B站视频下载神器BiliTools:5分钟学会轻松保存任何B站内容

B站视频下载神器BiliTools:5分钟学会轻松保存任何B站内容 【免费下载链接】BiliTools A cross-platform bilibili toolbox. 跨平台哔哩哔哩工具箱,支持下载视频、番剧等等各类资源 项目地址: https://gitcode.com/GitHub_Trending/bilit/BiliTools …

2026/7/5 0:03:34 阅读更多 →
威胁模型全解析:从新手入门到实战应用,助你构建安全产品!

威胁模型全解析:从新手入门到实战应用,助你构建安全产品!

威胁模型的陌生现状在忙碌疲惫的一天里,参与了关于混合后量子密码学的讨论,应付端点攻击找茬的人,还参与留言板讨论后,发现“威胁模型”对多数人仍是陌生概念,且多被当作时髦用语。有趣的相关画作有一幅由 Embyr 创作的…

2026/7/5 0:03:34 阅读更多 →
渗透测试入门指南:从零基础到实战环境搭建

渗透测试入门指南:从零基础到实战环境搭建

1. 从“看热闹”到“入门”:我理解的渗透测试到底是什么?每次看到新闻里说某个大公司的数据被“黑”了,或者某个网站被攻击导致服务瘫痪,你是不是和我一样,心里会冒出两个念头:一是“这黑客真厉害”&#x…

2026/7/5 0:07:38 阅读更多 →

月新闻