前阵子给公司搭报表系统,后端指标都算好了,前端却卡在表格组件上。用纯DOM画表格太粗糙,用纯数据展示又满足不了业务方“要能在线编辑、要能拖拽、要能粘贴Excel原文”的需求。试了一圈开源方案,最后确定用 Univer 来承载在线表格模块。如果你也在做类似的需求,比如在线Excel编辑、数据填报、低代码平台里的公式计算,或者想让用户直接在网页里操作一套接近桌面软件的表格能力,Univer 应该是目前开源社区里很值得研究的一个项目。这篇文章就从选型、初始化、业务集成到生产环境踩坑,完整复盘一遍。
1. 为什么选Univer:在线表格组件的选型真相
1.1 开源表格组件现状与切肤之痛
先说结论:做Web端“能编辑的表格”,和做“看起来像Excel的表格”,难度完全不是一个量级。前者只需要渲染二维数据,后者要处理选区、键盘导航、公式引用、合并单元格、条件格式、剪贴板事件,甚至是多用户同时操作同一批单元格。我最初用的是LuckySheet,社区里名气很大,但那段时间项目基本停更,很多交互细节要自己改源码,改着改着就成了fork分支,后续升级非常痛苦。也考虑过直接引入桌面级组件的商业授权方案,一类是SpreadJS这类商业控件,能力确实完整,但项目预算和授权边界需要反复确认,不是所有团队都能接受;另一类是现成的在线Office套件,太重了,我只想要表格模块,不想让后端服务强依赖一套独立的文档服务。
Univer进入视野有三个原因。第一,它把表格、文档、幻灯片统一放到一套架构里,虽然市面上大多数场景只用它的表格能力,但至少说明核心团队在做一个长期演进的项目。第二,它是模块化设计,核心、渲染引擎、公式引擎、UI层都是独立插件,按需注册,不会一个import全量打包。第三,它的命令系统非常适合做协同和业务审计,每一个操作都是一个可序列化的命令对象,这一点在做数据填报系统时特别有用。
1.2 架构层面的关键设计
Univer的架构可以粗略分成四层:领域模型层、渲染引擎层、公式引擎层和应用插件层。领域模型层用TypeScript定义Workbook、Worksheet、Range这些核心对象,所有数据操作都通过命令系统走一遍,而不是直接改内存对象,好处是撤销重做天然支持,协同编辑也可以通过命令流同步。渲染引擎层不依赖DOM表格,而是基于Canvas绘制,性能上比DOM操作稳定很多,单元格数量大的时候也不会DOM节点爆炸。公式引擎层放在Web Worker里运行,需要计算时通过消息和主线程通信,界面不会因为公式卡住。应用插件层就是我们在业务里看到的工具栏、右键菜单、筛选器这些UI能力,做起来可以按需裁剪。
对普通业务方来说,这些架构概念不用全部搞懂,但有一个点建议重点理解:命令系统。你在Univer里做任何一个操作,比如“给A1单元格赋值为100”,本质上会生成一条命令,命令由type、params、unitId这些字段组成。借助这个机制,我们可以把用户操作打包成JSON,通过网络发给其他人,这就是多人协同的基础;也可以把操作流入库做审计,比如记录谁在什么时候改了哪个单元格。这个设计给我的感觉是,“在线表格”等于“可复现的操作日志加实时状态”,比直接同步最终结果要可靠得多。
2. 5分钟跑通Univer在线Sheet:从空页面到能编辑的表格
2.1 初始化一个最小可运行的Univer实例
先说版本问题。Univer的版本迭代非常快,从0.1.x到0.2.x再到现在的1.x,API发生了不少变化。我下面的示例以0.2.x时代的插件化注册方式作为参考,核心思路仍然是适用的:先创建Univer实例,再注册需要的插件,最后创建sheet单元。如果你现在拿到的是新版,可能可以直接用@univerjs/presets里的预制包,少写几行注册代码,但理解插件逻辑还是很有帮助的。
新建一个前端项目之后,安装核心依赖:
npm install @univerjs/core @univerjs/engine-render @univerjs/engine-formula @univerjs/sheets @univerjs/sheets-ui @univerjs/sheets-formula @univerjs/sheets-io-xlsx然后在入口文件里初始化:
import { Univer, LocaleType, UniverInstanceType } from '@univerjs/core'; import { UniverRenderEngine } from '@univerjs/engine-render'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsUI } from '@univerjs/sheets-ui'; import { UniverFormulaEngine } from '@univerjs/engine-formula'; import { UniverSheetsFormulaPlugin } from '@univerjs/sheets-formula'; import { UniverSheetImportXlsxPlugin } from '@univerjs/sheets-io-xlsx'; const univer = new Univer({ locale: LocaleType.ZH_CN, }); univer.registerPlugin(UniverRenderEngine); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsUI); univer.registerPlugin(UniverFormulaEngine); univer.registerPlugin(UniverSheetsFormulaPlugin); univer.registerPlugin(UniverSheetImportXlsxPlugin); univer.createUnit(UniverInstanceType.SHEET, { name: '示例工作簿', sheets: { sheet1: { name: 'Sheet1', cellData: { 0: { 0: { v: '项目' }, 1: { v: '金额' } }, 1: { 0: { v: '项目A' }, 1: { v: 100 } }, 2: { 0: { v: '项目B' }, 1: { v: 200 } }, }, }, }, });对应HTML里挂载容器:
<div id="app" style="width: 100%; height: 600px;"></div>Univer需要一个具备明确高度的容器,如果页面里没有给这个div设置高度,你会看到一片空白或者表格只出现一截。
2.2 用univerAPI操作单元格与业务数据绑定
初始化完成之后,页面已经可以像Excel一样点击、输入、做基础格式调整了。但真实业务里,我们通常需要把后端数据填进去,或者在用户操作后把结果读出来。Univer提供了封装好的Facade API,也就是univer.createAPI()返回的实例,用起来比直接操作领域模型顺手很多。
const univerAPI = univer.createAPI(); const sheet = univerAPI.getActiveSheet(); const range = sheet.getRange('B2'); range.setValue(150); range.setStyle({ fontWeight: 'bold', backgroundColor: '#FFF3BF' });把后端数据写入表格,最省事的办法是循环setValue,但数据量大时建议一次性构造cellData传给单元创建或者批量更新,否则每条命令都会触发一次重绘和公式计算。我们第一次上线时就吃过亏,把几千行数据用循环写进去,页面卡了将近两秒。后来改成批量结构赋值,性能一下子正常了。
如果需要监听用户操作,可以使用选区变化事件:
sheet.onSelectionChange((selection) => { console.log(selection); });事件回调非常适合做联动场景。比如用户在第二行选中某个单元格时,右侧面板自动展示该行的明细,或者用户在某个区域填写数值时,底部自动算合计,这些都不用改造Univer源码,只挂事件就行。
2.3 实现Excel文件的导入与导出
Univer支持Excel文件导入导出,是业务团队接受度很高的一个能力。很多运营同学的工作习惯是把Excel表格整理好再上传到系统,而不是直接在网页里从零开始建表。因此,“支持.xlsx上传并渲染成在线表格,再让用户下载编辑后的文件”几乎成了标配需求。
实现方式很简单:注册了@univerjs/sheets-io-xlsx插件后,拿到本地文件并读取为ArrayBuffer,再通过Univer的导入方法把文件内容解析到当前工作簿即可;导出时则把当前工作簿数据序列化,生成xlsx文件下载。需要注意的是,Excel的兼容边界真实存在,比如某些复杂图表、宏、密码保护和数据透视表,Univer可能无法完整还原。我们在上线前跟业务方约定了一张“兼容清单”,凡是上传内容里包含宏文件或者嵌入图表,系统会提示“仅保留数据内容”。不要承诺100%还原Excel,这句话会给自己挖很大的坑。
3. 从试用走向上线:Univer协同编辑与业务集成方案
3.1 协同编辑的工作原理与落地路径
Univer在线表格做到后面,协同编辑几乎是躲不开的需求。哪怕一开始只是单机版,业务方也会问:“我一个同事改了,另一个同事能看到吗?”实现协同,核心不是实时推送最终表格数据,而是同步操作。之前说过,Univer的所有操作都是命令,所以协同设计可以这样理解:用户A在本地执行命令“将A1设置为10”,他需要把这条命令发送到服务端;服务端把它分发给用户B;用户B在同一文档基础上应用这条命令。多个命令同时并发时,需要解决冲突,常用的思路是操作变换,也就是OT,让两条命令在应用前做一些调整,保证最终结果收敛。
具体到落地路径,客户端要做的其实不复杂。第一步,建立一个WebSocket连接;第二步,监听从Univer发出的命令流,把命令对象发送到服务端;第三步,收到服务端广播的远端命令后,通过Univer提供的命令分发入口应用到本地;第四步,维护一个版本指针,如果发现网络断开或者命令回放失败,就重新拉取快照。真正的难点在服务端,需要做一个命令序列存储,并且要处理多端并发时的顺序一致性。如果团队没有算法背景,建议不要一上来就自己实现变种算法,先基于现有消息队列做“服务端排序,客户端按序执行”,覆盖90%场景,复杂的交换合并再逐步补。
3.2 与现有业务系统的集成方式
常见的集成模式有两种。一种是嵌入模式,页面里只放一个Univer表格区域,工具栏使用Univer默认的,适合内部数据填报,开发速度快;另一种是组件模式,把Univer实例封装成一个React或Vue组件,外层由业务系统控制工具栏按钮,比如只保留合并单元格、字体加粗、数据校验,隐藏公式函数等高级功能。推荐第二种,因为不同业务场景需要的功能边界不同,有些时候给用户暴露一个“插入图表”按钮不是好事,反而增加数据质量风险。
自定义工具栏按钮可以直接操作UniverAPI。比如业务里要加一个“保存并提交”按钮,点击后把所有修改过的单元格提取出来,提交给后端接口:
const range = sheet.getRange(0, 0, sheet.getLastRow() + 1, sheet.getLastColumn() + 1); const data = range.getValues(); submitToBackend(data);有人问我怎么知道用户改过哪些单元格,常规做法是记录提交之前的所有命令,按时间窗口筛选。这个方案需要结合命令流实现,也是协同能力带来的附带好处。另外,Univer支持单元格类型的数据校验,可以用下拉选项、日期格式等约束用户输入,类似Excel里的数据有效性,实现在线数据采集时防错效果很好。
3.3 性能优化与资源释放
在线表格的性能问题主要集中在三处:大数据量渲染、公式计算、长时间打开页面导致的内存增长。Univer的Canvas渲染使大量单元格仍可流畅滚动,但工具栏、选区逻辑和剪贴板同时工作时,低端设备也可能会感到迟滞。我们的做法是尽量少开隐藏的行列数,只加载真实数据区域;大数据场景用分页加载,比如一次只填充用户可以看到的几百行,滚动时再通过事件加载下一段。公式计算尽量依赖Univer自身的Worker机制,不要在业务代码里频繁读取公式依赖的单元格,否则会导致主线程被反复唤醒。
页面销毁时,记得调用Univer实例的销毁方法,把监听器和渲染循环释放掉。尤其是在单页应用里,如果路由切换后没有销毁实例,每次都新建一个Univer,最后会有多个Canvas和多个公式Worker同时在后台运行,用户会明显感觉应用变慢。
4. 生产环境常见问题与避坑实录
4.1 页面白屏或表格没有渲染出来
这是新手最容易碰到的问题。排查顺序建议按照:容器高度是否显式设置,CSS样式是否被全局覆盖,插件是否注册完整,以及是否加了国际化语言包。Univer对容器尺寸要求比较高,默认会初始化到父容器的尺寸,如果父容器高度是0,Canvas算出来的高度也是0。还有一个很隐蔽的问题,动态路由加载组件时,如果容器在Univer初始化之后才出现在DOM里,也会导致白屏,需要在初始化前先确保DOM节点存在。
4.2 公式计算不生效或一直显示原文本
公式不生效的常见原因有三个。第一个是依赖注册不全,只注册了表格插件,没有注册公式引擎和公式插件;第二个是用户在单元格内输入公式时使用了中文符号,比如全角逗号,Univer解析不了;第三个是编辑单元格后公式结果没有立即刷新。遇到后可以检查浏览器控制台有没有公式Worker报错,或者直接看单元格的公式字符串是否被正确保存。如果公式被当成纯文本,通常是单元格没有设置正确的类型,或者输入时前面误加了一个空格。
4.3 协同编辑时数据不一致
协同状态不一致,基本可以归因到三个层面:网络层面消息丢失,服务端层面命令顺序错乱,客户端层面撤回栈分叉。网络问题最直接,可以给WebSocket加上心跳和重连机制,断线后重新拉取服务端快照,覆盖本地状态。命令顺序问题需要服务端给每条命令分配单调递增的序号,客户端按序号应用,避免A端先应用了后发的命令。撤回栈分叉比较难排,通常是因为本地执行了未确认的命令,又收到了远端操作,两者之间没有做基于版本的比较。我的建议是不要贪多,协同能力作为MVP先上线“主从同步”,保证只有一个人能编辑,其他人实时只读,确认稳定后再放开多人同时编辑。
4.4 版本升级与API变动
Univer迭代期相对活跃,小版本间的API变化有时比较大,尤其是插件注册方式和命令名称。升级前一定要看发布记录和破坏性变更说明。我们内部现在是这样处理的:锁定版本号,不轻易跟随升级;功能需要更新时,先在测试环境跑一遍全量回归,重点验证初始化、导入导出、公式计算三个主链路;生产环境的包版本长期保持统一,避免不同模块之间核心库不一致。
4.5 常见问题速查
| 问题 | 优先排查方向 | 常见的解决手段 |
|---|---|---|
| 页面显示空白 | 容器高度/父级隐藏 | 显式设置高度,确保DOM渲染完成后再初始化 |
| 导入xlsx后样式丢失 | 插件缺失/Excel特性不支持 | 注册io-xlsx插件,提前声明兼容范围 |
| 公式不计算 | 公式引擎未注册/符号错误 | 注册引擎插件,检查全角符号 |
| 复制粘贴异常 | 剪贴板权限/浏览器限制 | 用浏览器自带授权,检查是否在iframe下运行 |
| 组件卸载后卡顿 | 未销毁Univer实例 | 调用univer.destroy()清理监听 |
| 工具栏按钮不响应 | 插件注册顺序问题 | 检查UI插件和核心插件是否同时注册 |
5. 我在实际项目里的几点体会
Univer最让我满意的地方是它的边界感。它没有把所有东西都耦合在一起,公式、渲染、数据模型各自独立,业务需要哪块就接哪块。但这同时也意味着学习成本比一次性导入几十个方法的库要高,需要花时间理解“命令”“单元”“插件”这三个关键词。
选型时,如果项目只需要读取Excel文件并展示数据表格,那用exceljs加一个轻量展示组件就够了,引入Univer确实会显得重。但一旦需求演进到在线编辑、多人协同、复杂公式引用,Univer的价值才会体现出来。如果你的团队评估后决定自建一套在线表格,最好预留一位熟悉TypeScript和Canvas渲染的同事去啃源码,而不是完全依赖文档。Univer源码质量不错,但终究是开源项目,紧急问题不能保证有人第一时间回复。
最后分享一个小细节。Univer默认的撤销栈会记录所有命令,长时间编辑后内存占用会有明显上涨。如果业务是表单填报性质,不太需要完整保留上千步的撤销历史,可以在初始化配置里限制撤销栈深度。这个配置项有的是插件参数,有的是实例参数,查一下对应版本文档,设置成20步左右,既能保证误操作撤销,也不会让内存无限增长。类似这种细节在实际使用中会陆续遇到,我的原则是:每接入一个能力都先在测试环境压一遍,尤其是协同和数据量大的场景,永远不要被“演示环境一切正常”误导。