接到要做一套“企业级协同设计器”的需求时,我心里很清楚:这本质上不是做一个页面,而是要回答一系列更难的问题。多人同时编辑一张电子表格时,操作冲突怎么处理?别人改了一个单元格,我的界面什么时候感知?断网后再连回来,本地这些编辑是保留还是丢弃?权限不只是“能不能打开”,而是某一行某一列谁允许动。上层还要叠加保存、导入导出、模板管理、审计追踪,整套东西加一起才配叫“企业级”。
我最终选择的路线是以 SpreadJS 作为表格引擎底座,自己做协同同步层。它能省下自研网格渲染、公式引擎、Excel 导入导出这些最烧时间的部分,也能让我把精力集中在“协同”这个真正的增量上。下面我把整个落地过程拆开讲,从选型、协议设计,到关键代码和排坑,基本是按实际动手的顺序来写的。想直接照着搭一套 MVP 的团队,可以按这篇文章的节奏走。
1. 项目整体思路与选型分析
1.1 为什么直接用 SpreadJS,而不是自研表格内核
做在线表格最忌讳的一件事,就是低估自研成本。一个能用的电子表格引擎,至少包含四块:渲染层要处理冻结行列、合并单元格、自适应列宽;公式引擎要支持跨表引用、绝对相对引用、循环引用检测;交互层要处理快捷键、拖拽填充、剪贴板粘贴;还有 Excel 文件导入导出时的样式兼容和精度问题。一个五人前端团队,把这些功能做到 70% 的 Excel 体验,投入 6 到 12 个月是很正常的,最后还不一定能扛住大数据量渲染。
SpreadJS 引入之后,情况完全不一样。它把上面这些能力都封装好了:GridView 负责展示和编辑,Formula Engine 处理公式,Designer 组件提供类似 Excel 的工具栏和面板,还有现成的 Excel 导入导出和 JSON 序列化。我们真正要写的代码,是自己的协同逻辑,而不是重新造一轮轮子。
说句实在的,“企业级”三个字意味着可靠优先,不是炫技优先。底层引擎越稳定,上层协同功能越能经得起考验。自研引擎再酷,万一某个老版本浏览器下渲染错位,整个协同设计器都会连带翻车。
1.2 先想清楚协作模式:锁、OT 还是 CRDT
很多人一听“协同编辑”就想到 OT(操作转换)和 CRDT(无冲突复制数据类型),仿佛不做到实时合并就不叫协同。但在做方案选型时,必须区分场景。电子表格和文档不同:它有明确的行列坐标,一个单元格同时被两个人改动,合并语义非常模糊。Google Docs 的多光标文本协同,放在 Excel 类产品里并不完全适用。
我当时把协作模式分成三条路线:
| 技术路线 | 核心思想 | 实现难度 | 适合场景 |
|---|---|---|---|
| 悲观锁 | 用户编辑某个区域时,把该区域锁定并通知他人 | 低 | 企业表单、指标维护、少量并发编辑者 |
| 操作转换 OT | 每个操作携带位置和值,经过转换函数合并 | 高 | 文字编辑器、高并发实时协同 |
| CRDT | 每个操作天然可合并,最终一致 | 较高 | 消息、多端数据合并、文本协同 |
SpreadJS 这类网格组件,单元格之间有公式依赖、合并单元格、行列结构变化,完整实现 OT 非常复杂。我最终选择的是“单元格级锁 + 操作日志版本号”的组合:谁的编辑区先抢占,其他人要么只读要么等待,服务端用操作日志保证最终一致。这个方案能覆盖 90% 的企业内部场景,尤其适合“一个人填数、另一个人在旁边审阅”这种典型工作流。
1.3 整体架构:前端设计器、协同网关与快照存储
整套系统拆了三层:
前端是 React 单页应用,页面里嵌入 SpreadJS Designer 组件;协同网关是一个 WebSocket 服务,专门处理文档连接、操作广播、版本管理和鉴权校验;存储层则分成文档表、操作日志表和快照表。
为了解决“新加入的协作者要看到当前状态”这个问题,不能让他重放几千条操作日志。我设计了“快照 + 增量”的同步策略:服务端每积累一定数量的操作,或者在固定时间间隔生成一份全量 JSON 快照。新用户连接时,先取最近一份快照,再补发快照之后的增量操作。类比一下就是“全量备份 + 操作日志恢复”,这类用户在打开文档时体验是秒开,后端资源消耗也可控。
1.4 技术栈怎么选最省事
后端我用 Node.js 做 WebSocket 网关,主要原因是前端本身是 JS 栈,共享操作对象的数据结构不需要做二次序列化,沟通成本也低。如果用 Java 团队,用 Netty 做 WebSocket 服务效果一样,但要在前后端统一字段命名和版本协议上有更强的规范约束。
存储我建议用业务中已有的 PostgreSQL 或者 MySQL,Redis 只用来做快照缓存和在线用户状态,不一定非要引入特别重的中间件。核心原则是:MVP 阶段不要为了堆新技术而增加运维复杂度。企业接入层一般已经有统一认证体系,网关直接对接 JWT 解析用户权限即可,不需要自己在前端管理会话。
2. 核心细节解析与关键技术准备
2.1 吃透 SpreadJS 设计器的二次开发入口
SpreadJS 的 Designer 组件,本质上是在“表格内核”外面套了一层完整的 Excel 化操作界面。拿到 Designer 实例之后,底层的表格核心对象其实还是那个 Workbook。
我初始化设计器的简化代码如下:
const designer = new GC.Spread.Sheets.Designer.Designer('designerHost', { width: '100%', height: '100%' }); // 拿到底层 workbook,后续所有协同操作都基于它 const spread = designer.getWorkbook();关键点是:协同操作的事件监听要挂在spread上,而不是挂在 Designer 的 DOM 元素上。因为单元格编辑、样式更改、行列操作都会触发 SpreadJS 的模型层事件,用spread.bind(...)才能统一捕获。直接去监听输入框的 value 变化,会漏掉公式更新、复制粘贴、拖拽填充这些非输入型操作。
另外一个心得是:不要一上来就去改造 Designer 默认的工具栏。先跑通最小可用链路,确认通过fromJSON和toJSON能完成文档初始化与保存,再考虑自定义按钮。否则你会在 UI 定制上花掉和协同同步一样多的精力。
2.2 数据模型:文档表、操作日志表和快照表怎么设计
协同能力不是只靠 WebSocket 广播就能落地,持久化设计更关键。我用的三张表结构大概是这样的:
documents( id varchar primary key, name text, owner_user_id varchar, current_version bigint default 0, snapshot_version bigint default 0, updated_at timestamptz default now() ); doc_ops( id bigserial primary key, doc_id varchar, version bigint, user_id varchar, op_type varchar, op_payload jsonb, created_at timestamptz default now() ); doc_snapshots( id bigserial primary key, doc_id varchar, version bigint, snapshot_json jsonb, created_at timestamptz default now() );documents.current_version是协同版本号,每次操作落库时用乐观锁递增。具体做法是执行一条原子更新:
UPDATE documents SET current_version = current_version + 1 WHERE id = $1 RETURNING current_version;返回的新版本号作为这次操作的全局版本,广播给所有协作者。这样能避免两个用户同时提交时版本冲突,也方便每个客户端知道自己看到的版本和服务端最新版差距。
doc_ops表是操作日志的事实来源,但也是膨胀最快的地方。如果每次单元格改动都落一条记录,一个活跃项目几个月可能积累几十万条。所以系统必须有快照机制。我用doc_snapshots表保存每次快照时整个 workbook 的 JSON,同步时取“小于等于客户端版本号的最新快照”,然后补发这个快照之后的增量操作,这样能控制 OPS 的同步量级。
2.3 操作粒度、单元格锁与公式依赖的边界
协同操作的最小单位要仔细定义。我选用的操作类型是这四类:setValue、setFormula、setStyle、insertRows。这四个类型基本覆盖了设计器里最主要的编辑动作。每个操作都有sheet、row、col、value等字段,服务端拿到后可以很方便地进行权限校验和版本递增。
锁的粒度也分了三级。第一级是文档锁,用于导入模板、整体替换文档结构这样的场景,锁住之后所有人进入只读;第二级是工作表锁,用于某个人负责整张表的数据更新时;第三级是单元格锁,也是最有用的粒度,用户正在编辑某个单元格时,这个单元格对其他用户显示为锁定状态,其他客户端通过灰色样式加提示语标识出来。
公式依赖是个容易忽略的坑。假设用户 A 正在编辑 A1,而用户 B 试图编辑引用 A1 的公式单元格 B1,这时候简单锁住 A1 并不能阻止 B1 产生逻辑冲突。我的处理方式是在服务端用 SpreadJS 提供的公式依赖分析能力,检查当前操作的目标单元格是否被某些公式引用。如果被引用,就拒绝该操作并返回提示。这个逻辑比较复杂,MVP 阶段可以先做成“只要目标区域处于编辑锁范围内,或者引用了被编辑锁的范围,就返回冲突拒绝”,虽然保守,但足够可靠。
2.4 权限控制要前后端一起做,别只靠前端
企业级系统的权限绝对不能只在工具栏上做隐藏。隐藏按钮能防君子,防不了专业用户直接通过 WebSocket 协议发送伪造操作。我强调一个基本原则:前端权限只决定用户“看不看得到入口”,服务端校验决定操作“能不能真正生效”。
我的权限模型分成三层。文档权限控制谁能打开、谁能编辑、谁是所有者;区域权限控制指定行、列或单元格是否只读;操作权限控制谁能增删行、谁能改公式、谁能导入导出文件。在 WebSocket 网关里,每收到一个客户端 op 消息,都要先做一次服务端权限校验,再进入版本递增和广播流程。
认证上直接对接统一的 JWT 体系,token 里带上用户标识、角色和过期时间。WebSocket 握手时解析 token,之后每个操作消息都绑定连接对应的用户 ID。后端还需要做准确的操作日志,谁在什么时间改了哪个单元格,都要有记录,这个是审计的基础。
3. 实操过程与核心环节实现
3.1 第一步:初始化前端工程并接入 SpreadJS
前端工程我用 React + TypeScript 搭建,实际上 Vue 也没问题,SpreadJS 是框架无关的,具体拿到的都是 DOM 容器。先把工程创建好,然后安装并导入 SpreadJS 相关包。
在项目入口里配置授权信息,大意是这个形态:
import { createApp } from 'vue'; import App from './App.vue'; const GC = window.GC; GC.Spread.Sheets.LicenseKey = '你的授权码'; createApp(App).mount('#app');我这里不写死具体的 npm 包名,因为你实际购买并使用的版本号会不一样,按官方文档里对应版本安装即可。需要提醒的是:核心库、Designer 组件都要使用同一个授权,否则会出现水印或者初始化失败。
初始化 Designer 的完整流程是准备一个占满页面的容器,然后拿到 workbook 对象:
const designer = new GC.Spread.Sheets.Designer.Designer('designerHost', { width: '100%', height: '100%' }); const spread = designer.getWorkbook(); spread.fromJSON(emptyTemplateJson);我最初踩过一个坑:容器写在 flex 布局里,子元素 height 没有显式设置,结果 Designer 渲染出来高度为 0,工具栏能看到但表格区空白。后来统一给容器设置绝对尺寸或者 calc(100vh - 48px),问题才解决。
3.2 第二步:把默认设计器改造成多人协同入口
默认的 Designer 工具栏是编辑器专属的,包含文件、打开、保存这些操作。在企业协同场景里,这些默认按钮不一定符合业务预期。我保留了核心编辑按钮,但新增了几个自定义入口:顶部的“分享”按钮会生成一个带文档 ID 的链接,右上角的“协作者列表”展示当前在线用户,“同步状态”位置显示“已保存”“保存中”“离线编辑中”三种状态。
SpreadJS Designer 的自定义按钮可以通过命令配置来注册,注册后的命令可以在工具栏里直接引用。大致思路是定义一个自定义 command,然后把它插入到设计器的 config 里。这样不用改 Designer 的源码,也不会影响后续官方升级。
对只读访客的处理是:前端隐藏编辑类按钮,并把 workbook 设置成只读模式;但这个只读只是体验层,真正拦截还是要靠服务端。访客通过链接进入后,如果尝试发送 op,会被网关直接拒绝。
3.3 第三步:设计基于 WebSocket 的同步协议
这一节是整个协同系统的核心。我梳理出一套最小够用的 WebSocket 消息协议,包含认证、快照同步、操作提交、广播确认和心跳。
| 方向 | 消息类型 | 说明 |
|---|---|---|
| client -> server | hello | 携带 token、docId、fromVersion 建立连接 |
| server -> client | snapshot | 返回当前版本号 + 快照 JSON + 增量操作 |
| client -> server | op | 提交一批操作,携带 docId、fromVersion、ops |
| server -> client | op | 广播已经落库的操作,携带版本号和作者 |
| server -> client | ack / reject | 确认成功或拒绝,并附带原因 |
| client -> server | ping | 心跳保持连接 |
服务端用 Node.js 实现时,简化的结构如下:
import { WebSocketServer } from 'ws'; const wss = new WebSocketServer({ port: 8080 }); const onlineClients = new Map<string, Set<WebSocket>>(); wss.on('connection', (ws) => { ws.on('message', (data) => { const msg = JSON.parse(data.toString()); if (msg.type === 'hello') { ws.docId = msg.docId; ws.userId = msg.userId; // 将 ws 加入对应 docId 的房间 onlineClients.set(msg.docId, (onlineClients.get(msg.docId) || new Set()).add(ws)); // 返回 snapshot + 从 fromVersion 开始的增量 sendSnapshotAndDlt(ws, msg.docId, msg.fromVersion); } if (msg.type === 'op') { // 服务端校验权限和版本 // 乐观锁递增 current_version // 写入 doc_ops 表 // 广播给同文档其他客户端 const newVersion = incrementVersion(msg.docId); broadcast(msg.docId, { type: 'op', author: ws.userId, baseVersion: msg.fromVersion, version: newVersion, ops: msg.ops }); } }); });需要重点关注的是fromVersion的校验。客户端每次发送操作时,必须带上它当前同步到的文档版本号fromVersion。如果服务端发现这个值不等于最新的current_version,说明这个客户端落后了,此时不能直接把操作落库,应该返回一个“版本过期重连”的提示,让客户端先同步增量,再重新提交。否则旧版本客户端很容易把新改动覆盖掉。
3.4 第四步:采集前端操作并防止事件回写风暴
SpreadJS 的操作采集,主要通过事件绑定实现。我监听CellChanged事件,把它转换为服务端能够理解的操作对象。
let applying = false; function bindSpreadEvents(spread) { spread.bind(GC.Spread.Sheets.Events.CellChanged, (_sender, args) => { if (applying) return; if (args.propertyName !== 'value' && args.propertyName !== 'formula') return; const op = { type: 'setValue', sheet: args.sheetName, row: args.row, col: args.col, value: args.newValue }; ws.send(JSON.stringify({ type: 'op', docId, fromVersion: localVersion, ops: [op] })); }); }这里最头疼的问题是回写风暴。当一个远端操作被应用到本地时,spread.setValue()同样会触发CellChanged事件,如果applying标志没有做好,本地会把这个远端操作再原样广播出去,对方再广播回来,形成循环风暴。
我最终的解法不是简单的布尔值,而是用“挂起事件 + 令牌恢复”的方式:
let remoteApplyToken = 0; function applyRemoteOps(remoteOps) { const token = ++remoteApplyToken; applying = true; spread.suspendEvent(); spread.suspendPaint(); try { for (const op of remoteOps) { const sheet = spread.getSheetFromName(op.sheet); sheet.setValue(op.row, op.col, op.value); } } finally { spread.resumePaint(); spread.resumeEvent(); // 延迟到下一帧再解除 applying 状态,保证同一批次事件不会穿透 requestAnimationFrame(() => { if (token === remoteApplyToken) { applying = false; } }); } }requestAnimationFrame在这里是故意做的延迟恢复。SpreadJS 的事件触发可能不只在 setValue 的调用栈里同步完成,有些事件机制会在同一帧内延迟派发,单纯同步恢复applying是拦不住漏网事件的。加一个 rAF,基本能堵住 99% 的回写问题。
3.5 第五步:冲突处理、回滚提示与断线重连
当服务端拒绝了某个操作时,客户端不能假装无事发生。我的策略很简单:把本地还没提交成功的操作打回,重新同步服务端最新状态,然后弹一条提示,告诉用户“该单元格已被他人更新,请基于最新内容继续操作”。
具体到回滚动作,不要做太复杂的逆操作,直接对你关心的最小区域重新执行一次sheet.getValue(row, col)并更新一次样式,把界面拉回到服务端真实状态。这样做比写一套通用“反操作”机制简单得多,也不容易引入二次冲突。
断线重连是我做了好几轮迭代才理顺的模块。客户端要维护一个本地待发队列pendingOps,网络断开后,本地操作先进入队列,界面切换为“离线编辑中”。重连时携带fromVersion发hello,服务端返回快照和增量,客户端先把远端增量应用完,再尝试补发pendingOps。如果某个本地操作因为版本落后被拒绝,就丢弃该操作并提醒用户,而用户自己决定是否重做。
还要加心跳机制。每 30 秒发一次 ping,连续 3 次没有响应就判定连接断开,进入重连流程。重连的最大等待时间可以设 10 秒,如果超过就显示“重新连接”按钮,让用户手动触发。
4. 常见问题与排查技巧实录
4.1 页面白屏或设计器渲染不出来
这个是让我花过最多时间排查的问题。总结起来,大概有四个原因:
容器高度为 0。Designer 依赖容器高度计算内部渲染尺寸,如果外层布局是 flex 并且子元素没有高度,得到的就是一块空白区域。解决办法是给容器显式设置height: calc(100vh - 50px)之类。
CSS 资源没加载完整。Designer 自带一套工具栏样式和图标字体,漏引样式会导致工具栏图标不显示甚至布局错位。要按官方文档顺序引入,并且放在初始化之前。
授权信息不一致。核心库和设计器组件如果用了不同版本的授权,运行时不会立刻报错,但可能渲染完成后无响应。控制台如果有水印或者初始化静默失败,第一步检查授权。
重复初始化。在热更新或者二次进入页面时,如果同一个容器被初始化了两次,新的 Designer 实例覆盖不了旧的实例。需要在切换路由前调用销毁方法,并清空容器。
4.2 协作时操作被反复广播,像滚雪球一样
这个问题其实就是回写风暴。我见过不止一次,表现是多端连接后操作数量呈指数增长,最终服务端日志刷屏、客户端卡顿。
排查时可以先用“只开两个客户端”测试,观察 WebSocket 消息循环里是不是每一条 op 都重复出现。最常见的原因是applying标志恢复过早,或者远程操作触发了本地事件后没有过滤。按照前面讲的suspendEvent + rAF 延迟恢复方案,基本能解决。
还有一个辅助手段:客户端发送 op 前检查fromVersion,只有当前版本大于等于已确认版本时才允许发送。这样可以保证旧版本的残留事件不会再次触发。
4.3 多人同时拖拽填充、复制粘贴导致数据错乱
SpreadJS 的拖拽填充会触发多个单元格的事件,如果我在每个事件里立刻发送一个独立 op,服务端会得到很多片段式操作,广播时其他客户端会先看到一半的填充结果,画面非常别扭,还容易导致版本错乱。
我的方案是做批量缓冲。用一个队列累积短时间内的操作,每 50ms 或一帧结束批量提交一次。这样拖拽填充产生的几十个单元格修改,最终会变成一批原子操作提交到服务端,其他客户端也一次性看到完整结果。
let batchOps = []; let batchTimer; function queueOp(op) { batchOps.push(op); clearTimeout(batchTimer); batchTimer = setTimeout(() => { if (!batchOps.length) return; ws.send(JSON.stringify({ type: 'op', docId, fromVersion: localVersion, ops: batchOps.slice() })); batchOps = []; }, 50); }这个做法有一个小代价:单次操作会带来 50ms 的提交延迟。但对企业协同设计器来说,50ms 完全在可接受范围内,它不是实时游戏,不需要追求毫秒级广播。
4.4 断线重连后本地操作丢失
丢失操作是协同系统里最影响信任感的问题。用户明明看到“已保存”,重连后内容却没了,这在企业环境里是不可接受的。
排查时要区分两种情况:客户端发送了 op 但服务端没收到;客户端发送了 op 但版本过期被拒绝。前者的处理是,本地写一个操作日志,记录最近 500 条 op 落库到 localStorage,重连后如果发现服务端版本落后,可以从本地补发。后者只能提示用户手动处理,因为版本冲突时已经无法判断哪个操作是对的,必须让用户基于最新状态重做。
我最终方案是让“离线编辑中”这个状态足够显眼,用户感知到自己处于离线缓冲区。这样即使某个操作因为冲突无法合并,至少用户有心理预期,不至于突然发现修改消失而投诉。
5. 我在落地这套方案时的经验补充
5.1 协作粒度:锁是简单且实用的共识
如果你要从中选择一个最重要的设计决策,我会说是“锁 + 操作日志”的组合,而不是盲目追求实时的无冲突合并。企业内部编辑电子表格,绝大多数场景是少数人同时操作系统,尤其涉及财务、库存、项目排期时,用户反而希望看到明确的锁定提示。锁带来的安全感和确定性,往往比“两个人同时改最后自动合并”更受欢迎。
用锁也能把后续复杂性控制得很好:操作日志是未来的数据结构基础,即使以后要升级到 OT 或 CRDT,数据模型不需要推翻,只是把冲突处理层替换掉而已。
5.2 几个容易忽略的坑:快照更新、undo 与导入文件
快照更新不能贪频繁。全量 JSON 快照会对数据库和网络造成压力,我一般设两个条件:每积累 200 条操作,或者每 5 分钟,先到先触发。恢复时再基于快照补增量,这样恢复速度可控。
undo/redo 在协同模式下要非常小心。SpreadJS 自带的 undoManager 是本地模型层面的,不会感知服务端版本。一旦多人操作,本地 undo 可能把别人的修改也撤销掉。我在 MVP 阶段是关掉 undo 的,只支持“撤销自己最近一次被服务端确认的操作”,否则后患无穷。
导入 Excel 时要先把 workbook 切换成“离线批量模式”,等fromJSON完全执行完,再把结果作为一次快照同步给所有人。不要在导入过程中逐事件广播,否则会把一个大文件切出几千个小操作,把服务端拖垮。
5.3 后续值得扩展的方向
这套方案跑通之后,还可以往几个方向扩展:协同光标,用来显示别人正在编辑的单元格位置,这需要在 SpreadJS 之上做一层 overlay;评论和批注,把协作从“改数据”扩展到“讨论数据”;更细的审计日志,按用户维度按时间线回溯每次操作;以及条件格式层面上的只读区域视觉提示。
另一个方向是文档模板管理与版本回滚。每次快照生成时保留一个带版本号的还原点,配合操作日志,可以让管理员一键恢复到任意历史版本。这些功能都是企业级应用的刚需,而且都能在当前架构上平滑叠加。