1. 从“univer”这个标题说起:它到底是什么,能解决什么问题
第一次看到“univer”这个词,很多人会以为是“universe”的缩写,或者某个开源社区起的洋气名字。实际上,在表格与文档协同编辑这个圈子里,Univer 指的是一套开源的、面向电子表格和文档的协同编辑引擎。它最核心的卖点,是把传统上只有商业办公套件才具备的能力——公式计算、单元格渲染、多人实时协作、插件化扩展——拆解成一套可以独立引入的 SDK,让开发者能在自己的 Web 应用里“长出”一个类似在线表格的东西。
我最早接触它,是因为团队要做一个内部的数据填报系统。业务方给的需求很朴素:能像 Excel 一样编辑、能算公式、多人同时改不冲突、能嵌进现有的后台页面。听起来简单,真动手才发现坑很深。自己用 Canvas 从零画表格,光是处理滚动、冻结行列、合并单元格的渲染就够喝一壶;用现成的开源表格库,又大多只解决了“展示”,没解决“编辑”和“协同”。Univer 恰好卡在这个位置上:它把渲染层、数据模型、公式引擎、协同层都做了,而且以 Facade API 的形式暴露出来,你不需要读懂它内部几万行代码,就能调用它的能力。
这篇文章适合三类人看。第一类是前端工程师,正在评估“要不要在项目里引入一个表格引擎”,想知道它的技术底座和接入成本。第二类是 Node.js 方向的后端或全栈,关心服务端协同、公式计算能不能下沉。第三类是对 Canvas 绘图引擎感兴趣的人,想看看一个成熟的表格产品是怎么把 Canvas 用到极致的。我会从整体设计思路讲到核心细节,再到实操步骤和踩坑记录,尽量把“为什么这么设计”讲透,而不是只丢一堆 API 文档。
需要先说明一点:Univer 本身是一个持续演进的开源项目,不同版本之间 API 会有调整。我下面讲的内容,基于我实际用过的版本和常见实践,具体到你的项目时,建议先锁定一个稳定版本再动手,别一上来就追最新。
2. 整体设计与思路拆解:为什么是 SDK + Canvas + Facade API 这套组合
2.1 把“表格”拆成 SDK,而不是做成一个成品应用
传统办公套件是一个完整的应用,你只能用,不能改。Univer 走的是另一条路:它把自己定位成SDK,也就是一套开发工具包。这个定位决定了它的架构必须是可拆解、可组合的。
我理解这个选择背后的逻辑是这样的:表格这个场景,需求差异极大。有人只要一个只读的报表展示,有人要完整的编辑能力,有人还要协同。如果做成一个成品应用,就得把所有功能都塞进去,体积大、定制难。做成 SDK 之后,你可以只引入渲染和基础编辑,公式引擎按需加载,协同模块单独接入。这种“按需拼装”的思路,和现在前端工程化里“微前端”“按需加载”的理念是一致的。
从实际使用角度看,这意味着你的接入成本是分层的。最简场景下,你只需要初始化一个 Univer 实例,挂到一个 DOM 容器上,就能得到一个可编辑的表格。复杂场景下,你再逐步引入公式、协同、导入导出等插件。这种渐进式的接入方式,对存量项目很友好,不用一次性重构。
2.2 Canvas 渲染:为什么不用 DOM 表格
这是很多人第一个会问的问题:HTML 本来就有 table 标签,为什么还要用 Canvas 重画一遍?
答案在于性能和一致性。DOM 表格在数据量小的时候没问题,但一旦行数上千、列数上百,浏览器要维护的 DOM 节点数量会爆炸,滚动和编辑都会卡。Canvas 是一块画布,所有单元格都是画上去的,节点数量恒定,性能只和绘制复杂度有关,和数据量关系没那么大。这就是为什么成熟的在线表格产品,几乎都用 Canvas 或类似的立即模式渲染。
但 Canvas 也有代价。DOM 天然支持文本选择、无障碍、输入框聚焦,Canvas 全都要自己实现。所以 Univer 在 Canvas 之上做了一套完整的交互层:光标、选区、编辑框、滚动条,都是自己模拟的。这也是它代码量大的原因。我实测下来,在几千行数据的情况下,Canvas 方案的滚动流畅度确实明显优于 DOM 方案,这个取舍是值得的。
2.3 Facade API:让使用者不用碰内部实现
Facade 是“门面”的意思。Facade API 就是给外部调用者提供的一层简化接口,把内部复杂的模块调用包装成几个好用的方法。
举个例子,你想往某个单元格写值。内部可能涉及数据模型更新、公式重算、渲染触发、协同广播好几个步骤。但通过 Facade API,你可能只需要调用一个类似setCellValue的方法,剩下的它帮你串起来。这个设计的好处是,内部实现怎么改,只要 Facade 层不变,你的代码就不用动。
我在接入时的一个体会是:不要试图去读它内部的所有源码,那样会陷进去。先把 Facade API 的文档过一遍,知道有哪些能力可用,遇到不够用的情况再去翻内部实现。这样效率最高。
2.4 Node.js 在其中的角色
热搜词里出现了 Node.js,这不是偶然。Univer 的协同能力,通常需要一个服务端来做消息中转和状态同步,Node.js 是最常见的选择。另外,公式计算、导入导出这些能力,也可以在 Node.js 侧复用同一套逻辑,实现“前后端同构”。
我自己的做法是:前端负责渲染和交互,Node.js 服务端负责协同的房间管理、消息广播,以及一些重计算的兜底。这样前端压力小,服务端也能做权限校验。下面讲实操时会具体说。
3. 核心细节解析与实操要点:从初始化到公式计算
3.1 环境准备与依赖安装
动手之前,先把环境理清楚。Univer 是前端库,但如果你要跑协同,Node.js 环境也得有。
前端侧,你需要一个现代前端工程环境。我用的是 Vite,启动快,配置简单。核心依赖是 Univer 的主包和几个插件包。安装命令大致如下:
npm install @univerjs/core @univerjs/ui @univerjs/sheets @univerjs/sheets-ui如果你要公式能力,再加公式引擎包;要协同,再加协同相关包。这里有个经验:不要一次性把所有包都装上,先装核心的,跑起来,再按需加。因为包之间有版本对应关系,装太多容易冲突。
Node.js 侧,如果你要做协同服务,建议用 LTS 版本。我用的 18.x 和 20.x 都跑过,没问题。安装就是常规的 Node.js 安装流程,官网下载对应系统的安装包,一路下一步即可。装完用node -v验证一下。
注意:前端包和 Node.js 服务端的包,版本要尽量对齐。我踩过一次坑,前端用的 Univer 版本和服务端协同库版本差了一个大版本,结果消息格式对不上,排查了半天。
3.2 初始化一个最小可用的表格
环境好了,先跑一个最小例子。核心步骤是:创建 Univer 实例、注册插件、挂载到 DOM。
import { Univer } from '@univerjs/core'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui'; const univer = new Univer(); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsUIPlugin); univer.createUniverSheet({ id: 'my-sheet', container: document.getElementById('app'), });这段代码跑起来,你就能看到一个可编辑的表格。注意container必须是一个真实存在的 DOM 元素,而且要有明确的宽高,否则 Canvas 画不出来。
我建议第一次跑的时候,先不要加任何额外插件,就用最核心的这几个。确认表格能显示、能输入、能滚动,再往下加功能。这样出问题时,排查范围小。
3.3 数据模型与单元格操作
Univer 内部有一套自己的数据模型,单元格的值、样式、公式都挂在上面。通过 Facade API 操作时,你不需要直接碰这个模型,但理解它的结构对排查问题有帮助。
写值的典型方式是通过工作表的 Facade 对象。大致逻辑是:先拿到当前工作表,再定位到单元格,然后设置值。设置完,渲染会自动触发。
这里有个细节值得说:批量写入比逐个写入快得多。如果你要初始化几千行数据,不要循环调用单格写入,而是构造一个二维数组,一次性写入。我实测过,逐格写入几千次,页面会卡好几秒;批量写入,基本瞬间完成。原因是每次单格写入都可能触发一次重算和重绘,批量写入只触发一次。
3.4 公式引擎的接入与计算时机
公式是表格的灵魂。Univer 的公式能力是独立模块,需要单独注册。注册之后,你在单元格里输入=SUM(A1:A10)这样的表达式,它会自动计算。
公式计算有几个关键点。第一是依赖追踪:改了 A1,依赖 A1 的公式要重算。Univer 内部会维护依赖图,你不需要手动触发。第二是计算时机:默认是同步计算,数据量大时可能阻塞。如果公式特别多,可以考虑把重计算放到 Node.js 侧异步做,前端只负责展示结果。
我在一个报表场景里遇到过公式链很长的情况,前端算一次要几百毫秒。后来改成服务端预计算,前端只拉结果,体验好很多。这个取舍要看你的场景:如果用户需要实时看到公式结果,就前端算;如果是展示型报表,服务端算更合适。
3.5 协同能力的接入思路
协同是 Univer 比较有分量的能力,但也是最复杂的部分。核心思路是:每个编辑操作都产生一个“变更”,这个变更通过服务端广播给同一房间的其他客户端,其他客户端应用这个变更,达到状态一致。
服务端用 Node.js 做中转,通常配合 WebSocket。你需要处理几件事:房间的创建和加入、变更消息的转发、新加入者的状态同步。新加入者进来时,不能只给它后续的变更,得先把当前完整状态给它,否则它看到的是空的。
注意:协同场景下,冲突处理是难点。Univer 内部有自己的一致性机制,但你在服务端转发消息时,要保证顺序。我见过因为消息乱序导致两端状态不一致的情况,排查起来很痛苦。建议在服务端给消息加序号,客户端按序号应用。
4. 实操过程与核心环节实现:一个数据填报系统的完整搭建
4.1 需求拆解与技术选型确认
我拿之前做的内部数据填报系统举例。需求是:多个部门同时填报数据,表格有固定模板,部分列是公式自动算,填报完成后导出。
技术选型上,前端用 Univer 做表格,Node.js 做协同服务,数据持久化用常规数据库。选 Univer 的理由前面说过:公式、协同、渲染都有,不用自己造轮子。选 Node.js 做服务端,是因为协同逻辑和前端可以共享一部分代码,减少重复。
这里有个决策点:要不要用 Univer 的协同模块,还是自己实现协同。我的建议是,如果你的协同需求是标准的“多人编辑同一表格”,直接用它的协同模块,省事。如果你有特殊的权限控制、审批流,那可能要在它的基础上做二次开发,或者自己实现变更层。
4.2 前端表格的初始化与模板加载
前端初始化分两步:先创建空的 Univer 实例,再加载模板数据。
模板数据可以是一个 JSON,描述有哪些工作表、每列的表头、预设的公式。加载时,用批量写入的方式把模板灌进去。公式列不需要写值,只写公式表达式,让引擎自己算。
const template = { sheets: [{ name: '填报', columns: ['部门', '人数', '人均成本', '总成本'], formulas: { 'D2': '=B2*C2', }, }], };实际代码里,我会把模板配置和渲染逻辑分开,模板放一个单独的配置文件,方便业务方改。这样改模板不用动代码,重新加载配置就行。
4.3 Node.js 协同服务的搭建
服务端我用了 WebSocket 库来做消息通道。核心逻辑是:客户端连接时带上房间 ID,服务端把同一房间的连接归到一组;收到某个客户端的变更消息,转发给同组其他客户端。
const rooms = new Map(); function joinRoom(roomId, socket) { if (!rooms.has(roomId)) { rooms.set(roomId, new Set()); } rooms.get(roomId).add(socket); } function broadcast(roomId, message, sender) { const room = rooms.get(roomId); if (!room) return; room.forEach((socket) => { if (socket !== sender) { socket.send(message); } }); }这段是简化版,实际还要处理断线重连、心跳、消息序号。断线重连时,客户端要重新拉一次完整状态,否则会丢变更。
4.4 公式计算的前后端分工
前面提到公式可以前端算也可以服务端算。这个项目里,我做了分工:用户正在编辑时,前端实时算,保证输入即见结果;填报提交后,服务端用同一套公式逻辑重算一遍,作为最终结果存档。
这样做的好处是,前端算得快,体验好;服务端算得准,作为权威数据。两边用同一套公式定义,结果应该一致。如果出现不一致,说明有 bug,可以拿服务端结果为准。
服务端复用公式逻辑,需要把 Univer 的公式引擎在 Node.js 里跑起来。这部分要注意,公式引擎可能依赖一些浏览器 API,在 Node.js 里跑需要做适配。我遇到过一个日期函数在 Node.js 里报错,后来发现是它内部用了浏览器的日期格式化,换成 Node.js 的等价实现就好了。
4.5 导出功能的实现
导出是把当前表格状态转成文件。常见格式是 Excel 或 CSV。Univer 有导入导出相关的包,可以复用。
导出的关键是把内部数据模型转成目标格式。如果只是导出值,比较简单;如果要保留公式、样式,就复杂一些。我的做法是:导出时把公式也带上,这样用户拿到文件后还能继续编辑。
注意:导出大表格时,注意内存占用。我导过几万行的表,前端直接转字符串会爆内存。后来改成流式导出,边转边写,内存就稳了。
5. 常见问题与排查技巧实录
5.1 表格不显示或显示空白
这是最常见的问题。排查顺序是:先看容器有没有宽高,再看 Canvas 有没有被创建,最后看数据有没有加载。
容器没宽高是最常见的原因。Canvas 需要一个有尺寸的父元素,如果父元素高度是 0,画出来就是空白。我一般会在初始化前打印一下容器的clientWidth和clientHeight,确认不是 0。
如果容器没问题,检查插件有没有注册全。少注册一个 UI 插件,表格可能只渲染数据不渲染界面,看起来也是“不完整”。
5.2 公式不计算或计算结果不对
公式问题分两类:不计算,和算错。
不计算,通常是公式引擎没注册,或者公式表达式格式不对。检查一下注册代码,以及表达式是不是以=开头。
算错,多半是引用范围不对,或者数据类型不对。比如SUM里混了文本,结果可能不符合预期。我建议先在单元格里手动输入公式验证,确认引擎本身没问题,再排查数据。
5.3 协同场景下状态不一致
这是协同最头疼的问题。表现是两个人看到的表格内容不一样。
排查思路:先确认消息有没有丢,再确认消息顺序对不对,最后确认新加入者的初始状态是不是完整。
我遇到过一次,是因为新加入者只收到了后续变更,没收到初始快照,导致它从空表开始应用变更,结果和别人的不一样。解决办法是加入房间时,先发一次完整状态。
5.4 性能问题:滚动卡顿、输入延迟
性能问题通常和数据量、公式复杂度、渲染频率有关。
如果滚动卡,先看是不是公式太多。公式重算会阻塞主线程。可以考虑把公式计算移到 Web Worker,或者服务端。
如果输入延迟,看是不是每次输入都触发了全表重绘。Univer 内部有优化,但如果你在外部频繁调用 API,可能破坏它的优化。尽量用批量操作。
下面这张表是我整理的高频问题速查:
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 表格空白 | 容器无宽高 | 检查父元素尺寸 |
| 公式不计算 | 引擎未注册 | 检查插件注册 |
| 协同不一致 | 消息丢失或乱序 | 检查服务端转发逻辑 |
| 滚动卡顿 | 公式过多 | 考虑 Worker 或服务端计算 |
| 导出失败 | 内存不足 | 改流式导出 |
5.5 版本升级带来的兼容问题
Univer 迭代比较快,升级版本时 API 可能有变化。我的经验是:锁定版本,不要自动升级。在 package.json 里写死版本号,升级时手动改,改完跑一遍回归测试。
升级前,先看 changelog,重点看 breaking change。如果项目里用了 Facade API,确认这些 API 在新版本里还在不在。我升级过一次,有个方法被改名了,编译不报错但运行时报错,找了半天。
6. 我个人的一些实操心得
用 Univer 做项目,最大的感受是:它给了你很大的自由度,但自由度也意味着你要自己做很多决策。比如公式前端算还是后端算,协同用它的模块还是自己写,这些没有标准答案,要看你的场景。
我的建议是,先用最小可用版本跑通核心流程,再逐步加功能。不要一上来就追求大而全,那样容易陷在细节里出不来。另外,多看看它的示例代码,很多用法示例里都有,比文档还直观。
最后分享一个小技巧:如果你在 Node.js 里复用公式引擎遇到浏览器 API 缺失的问题,可以先用一个轻量的 polyfill 顶上,把流程跑通,再逐步替换成 Node.js 原生实现。这样不会卡在环境适配上。