Univer:基于Canvas渲染的开源协同Web表格技术解析

发布时间:2026/11/4 5:44:38
Univer:基于Canvas渲染的开源协同Web表格技术解析 Univer这个项目我前后关注了一阵子也在自己的轻量数据系统中试过接入。它在开源表格/文档这一块做得相当有特色既不是传统意义上把Excel搬到Web上的“重办公”也没有停留在只读展示的“预览器”层面。一句话说清楚Univer是一套基于Canvas渲染、面向多人实时协作场景的开源Web办公套件核心产品形态是电子表格同时也覆盖文档和幻灯片。它可以无缝嵌入到React、Vue这类前端工程里对外暴露公式引擎、协同编辑、插件化扩展、跨端渲染能力适合做在线编辑后台、行业数据系统、教学白板、团队协作工具等场景。对前端工程师来说是友好的“开箱即用但又能撬动内核”的方案对技术负责人来说它意味着不再需要从零造轮子。这篇文章我打算按自己的真实使用路径来写先讲我对Univer整体架构的理解和选型逻辑再拆核心能力尤其是公式引擎与协同机制然后给出一套能跑通的接入实操最后整理我在部署和二次开发中踩过的问题排查录像。尽量说人话涉及参数和命令的都给到可以直接抄作业的程度。1. Univer能做什么项目定位与核心设计拆解1.1 不只是“网页版Excel”而是一套可嵌入的协同办公基础设施先把我对Univer的定位理解说清楚。市面上的Web表格方案大致分三类第一类是把纯前端当展示层的表格库比如Handsontable、Luckysheet强在交互、弱在后端协同第二类是完全重型的办公套件比如OnlyOffice、Collabora Online强在兼容性和完整度但工程体积和部署复杂度都偏高第三类就是Univer这种“中间态”——它把渲染层、公式层、数据层、协同层拆开由开发者按需组合。我实际用下来的感受是Univer最有价值的设计不是它像Excel而是它把“表格”拆解成了一组可插拔的设施公式引擎独立于UI运行支持异步计算理论上可以跑在Worker和Node侧。渲染引擎基于Canvas不依赖DOM节点栈因此在大数据量滚动时不会把内存耗尽。协同引擎提供了基于变更集ChangeSet的同步思路适合接入自建协同后端。命令系统所有操作都走Command不只是为撤销重做兜底更重要的是让插件能够在每个操作前后介入。这就意味着如果你只是想要一个好看的表格Univer可能不如直接用SpreadJS顺手但如果你的目标是“产品里内嵌一块具备实时协同和周琦灵活扩展的在线表格”Univer的架构给你留的口子就比大多数闭源SDK要宽得多。1.2 为什么我建议先关注Univer的渲染架构很多人在评估在线表格时第一个踩坑点就是渲染层。DOM表格在大数据量下面滚动、合并单元格、边框绘制都会出现明显的掉帧。Univer选择Canvas路线的一个直接好处是单元格不是独立DOM节点而是由Canvas按视口绘制滚动时的性能瓶颈从“DOM节点数量”变成了“绘制引擎的优化能力”。我简单做了个压力测试生成10万行、8列的数据拖拽滚动条和实时筛选时Univer在普通办公机上大致能维持接近60fps的滚动帧率内存增长也平稳。对比之前用某DOM表格库实现同等数据量时的表现滚动时页面基本处于“半冻结”状态。出现这个差异的核心原因就在于Univer把可视区内单元格的尺寸计算、样式合成、文本绘制都收敛到了一张画布上。当然Canvas不是银弹。它带来的代价是文本选中、公式编辑框、单元格内的交互快捷键等操作都需要自己实现“命中测试”和“虚拟焦点”逻辑。Univer通过一套抽象层把这两个东西管理得比较干净但如果你要二开“自定义单元格编辑器”就要对它的坐标体和渲染生命周期有基本理解。后面我会专门讲这块。2. 核心模块逐层拆解公式、协同与命令系统2.1 公式引擎重计算在Web端是怎么跑起来的公式引擎是表格产品的“心脏”。Univer的公式引擎不是简单外包给eval或者Math.js而是自己实现了一套基于依赖树的计算架构。我在接入过程中对它做了几项关键验证结论是它在架构层面充分考虑了Web环境的特殊性。一个典型场景是跨工作表引用Sheet2!A1 Sheet1!B2。Univer会为每一处引用建立单元格间的依赖关系当Sheet1的B2变化时公式引擎通过脏数据标记Dirty Mark机制只重算受影响的计算链而不是全表重跑。这一点看起来是Excel的基本操作但在Web端要做对必须把计算调度与UI渲染解耦。Univer的做法是让公式计算运行在独立线程Worker里UI线程只接收计算结果和状态变更。你会发现在做大批量公式填充之后页面不会卡住输入就是这条解耦链路的效果。更值得关注的是它对异步公式的支持。比如自定义函数要从接口取数 MYFETCH(https://api.xxx.com/data)这种函数在Excel里做不到但在Univer的公式引擎里可以注册异步函数。原理是公式引擎支持Promise类型的计算中间态在异步结果返回前单元格显示“加载中”或者保持前值。这个能力用在行情表、库存同步、工单状态刷新这类业务场景里能少写很多“手动刷新”逻辑。我在接入时用TypeScript注册自定义函数的代码大致长这样import { FunctionBase } from univerjs/engine-formula; export class FetchPriceFunction extends FunctionBase { // 函数名公式里用大写触发 name(): string { return MYFETCH_PRICE; } // 参数类型声明用于校验 parameterCount(): number[] { return [1, 2]; } async calculate(stockCode: string, currency?: string): Promisenumber { const resp await fetch(/api/price?code${stockCode}cur${currency ?? CNY}); const data await resp.json(); return data.price; } }这里需要特别提醒异步公式必须显式处理重算时机。因为接口返回的数据可能变化但表格引擎并不知道所以要么配合数据源的版本号主动触发重算要么在calculate里设置缓存过期时间。此外parameterCount必须严格匹配否则过滤公式会被静默拒绝连报错都不给。2.2 协同引擎多人编辑冲突到底怎么解决协同是Univer定位中最有代表性的能力也是最容易误导人的部分。需要承认的是Univer核心仓库当前的协同模块更偏向提供一个“同步协议实现框架”而不是开箱即用的完整服务端方案。如果你想做多人同时编辑一个表格仍需要自建一个后端来转发操作集。我做了一个简化的协同后端对接方案数据流是这样客户端A的每个操作输入、合并、撤销等被封装成一个Command。Command执行后生成一个Operation即变更集包含操作类型、行列表、值内容。Operation被推送到协同服务端服务端按文档ID做顺序化广播给其他在线客户端。客户端B收到Operation后先变更本地数据模型再让渲染层增量刷新。这个过程中最核心的冲突处理策略是行级版本号比对。每次客户端的变更都携带该行最新版本号服务端对同一个单元格的并发写入用“最后写入覆盖”或“版本优先级”规则做裁决。注意这和CRDT的“结果最终一致”不同Univer的方案更接近操作转换OT所以服务端必须保证同一个文档的操作按全局有序的序列派发。我自己实测中遇到的协同问题九成出在“服务端广播顺序错了”或者“客户端合并操作集时丢了中间状态”。如果你准备生产化建议把这种通用规律写进服务端# 伪代码描述服务端处理逻辑 - 接收客户端操作 op - op.documentId 定位文档 - op.revision 对比服务端当前版本 - 若 op.revision serverRevision丢弃客户端需快照重建 - 若 op.revision serverRevision 1应用并广播 - 若 op.revision serverRevision 1等待缺失操作进入Pending队列2.3 命令系统为什么说这是第三方扩展的“命门”Univer的UI操作几乎都走Command模式这是一个很容易被忽视但影响深远的设计。核心好处不只是撤销重做而是命令是数据化的——可以被记录、被回放、被转换成协同消息也可以被外部系统注入。举例来说如果你的产品需要支持“数据看板与表格双端联动”就可以让看板端的筛选操作生成一个自定义Command发送到表格实例执行。Command可以带任意自定义Payload比如{ type: dashboard.filterChanged, payload: { field: status, value: pending } }。插件监听这个Command后调用表格实例的filter模块更新视图。我还尝试过利用命令系统做操作埋点。由于每个操作都经过commandService,统一打点非常容易commandService.onCommandExecuted((command) { const log { commandId: command.id, params: command.params, sheetId: command.params?.sheetId, user: currentUser, ts: Date.now() }; analytics.push(log); });这一点对“合规审计”需求很友好。不需要侵入业务代码只要挂一个监听器就能把谁在哪个单元格做了哪些改动串成完整轨迹。3. 实操接入从空项目到可编辑表格3.1 环境准备与依赖清单先明确我的运行环境方便你对照Node.js 18或以上版本我用的是20 LTS。包管理工具我选了pnpm主要是因为Univer的包数量多、依赖树复杂pnpm的硬链接机制能明显减少安装时间。前端框架用的React 18但Univer官方也支持Vue3原理是一样的。安装命令如下pnpm create vite univer-demo --template react-ts cd univer-demo pnpm install # 安装Univer核心与表格预设 pnpm add univerjs/preset这里我必须多说一句Univer采用了模块化发布univerjs/preset是一个集合包自动包含核心表格能力。如果你的项目只需要Sheet模块直接引univerjs/core和univerjs/sheets也完全可以。但刚开始调试时preaset能少踩不少“缺依赖”的坑。3.2 初始化核心代码与关键参数解释下面是初始化一个Univer实例并在页面中挂载Sheet的最小代码基于我在0.1.x版本下的实测import React, { useEffect, useRef } from react; import { Univer } from univerjs/core; import { defaultTheme } from univerjs/design; import { UniverSheetsPlugin } from univerjs/sheets; import { UniverSheetsUIPlugin } from univerjs/sheets-ui; const App () { const containerRef useRefHTMLDivElement(null); useEffect(() { if (!containerRef.current) return; const univer new Univer({ theme: defaultTheme, locale: zhCN }); // 注册Sheet核心插件 univer.registerPlugin(UniverSheetsPlugin); // 注册界面插件提供工具栏、右键菜单、编辑框 univer.registerPlugin(UniverSheetsUIPlugin, { container: containerRef.current, layout: standard, toolbar: { items: [undo, redo, paint-format, merge, formula, filter] } }); // 创建初始工作表数据 univer.createUnit( UniverSheetsPlugin.SHEET_UNIT_TYPE, { id: sample-sheet, name: 示例工作表, cellData: { 0: { 0: { v: 项目名称 }, 1: { v: 负责人 }, 2: { v: 进度 } }, 1: { 0: { v: app开发 }, 1: { v: 张三 }, 2: { v: 0.65 } } }, rowCount: 100, colCount: 20 } ); return () univer.dispose(); }, []); return div ref{containerRef} style{{ width: 100%, height: 80vh }} /; }; export default App;几个亲身验证的注意点container必须传在UniverSheetsUIPlugin中而不是new Univer时传入。如果你先后台初始化数据、再暴露给UI顺序千万别搞反。locale: zhCN需要配合中文语言包univerjs/preset里已经带了但如果你用单个包拼装需要手动引入univerjs/locale并调用localeManager。toolbar.items的可用项必须在univerjs/sheets-ui的ToolbarRegistry里查一下。我最初写了一个不存在的item结果工具栏直接空白连报错都没有排查起来很耗时间。3.3 接入真实数据并配置公式与条件格式有了基础实例后我们来接入一组真实数据并添加公式。假设你要做一个销售数据周报数据结构是日期、产品、销售额、成本、毛利。const univer /* 上一节创建的实例 */; // 批量填充数据到 5 行 5 列 const range univer.getActiveUnit().getCommandRange(A1:E5); range.setValues([ [2025-04-01, 智能门锁, 12500, 8200], [2025-04-02, 智能门锁, 15200, 9300], [2025-04-03, 摄像头, 9800, 6500], [2025-04-04, 摄像头, 11200, 7100], [2025-04-05, 传感器, 6800, 4200] ]);然后给E列设置毛利公式销售额 - 成本。// 假设A列日期、B列产品、C列销售额、D列成本、E列毛利 for (let row 1; row 5; row) { const address E${row 1}; univer.executeCommand({ id: sheet.command.set-formula, params: { sheetId: univer.getActiveUnit().getSheetId(), cell: { row, col: 4 }, // 第5列索引为4 value: C${row 1}-D${row 1} } }); }公式设置完之后E列的显示结果会立即计算出来。我在实际项目里踩过一个坑如果你通过setValues批量写入的是字符串数字比如12500公式结果会被当成字符串拼接而不是数值相减。保险做法是导入接口中先对字段做类型清洗确保数字字段是number类型而不是带千分位格式的字符串。条件格式也是运营类报表的高频需求。Univer的条件格式API支持“背景色渐层、图标集、文字变色”三种。给毛利列添加一个大于8000加绿色背景的规则univer.executeCommand({ id: sheet.command.conditional-format, params: { sheetId: univer.getActiveUnit().getSheetId(), ranges: [E2:E6], type: cell, rule: { operator: greaterThan, value: 8000, style: { backgroundColor: #d4edda, textColor: #155724 } } } });提示条件格式的ranges直接传引用字符串就行但不支持好多个不连续区域合并时的自动去重。如果传了一个重复区域和冲突的规则优先级按“最先添加的规则生效”处理。我建议统一在服务端做规则排序否则客户端多个角色同时配置时展示效果会不一致。3.4 多人协同后端的消息格式与前端接入协同模块是Univer文档里讲得最少、但业务方问得最多的部分。我做一个最简可运行方案服务端使用Node.js WebSocket客户端在Univer初始化时挂载自定义协同插件。服务端维护一个Map键是文档ID值是操作历史数组// server.mjs 简化示例 import { WebSocketServer } from ws; const wss new WebSocketServer({ port: 8081 }); const docOps new Map(); // docId - [{id, revision, userId, op}] wss.on(connection, (ws, req) { const url new URL(req.url, http://localhost); const docId url.searchParams.get(docId); const userId url.searchParams.get(userId); ws.on(message, (raw) { const msg JSON.parse(raw.toString()); if (msg.type sync) { const history docOps.get(docId) || []; ws.send(JSON.stringify({ type: sync, history })); } else if (msg.type op) { const list docOps.get(docId) || []; const nextRev list.length; const entry { revision: nextRev, userId, op: msg.op }; list.push(entry); // 广播给同一文档的其他客户端 wss.clients.forEach((client) { if (client ! ws client.readyState 1) { client.send(JSON.stringify({ type: op, entry })); } }); } }); });客户端这边在Univer表层包一层事件转发const ws new WebSocket(ws://localhost:8081?docIdsheet1userIdu-10086); ws.onmessage (event) { const msg JSON.parse(event.data); if (msg.type op) { // 本地合并远程操作 univer.applyOperation(msg.entry.op); } }; univer.on(commandExecuted, (command) { // 筛选UI操作转成协同操作 if (command.id.startsWith(sheet.command.)) { ws.send(JSON.stringify({ type: op, op: { commandId: command.id, params: command.params, ts: Date.now() }})); } });这里要特别提醒一个“边界问题”WebSocket重连后的增量同步。最稳妥的做法是客户端每次重连后请求服务端全量操作历史然后从本地当前版本号开始重放。如果历史太长可以在服务端对操作序列做定时的快照压缩具体算法可以是“每隔N个操作保存一份完整文档状态”重连时先取最近快照再重放剩余操作。4. 常见问题与性能排查实录4.1 初始化白屏或工具栏不显示这是我被问得最多的问题。按经验顺序排查确认univerjs/preset与对应的univerjs/core版本号一致。如果用了Vite的依赖预构建有时候会缓存旧版本执行pnpm dev --force让vite重新预构建。看容器高度。Univer的Canvas渲染要求父容器有明确高度不要依赖height: auto推算。建议给根节点直接写height: 100vh或height: calc(100vh - 48px)。检查插件注册顺序。UniverSheetsPlugin必须在UniverSheetsUIPlugin之前注册否则UI层找不到数据模型。4.2 大数据量下的滚动掉帧与内存增长我推荐从三个方向入手开启Canvas的降级绘制。当滚动速度超过阈值时Univer会自动切换低分辨率渲染但前提是注册了optimize插件或者在初始化参数里打开了canvasRenderMode: runtime。减少单元格富文本的复杂度。10万行数据如果每个单元格都带边框颜色、字体阴影就算是Canvas也扛不住。可以把绘制开销大的样式批量“消隐”比如滚动时暂时隐藏单元格边框。数据解耦。非可视区的单元格数据不需要绑定到渲染上下文。Univer的数据模型跟渲染是分离的所以你的数据源可以分页加载配合虚拟滚动策略不必一次全量注入。实测结论10万行*8列文本数据打开节流渲染后滚动稳定在50fps上下但引入合并单元格和条件格式后性能下降明显合并区域建议控制在5000个以内。4.3 协同不同步本地生成了操作但服务端没收到遇到这种情况先别急着查WebSocket先查Command是否进入了执行链univer.getCommandService().onCommandExecuted((cmd) { console.log(executed:, cmd.id); });如果这一步都没有输出说明Command在拦截阶段被吞掉了。常见原因是自定义插件中监听了beforeCommandExecute然后调用了event.preventDefault()但没有返回值。Univer的拦截器协议要求事件处理器必须返回true或false来表示是否放行。返回undefined时行为是“放行”但在某些版本中会导致后续监听器异常而不执行。另外协同消息中如果包含range.actualRange这类内部对象直接JSON.stringify可能会丢失原型方法。我建议在发送前做一层DTO转换只保留startRow、startCol、endRow、endCol四个字段。4.4 常见问题速查表问题现象大概率原因解决方式初始化白屏容器高度缺失 / 插件注册顺序错误检查容器CSS与插件顺序vite --force重跑工具栏空白toolbar.items中存在未注册项逐个删除排查或只保留[undo,redo]公式显示#NAME?函数名未注册 / 异步函数未加载完成注册自定义函数后再执行计算公式异步函数确认已执行完Promise协同消息送达但数据不一致客户端重放顺序错位改为固定revision顺序拉取历史避免并发覆盖大数据集导出崩溃Canvas截图方案把全表一次性绘制使用服务端数据序列化导出前端只提供异步回调内容编辑中文输入法掉字IME合成事件与Command的提交时机冲突升级到0.1.5以上并开启imeSupport: true4.5 自定义单元格编辑器的开发经验最后聊一个二开进阶技巧自定义单元格编辑器。Univer的默认编辑器是一个覆盖在Canvas上的输入框。如果你的业务需要做“单元格内选择日期区间”、“单元格内下拉级联”你需要自定义编辑器。核心流程是实现一个类实现IEditor接口包含generateContent、updateContent、validate等方法。通过editorRegistry注册编辑器类型。在单元格协议cellEditorType里绑定你的编辑器ID。我实现过一个“单元格内嵌选择器”的自定义编辑器。它的坑点在于编辑器弹出后必须自行管理焦点如果失焦时没有把值写入数据模型会导致关闭编辑器后单元格显示旧值。我的建议是在编辑器的onBlur回调中而不是在onCancel中提交数据避免用户点击其他单元格时丢失输入。另外在设置cellEditorType时记得把类型写成一个字符串常量并和注册的ID完全一致。我用过一个动态生成的ID结果每次重新渲染表头时编辑器ID不一致导致点击单元格的编辑器找不到注册对象报了一个“editor type not found”的隐藏异常。查这个错花了我近两个小时——把ID固定写死之后就再没出过问题。5. 部署与二次开发中的工程化建议5.1 构建体积优化与按需加载Univer在构建上相对成熟但不代表不需要优化。第一次执行vite build后输出体积可能到了2MB以上gzip前。我优化后的几个操作使用unplugin-vue-components或类似插件做按需加载只注册用到的模块。例如不需要Slide就不引入univerjs/slides。公式引擎中如果要支持全量函数它的体积相当可观。如果业务只需要几个业务函数可以在初始化时配置functionWhiteList只加载白名单函数。注意这也会影响部分内置函数的可用性如果你的Excel文件里包含复杂函数白名单模式会阻止它们被计算。CSS样式也是隐藏的体积大头。univerjs/design的样式文件全量引入可以达到几百KB。用unplugin-light-sass或者手动按需导入样式入口能明显压缩。5.2 数据持久化方案选型Univer本身不带后端持久化文档打开后你得负责把数据存储下来。我的方案建议如下增量快照每次操作后把变更集追加到数据库的变更表Change Data Capture表并在内存中维护最新文档状态的JSON。定时压缩每1000个变更或每5分钟生成一次全量快照存到对象存储里。超过30天前的变更历史可以归档保留最后1000个变更用于协同重放。前端加载顺序先请求全量快照再向服务端要增量变更把两者合并后加载到Univer。这种设计的优势是用户的每个操作都可追溯同时打开文档的耗时基本等于下载快照的时间不会因为历史操作堆积而变慢。唯一要注意的是并发快照的一致性。我因为抢锁不严出现过两次快照把最新的几条变更覆盖丢掉的状况后来在生成快照前必须判断“当前变更集版本号 快照基础版本号”否则跳过生成。5.3 权限控制与操作审计Univer的权限控制是典型的“前端展示层控制”。真正安全的权限必须放在协同服务端管控服务端针对每个用户、每个文档维护“可读、可编辑、可评论”级别。数据下发时对无权限区域的单元格做脱敏处理而不是依赖前端隐藏。操作广播时服务端检查userId是否有执行相应Operation的资格没有则拒绝。我在二次开发里做了一个操作审计服务把命令ID、操作范围、改动值、用户ID、时间戳统一写入日志库。相比自己写全局事件监听利用命令系统的优势是天然覆盖了“撤销”、“重做”这类内部操作审计层面会主动记录“undo了某次操作”这套完整性是被动埋点给不了的。注意不要轻易修改Univer内置Command的语义。你需要扩展行为时优先用插件包裹并新增Command而不是去改内置Command的type字段。否则协同后端对操作类型做白名单校验时会识别不了导致A客户端执行成功、B客户端被拒绝的“两端状态不一致”问题。6. 写在最后的几点体会Univer这套东西我最初接触时觉得它像“又一个大而全的表格库”但深入进去才发现它的架构给了我很大的操作空间。它没有试图把Excel那套复杂交互全包圆而是提供了一个可靠的渲染层、一个干净的命令系统、一个可替换的公式引擎剩下的业务拼图都留给了开发者。我个人在实际操作中的体会是如果你打算在项目里引入Univer千万不要一开始就求全。先做一个小场景比如“把项目里的只读数据表换成可编辑表格”接入基础Sheet能力跑通全链路数据持久化和命令解析。等这个闭环稳定了再逐步加入协同、自定义公式、权限隔离。一次把协同和公式全部接入排查问题会让你非常狼狈。最后再分享一个小技巧Univer社区层面目前讨论热度最高、也最容易踩坑的其实是“国际化”和“主题定制”。如果你接手的时候团队已经有一套设计系统建议优先考虑覆盖Univer的默认主题变量而不是硬改DOM结构。我试过通过覆写一套CSS Variables把工具栏风格统一到团队现有设计规范效果比预期好很多也不影响后续升级版本。如果你目前正好在调研可嵌入的Web表格方案我真心推荐把Univer放进对比清单里跑一跑。别只看它的官方Demo直接用你们真实业务里最复杂的一张Sheet导入进去然后试着做几个公式、筛选、合并单元格操作你就能感受到它到底是“玩具”还是“武器”。我在跑完这个验证之后就把它正式定为团队新数据产品的底层表格引擎了目前跑得一直很稳。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询