上个月我们团队接了个需求:客户要一套在线表格系统,几十个编辑者同时改同一份预算模板,改动得实时可见,还要能追到谁在哪个版本改的。我们一开始以为SpreadJS自带设计器,嵌进去就完事。单机版确实当天就跑通了,但真正让人掉头发的是后半句——"多人同时改,不能互相覆盖"。这就是这篇文章的来源:怎么在SpreadJS设计器之上,从零到一搭出一套企业级协同设计器。
我默认看这篇文章的你已经了解SpreadJS是什么——简单说,它把Excel级别的公式、图表、样式、行列操作都搬到了浏览器里,前端表格控件里算是最贴近桌面体验的一档。如果你完全没接触过,也没关系,我会把协同相关的关键概念解释清楚。这篇不打算讲太多官方功能清单,重点放在"协同层"怎么设计、协议怎么定义、服务端怎么存、客户端怎么同步,以及我实际踩过的坑。适合正在做在线表格、协同文档、内部数据填报平台,或者想给SpreadJS加多人编辑能力的人参考。
1. 先想清楚再做架构:协同设计器到底在协同什么
1.1 "能用Excel编辑"和"能多人一起编辑"是两个问题
很多团队第一次评估SpreadJS时,会被它的编辑体验误导——既然单元格能选中、能输入、能拖动填充,那只要把设计器嵌进网页,再把文档存到服务器,不就是在线表格吗?
差远了。单机设计器所有操作都是本地闭环:用户在界面上改个单元格,SpreadJS内部改了自己的数据模型,界面立刻刷新,根本不需要"同步"这个概念。一旦引入多人,问题就变成了:你改了单元格C5,凭什么让三公里外的另一个同事屏幕上的C5也跟着变?他也在改C5怎么办?他删了一行,你恰好正在这一行上输入,你输入完数据往哪放?
所以协同设计器要协同的,不是"文档"这个大文件,而是每一次对文档的局部操作。版本、顺序、冲突、回放,这四件事才是架构的起点。
1.2 企业级协同的三个硬指标
个人工具和团队工具体感上差很多,落到技术指标上主要就三条:
一致性。所有在线编辑者最终看到的文档必须收敛到同一个状态。不能出现你在你屏幕上看到的合计是100,我在我屏幕上看到的是98,两个人还都觉得自己的是对的。
可追溯。出了问题要能回答"这个数是谁在什么时候改的""之前那个版本是什么"。这是企业财务、预算场景的刚需,不是附加题。
权限可控。谁能打开这份文档、谁能改这张表、谁只能看不能动,必须能在协同链路里生效。别等上线以后才发现只读用户可以通过编辑操作把数据改了。
这三条决定了后续每一个技术选型。不搞清它们,后面做的任何协同方案都是空中楼阁。
2. 整体方案:快照+操作指令流,服务端串行化绕开OT泥潭
2.1 三种协同路线,我为什么选中"服务端串行化"
做多人实时编辑,绕不开两类经典方案:操作转换(OT)和无冲突复制数据类型(CRDT)。
OT是Google Docs那一脉的思路,每个客户端基于自己看到的版本产生操作,服务端收到冲突操作后,通过转换算法把操作改成"基于最新版本也能正确执行"的形态。听起来完美,但表格比纯文本复杂太多——合并单元格、行列增删、样式覆盖、公式引用,每种操作都要定义与另一种操作的转换规则。我见过团队做了一年,还只覆盖了文本插入删除。CRDT同样如此,它要维护一套可交换、可结合的并发数据结构,对纯文本、JSON这种树状模型友好,对表格这种二维并带依赖关系的模型,复杂度只会更失控。
我选的是一条"看起来没那么聪明但工程上极其稳"的路线:服务端串行化。所有编辑操作统一上报到服务端,服务端按到达顺序逐条应用,给每条操作分配一个单调递增的版本号,再广播给房间内其他客户端。客户端本地操作先乐观显示,等服务端确认以后才真正"落账"。
这么做牺牲了一点点"两个人同时改同一个单元格时互不干扰"的极致体验,但换来的是:没有复杂的转换算法、不会出现分叉状态、审计回放天然有序,整个系统的正确性几乎只需要保证"服务端按顺序执行",这对企业级场景是决定性的优势。很多号称轻量协同的在线表格工具,用的都是这条务实路线。
2.2 协同数据流全景图
整个系统跑起来以后,一次编辑操作的生命周期是这样的:
- 用户在SpreadJS设计器里修改一个单元格;
- 前端协同SDK捕获这次操作,转成与界面无关的协议消息;
- 消息带着当前客户端已知的文档版本号
baseVersion发到服务端; - 服务端检查
baseVersion是否等于文档最新版本,通过校验后版本号+1,追加写入操作日志; - 服务端把带有新版本号的操作广播给除发送者以外的所有在线客户端;
- 其他客户端收到广播,把操作应用到本地SpreadJS实例,界面更新;
- 发送者收到确认回包,从本地未确认队列里移除这条操作。
快照服务在什么时候出场?一个新成员加入、或者一个客户端断线重连,需要拿到文档当前状态。这时服务端直接发一份spread.toJSON()生成的快照,客户端fromJSON()加载即可。快照+增量操作日志的组合,相当于"用版本控制的思想存表格"。
2.3 模块边界:设计器=编辑底座,协同层只翻译操作
架构上最忌讳把协同代码和SpreadJS界面写成一锅粥。我把整个系统拆成三层:
- 编辑底座:原生的SpreadJS设计器,负责渲染、交互、公式计算、数据模型,它完全不感知"有人和我一起编辑";
- 协同SDK:挂在设计器旁边的独立模块,负责监听操作、转换成协议、维护未确认队列、处理服务端广播并回灌到SpreadJS;
- 协同服务端:文档存储、版本控制、广播、权限校验,对前端完全不依赖。
这么拆的好处是,协同层出问题,SpreadJS还在,用户至少还能单机编辑;反过来SpreadJS升级版本,协同层只需要重新映射操作参数,不用推翻重来。
3. 第一步实操:把SpreadJS设计器改成"命令可监听"的协同底座
3.1 初始化带命令监听的设计器
如果你只用编辑器基础包,拿到的只是一个渲染画布,要自己堆工具栏。真正做协同,我建议直接上设计器包——它自带工具栏、剪切板、格式刷、查找替换这些企业用户离不开的功能。
初始化很简单:
import { Designer } from 'spread-designer-sdk'; import '@spreadjs/designer-resources/dist/gc.spread.sheets.designer.min.css'; const host = document.getElementById('designerHost'); const designer = new Designer(host, { sheetPanelVisible: true, allowUserDump: false, }); const spread = designer.getWorkbook(); spread.fromJSON(initialDocJson); // 首次加载快照需要注意的是,设计器初始化完成前不要调用fromJSON,否则会拿到一个半初始化实例。我习惯在designerInit事件之后再灌数据。
3.2 从CommandManager拿操作,而不是从界面事件里猜
SpreadJS暴露了两类接入点:一类是细粒度事件(ValueChanged、RangeChanged、RowChanged),一类是命令管理器(CommandManager)。如果目标是协同,核心接入点是命令管理器,不是事件。
为什么这么说?事件描述的是"数据变化后的结果",比如ValueChanged只告诉你哪个单元格值变了,但不告诉你这是用户输入的、粘贴进来的、还是公式联动算出来的。而命令描述的是"用户干了什么"——Worksheet.setValue、ClipboardPaste、insertRows,每条命令还带参数。协同同步"用户的意图"远比同步"数据的最终结果"可靠,撤销重做、合并操作、审计这些都依赖命令这个粒度。
监听命令执行也很直接:
spread.commandManager().addListener('commandChanged', (args) => { const { command } = args; if (args.action === 'execute' && command.canUndo !== false) { // 用户真实执行了一条可记录命令 handleUserCommand(command); } });这里有个关键过滤条件:action === 'execute'。撤销时会触发action === 'undo',重做会触发action === 'redo',这些在协同模式下要单独处理,不能当新操作广播,否则会出现"我撤销一个操作,你那边却新增了一个操作"的错位。
3.3 先把快照序列化这条命脉打通
协同系统最重要的基础能力不是WebSocket,是序列化。SpreadJS提供了toJSON()和fromJSON(),这俩是快照同步的命脉。
// 保存快照 const snapshot = spread.toJSON({ includeBindingSource: true, includeStyles: true, includeCalcModelCache: false, }); // 恢复快照 spread.fromJSON(snapshot);实际项目中要注意:快照体积比你想象的大,一张中型报表几十MB很常见,因为里面带着样式、公式、数据验证、图片等所有资源。所以快照不能每次操作都整体传,它只用在首次加载、断线重连、服务端落盘这几个低频场景,高频路径全走操作指令。
到这一步,你已经有"能跑、能被监听、能被序列化"的单机底座了。接下来做协议。
4. 第二步实操:设计一套能覆盖90%编辑场景的协同操作协议
4.1 消息结构:操作也要有"身份证"
协同消息就是定义一套JSON格式,但格式本身也分好设计差设计。我最终定下来的核心字段:
{ "docId": "doc_budget_2024", "baseVersion": 37, "opId": "uuid-xxxx", "opType": "setValue", "editor": { "userId": "u_1024", "userName": "李明" }, "payload": { "sheetIndex": 0, "row": 4, "col": 2, "value": "120000", "isFormula": false }, "timestamp": 1720000000000 }opId必须客户端生成全局唯一,服务端用它做幂等去重——网络重试时不能让同一条操作被执行两次。baseVersion是客户端发起操作时认为的服务端最新版本,这是并发控制的核心。opType和payload是真正要执行的编辑意图,后面细说。
4.2 六个核心操作类型,先做这几个再扩展
SpreadJS命令面很广,但把企业表格日常使用高频操作收敛一下,可以浓缩成以下六类:
| 操作类型 | 对应SpreadJS命令 | payload要点 | 说明 |
|---|---|---|---|
| setValue | setValue | sheetIndex、row、col、value、isFormula | 单元格输入,最频繁 |
| rangeEdit | editCell / fill | range、value | 区域填充、批量输入 |
| rowOp | insertRows / deleteRows | sheetIndex、row、rowCount、action | 行增删 |
| colOp | insertColumns / deleteColumns | sheetIndex、col、colCount、action | 列增删 |
| styleOp | setStyle / setBorder | sheetIndex、range、styleJson | 样式变更 |
| floatOp | addFloatingObject / updateFloatingObject | sheetIndex、objectId、type、props | 图片/形状/图表 |
把高频操作收敛成这六个类型,协同协议才能约束住。如果你把所有SpreadJS命令都原样转发,服务端就成了一条什么逻辑都没有的转发管道,后面冲突处理、权限控制、中间存储都会失去着力点。
4.3 命令到协议的映射,关键在一个"反查"动作
监听拿到command以后,怎么转成协议消息?我写了一个映射层,核心逻辑是:
function mapCommandToOp(command) { const { commandName, sheetName, ...args } = command; const sheetIndex = getSheetIndex(sheetName); switch (commandName) { case 'Worksheet.setValue': return { opType: 'setValue', payload: { sheetIndex, row: args.row, col: args.col, value: args.value, isFormula: args.isFormula !== false } }; case 'Worksheet.insertRows': return { opType: 'rowOp', payload: { sheetIndex, row: args.row, rowCount: args.rowCount, action: 'insert' } }; case 'Worksheet.deleteRows': return { opType: 'rowOp', payload: { sheetIndex, row: args.row, rowCount: args.rowCount, action: 'delete' } }; // ... 其他命令类似 default: return null; // 未被映射的命令,走能力降级 } }注意一个细节:value里如果带公式,SpreadJS返回的是=SUM(...)这种字符串,我用isFormula字段单独标记。原因是公式和纯文本的同步路径完全不同——公式要原样广播,每个客户端本地用实时计算引擎算出结果,绝不能把A端已经计算好的结果值广播出去。
4.4 能力降级:不支持的编辑操作怎么收口
总有一些命令你不想映射,比如高级数据透视表、切片器、复杂的条件格式规则。协同的边界不是"什么都支持",而是"明确什么不支持,并且不让它破坏一致性"。
我的策略是在协同模式下做命令拦截列表,白名单之外的操作直接禁止并弹提示"该操作在协同编辑中暂不支持"。另一种更温和的方式是检测到未映射操作时,服务端强制全量快照对齐一次——你可以理解为"虽然我不知道你干了什么,但每次全量同步我都能回到正确状态"。
这两个策略选择哪个取决于团队排期。我自己的项目里,先用了拦截策略保证一致性,后续每迭代一版才放开一组命令。
5. 第三步实操:服务端文档存储、CAS并发控制与操作广播
5.1 快照+操作日志,文档存储的"Git思想"
服务端的存储结构,我借鉴了Git的思路:全量快照相当于commit,操作日志相当于diff。每个文档维护三样东西:
doc表:文档元数据,包括当前版本号、快照地址;snapshot表:某个版本时刻的spread.toJSON()全量数据,存在对象存储里;op_log表:从上一个快照之后的所有增量操作。
平时编辑产生的操作只追加op_log,每累计N条操作(我这边取的200条)或在文档空闲时,生成一次新快照并清空旧的日志区间。这样加载文档时,取的是"最近快照+从快照版本到当前版本的操作回放",而不是把历史所有日志从第一天开始重放。
5.2 提交接口与CAS并发控制
服务端提交操作的接口,我核心就做一件事:版本号CAS检查。客户端的baseVersion必须等于文档当前版本,否则操作作废返回冲突。
// 伪代码,实际会带事务和锁 async function submitOp(docId, op) { const doc = await docStore.get(docId); if (op.baseVersion !== doc.version) { return { status: 409, currentVersion: doc.version, message: 'version conflict, need rebase' }; } const nextVersion = doc.version + 1; await opLogStore.insert({ docId, version: nextVersion, opType: op.opType, payload: op.payload, editor: op.editor, timestamp: op.timestamp, opId: op.opId, }); await docStore.updateVersion(docId, nextVersion); broadcast(docId, { ...op, version: nextVersion, status: 'applied' }, op.editor.userId); return { status: 200, version: nextVersion }; }这个设计的威力在于:并发冲突被显著简化了。两个客户端同时基于版本37提交操作,服务端只会让先到的成功变成38,后到的拿到409,必须基于版本38重做。虽然牺牲了一点并发度,但彻底避免了"两个操作不同顺序应用产生两种结果"的分布式一致性噩梦。
5.3 服务端的操作白名单校验
不要信任任何客户端。我在服务端维护了一份操作类型白名单,并做基础参数校验:
setValue的row、col必须是数字且不能超过预设最大范围;isFormula为true时,value必须以等号开头;- 行列增删的
rowCount必须在合法区间; - 样式操作的大小限制,防止有人一改就是几十MB。
这些校验不是为了防黑客,而是防三类真实场景:前端Bug发出的畸形消息、客户端断线期间积压后重连造成的重复消息、恶意用户绕过前端直接往WebSocket塞垃圾数据。企业级系统的稳定性,很多时候是靠服务端这种"不信任任何人"的校验撑起来的。
5.4 操作广播:把版本演进推给所有在线客户端
服务端广播用WebSocket,我直接在Node.js里挂房间(room)的概念:每个docId对应一个房间,持有正在编辑这份文档的所有连接。提交成功后,遍历房间内除发送者外的所有连接,发送{ type: 'op.applied', ...op }。
注意广播里必须带上version,客户端要靠它对齐本地版本。还有一点容易被忽略:广播是有可能先于发送者的HTTP回包到达其他客户端的,所以接收方不能认为"收到op.applied就说明我的本地已确认操作可以出队了"——出队只依赖自己的回包,跟别人收到没有关系。
6. 第四步实操:客户端同步队列、断线重连与冲突重放
6.1 WebSocket会话与房间管理
客户端的WebSocket管理逻辑比服务端复杂,因为它要处理网络抖动。我封装了一个CollabSession,职责是:
- 建立连接,发送
{ type: 'join', docId, token }加入房间; - 维护心跳,每30秒ping一次,连续三次pong超时就触发断线重连;
- 断线后指数退避重连,重连成功发送
{ type: 'resync', lastVersion },服务端返回缺失的操作或全量快照。
房间内的用户进出要感知,否则没法展示在线人头。服务端在join/leave时广播一份presence消息,客户端维护用户列表即可。
6.2 本地乐观更新+未确认队列
协同体验的核心是"不能等服务端返回才让用户看到结果"——任何大于100毫秒的等待都会让输入框显得卡顿。所以客户端本地操作采用乐观应用:用户在单元格输入,SpreadJS本地数据模型立刻更新,同时协同SDK把这条操作放入pendingOps队列并发送服务端。
function handleUserCommand(command) { const op = mapCommandToOp(command); if (!op) return; // 能力降级或忽略 op.opId = uuid(); op.baseVersion = currentVersion; op.timestamp = Date.now(); pendingOps.push(op); send({ type: 'op.submit', docId, op }); }这里有个重要设定:当pendingOps不为空时,新操作必须以"队列最新版本"作为baseVersion继续提交,而不是用服务端可能已经落后的currentVersion。否则高输入频率下几乎每次都会409。
6.3 版本对齐与冲突重放
收到服务端确认回包,把对应opId的操作移出队列,currentVersion加一。
收到远端广播op.applied,则要把这个操作应用到本地SpreadJS实例。但这里有个先后问题:如果本地队列里还有未确认操作,直接应用远端操作会让两个状态叠加错位。正确的做法是先重放,再应用,再恢复:
- 把
pendingOps里的本地操作逐个从SpreadJS中逆向撤销(用SpreadJS的undo命令栈,或直接重新注入旧值); - 应用远端操作到SpreadJS;
- 基于新的
currentVersion,重新执行pendingOps里的所有本地操作。
这套"缓存-回滚-应用-重放"就像一个简化的协作编辑器rebase流程。如果重放后发现本地操作和远端操作改的是同一个单元格,那么后执行的本地操作以本地当场值为准(即覆盖远端值),同时弹出一条轻量提示。
function applyRemoteOp(op) { const localPending = pendingOps; pendingOps = []; localPending.forEach(p => undoLocalOperation(p)); applyOpToSpread(op); // setValue / insertRows / ... currentVersion = op.version; pendingOps = localPending.map(p => ({ ...p, baseVersion: currentVersion })); pendingOps.forEach(p => replayLocalOperation(p)); }这个方案不完美——两个用户同时改一个单元格,后到者会覆盖先到者的改动。但在企业场景里,这种冲突有完整操作日志兜底,比OT那套"我不小心把你的字合并了"要透明得多。
6.4 断线重连与补偿同步
断线期间用户可能继续编辑,本地pendingOps越积越多,但baseVersion已经落后。重连以后,我们不直接把这些操作发出去,而是先做补偿同步:向服务端要lastVersion+1到最新版本的差量操作,先把这些远端操作回放进本地,再对pendingOps逐一rebase提交。
如果差量太大(比如断线几个小时,期间几百条操作),直接放弃差量,用全量快照重载,然后重放本地操作。快照优先还是差量优先,判断标准是操作条数,我设的阈值是100条,超过就走快照。
6.5 在线用户和光标状态
协同编辑器没有光标位置信息,会让用户不知道别人在改哪。实现不复杂:SpreadJS支持选区获取,客户端在SelectionChanged事件里发送自身当前sheet和activeCell,广播其他用户后,在界面上渲染一个带用户名的小标签。三色以上颜色要提前定好,每人分配一个固定颜色,才能快速识别"谁在动这一片"。
这个功能技术上不难,但对"协同感"的提升远大于成本。用户反馈里,看到别人光标在旁边移动,比看到数据突然变数字要安心得多。
7. 第五步实操:权限、审计、性能优化,补齐企业级短板
7.1 权限模型:按文档、按区域、按角色
协同链路里权限要覆盖两级:能不能打开这份文档,以及打开以后能做什么。前一级用token鉴权,在join时校验;后一级是操作级权限,在服务端CAS校验之后、写入日志之前执行。
我落地的模型是这样:
- 文档级别:owner/editor/viewer三种角色,owner可分享、可删除;editor可编辑;viewer只读;
- 区域级别(可选):某些sheet可以锁定给特定用户组,其他人只读。比如预算表总表只有财务部能改,各部门只能改自己的分sheet。
服务端在每次submitOp时,根据op里的sheetIndex和userId做区域校验,不通过直接返回403。这套校验放服务端而不是前端是为了防被绕过。
7.2 操作审计:谁在什么版本改了什么
操作日志本身就是审计基础。落地审计能力,我只需要把两件事做好:
一是日志不可丢。操作写入日志和更新版本号要在一个事务里,不能出现版本号更新了但日志没写入,否则快照回放会少一段。
二是查询接口。审计页面可以按时间、用户、sheet、操作类型过滤,返回每条日志的opId、版本号、操作内容、时间戳。最直接的价值体现是:客户质问"这个数谁改成负数的",我们30秒内就定位到了具体人和具体操作,还附带版本号可以一键回放现场。
7.3 高频操作合并与首屏性能优化
两个常见的卡顿点,我实测下来必须处理。
第一是连续输入的摩擦:用户快速输入一列数据,会产生几十条setValue操作,每条都广播,其他客户端界面会被频繁重绘。我在协同SDK里加了一个合并层:300毫秒内同一sheet同一用户的连续setValue,合并成一条批量操作(payload改成一个数组),只提交和广播一次。这让网络流量下降至少一半,同时不牺牲实时性。
第二是首屏加载速度:快照JSON几十MB,直接发到浏览器,加载期间用户只能干等。优化方案是先发一个去样式、去绑定源的精简快照(通常能砍到几MB),用户看到数据以后在后台异步补全样式和图表。对几百人同时在线的大文档,这个优化能让首屏时间从十几秒降到两三秒。
7.4 权限与体验在命令层的落地
前端体验也要配合权限,不能让viewer用户选中单元格还弹编辑框。我在设计器初始化后,根据当前用户角色动态配置:
- viewer用户:开启
spread.options.isProtected = true,锁定所有sheet,屏蔽工具栏编辑按钮; - editor用户但某区域只读:用SpreadJS的
sheet.setIsProtected(true)配合允许编辑区域白名单。
协同层和权限层在命令监听处汇合:如果命令映射出的操作会被服务端拒绝,前端就提前提示"你没有权限编辑这个区域",避免用户操作半天被静默驳回。
8. 踩坑实录:六个最容易翻车的瞬间
8.1 回车键引发"事件风暴"
第一次联调时,测试同事按了一下回车,服务端日志瞬间多了六条操作。查下来发现:回车键在SpreadJS里同时触发编辑结束、单元格移动、选区变化、值更新等多个事件,而命令监听器捕获到了两个不同命令。这类"一次用户动作产生多次命令"的情况非常普遍。
解决方案是在命令监听入口做语义聚合:短时间内属于同一用户意图的命令,合并为一条协议操作。我加了60毫秒的聚合窗口,窗口内相同单元格的命令只保留最后一个,然后统一提交。
8.2 中文输入法把拼音广播出去了
我们第一版上线,几个中国客户反馈"打字过程中经常出现一堆字母往屏幕上蹦"。原因是composition事件没处理。用户在输入法组词时,SpreadJS会通过ValueChanged把拼音字母作为临时值写入单元格,这些临时值的变更全被当成了协同操作广播出去。
修复方式是监听文本输入法的compositionstart和compositionend,在composition期间,协同SDK对当前单元格的命令监听直接挂起;等组词结束,SpreadJS最终触发一次真实命令时再提交。
8.3 撤销/重做与远端操作互相拆台
单机模式下,撤销是"撤回我自己的上一步操作"。协同模式下这变得很危险——你撤回自己操作时,有可能把别人刚提交的远端操作一起撤销了,因为SpreadJS的撤销栈是全局的。
我的做法是:协同模式默认禁用UI上的撤销/重做按钮,服务端提供"撤回我的最后一条操作"的接口,实际上就是提交一条反向操作(旧值覆盖新值),跟普通协同操作一样走版本和广播。这个变更让"撤销"这件事变得可审计、可追溯,也不影响别人。
8.4 断线重连后的"未来操作"先于"历史操作"到达
断线期间客户端积压了操作,重连时服务端先补发了期间漏掉的历史操作,随后客户端又提交了积压的"未来操作"。如果服务端收到积压操作时它的baseVersion恰好等于当前版本,一切正常;但如果同一时刻另一个客户端提交了新操作,这个积压操作就拿到409。
这个坑我踩了很多次才意识到:重连之后的第一次动作应该是同步状态,而不是提交积压操作。客户端重连后先主动进入"resync"状态,服务端补发完差量并确认客户端对齐版本后,客户端才开始重放积压操作。顺序错了,后面全是版本冲突。
8.5 公式单元格不能只同步计算结果
有次实轧,A用户在B2写了个=SUM(C2:C10),B用户那一侧看到的值也对,但点进B2显示的是数字而不是公式。定位发现是setValue映射时把isFormula判断漏了,值被当作纯文本同步过去。
公式必须同步"公式字符串",让每个客户端用自己本地引擎计算,而且公式引用到的单元格发生变化时,每个客户端会自动联动重算。这部分SpreadJS替我们省了巨量工作——如果它不做本地计算引擎,协同表格的公式推导将是灾难级复杂度。
8.6 冲突提示如果设计得不好,用户会直接关页面
最开始冲突提示弹的是"操作冲突,请刷新页面",结果客户电话被打爆。后来我把提示改成了信息型的:"你的修改与另一位同事的修改发生在同一单元格,已保留最新修改,你可以通过版本记录找回被覆盖内容。"
同样是冲突,有信息量的提示和无信息量的提示,用户体验差了一个数量级。协同系统让用户看到冲突不可怕,可怕的是让用户感觉数据丢了、工作白干了。每一次冲突恢复路径都要展示出来,这是线上事故和可接受体验的分界线。
最后聊一点我的个人体会。做完这套协同设计器,最大的感受是:协同不是给SpreadJS加一个WebSocket,而是把单机编辑的每一个"沉默假设"都翻出来重新审视一遍——事件合并、输入法、撤销栈、版本冲突、公式传播、断线恢复,任何一个单机环境下不会响的问题,在协同环境里都会被无限放大。如果准备做类似系统,建议先把第2节的架构目标定清楚,尤其是"接受后到覆盖、服务端串行化"这个妥协是否能被业务接受。我自己现在的习惯是优先把协议、版本、日志三层打通,再去完善那些炫酷的在线光标和实时状态展示——底层不一致,上面一切都白搭。这套方案目前在我们的预算填报和报表评审场景里跑了半年多,最复杂的几十人同时改一份大表也能稳定收敛,希望这些经验对你的项目有实际帮助。