ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

解锁Univer:从嵌入到协同编辑的Web表格实践指南

解锁Univer:从嵌入到协同编辑的Web表格实践指南

说实话,第一次知道Univer这个项目,是我在给客户做一套内部数据填报系统的时候。当时那个老板要求"网页上的Excel,最好能多人同时填",而且补了一句"别用那种弹窗式的老古董控件,看着难受"。我试过几个方案,要么是付费授权黑得吓人,要么是导出格式乱七八糟。后来在技术社区里偶然刷到Univer,最初只是觉得"哦,一个开源的Web表格库",结果真部署试了一把之后,我决定把整个协同办公的底层都换成它。

这篇东西不打算写成官方文档的翻译版,而是想以一个真正把它跑进生产环境、接过需求、踩过坑的开发者视角,聊聊Univer到底好在哪里、难在哪里、坑又在哪里。如果你正准备选型Web端的表格组件、或者你手里已经有一堆Excel残留的痛点,这篇应该能帮你在动手前省下不少弯路。

1. 为什么我要把业务系统重写到一个"网页表格"上

1.1 被老Excel体系卡住脖子的真实场景

我们之前做的数据填报系统,本质上就是"在线Excel + 审批流"的结合体。前端用的是某老牌表格控件,后端生成一个xls文件,用户下载、填完、再上传,服务器解析之后写入数据库。听起来很完整对吧?但实际运维起来你会发现,用户根本不会按你规定的模板填。

有人删除列,有人加备注,有人把数字填成文本,有人用WPS打开后再保存,格式就变了。最离谱的一次,有个同事把公式粘贴了整整一列,结果解析程序直接内存溢出,把服务器搞挂了。这就是老式文件流转方案的通病:你不是在管理数据,你是在陪用户玩格式捉迷藏。

所以我开始思考一个更本质的方向:能不能把"填表"这件事直接变成"在网页里操作表格"?用户在浏览器里直接编辑,数据结构始终是前后端共用的JSON,不落文件、不转格式、不出错。而Univer正好提供了一个接近原生Excel体验的Web端表格引擎,还自带协同能力。

1.2 Univer是什么,它在整个体系里处于哪一层

Univer是一个开源、基于TypeScript的Web办公套件。它覆盖的不仅仅是表格(Sheet),还包括文档(Doc)和幻灯片(Slide),目前社区里用得最多的还是Sheet模块。它做的事情,简单说就是在浏览器里画出一套完整的电子表格界面,同时提供数据存储、公式计算、渲染、编辑和协同的能力。

从架构上说,它分成了层层解耦的部分:

  • 核心层(Core):负责命令、数据模型、协同能力的底层建设。
  • 渲染层(Render):基于Canvas绘制单元格和工作表,保证大数据量下的交互流畅度。
  • UI层:提供工具栏、右键菜单、公式栏这些和用户直接打交道的界面。
  • 插件层(Plugin):通过独立插件扩展功能,比如接入AI、接自定义菜单、接数据校验。

我自己理解,它本质上是一个"可编程的Excel内核"。你不需要关心单元格怎么画、公式怎么算,只需要把数据喂给它,然后听它的事件、调它的命令。

1.3 跟其他方案横向比一比

选型的时候我前后比较过几类东西:

  • 第一类:重编辑器型,比如OnlyOffice、LibreOffice Online。功能确实全面,但部署成本高、集成深度低,你要塞进自己的业务系统里,得像伺候一个独立应用一样伺候它。
  • 第二类:纯表格组件型,像Handsontable、AG Grid。它们表格能力强,但公式、样式、合并单元格这些Excel级别交互比较弱。
  • 第三类:国内商业SDK,功能强大但授权费不低,代码封闭,出了问题只能等官方。
  • 第四类:Univer,开源、架构现代、基础体验到位,且支持协同。虽然像打印、更多复杂函数、移动端适配还在完善中,但对于"深度嵌入业务系统"这个场景,它反而最顺手。

这里多提一句,如果你只是想要一个"快速展示数据"的表格,AG Grid那类组件会更轻;但如果你要的是"用户在网页里像用Excel一样操作数据",Univer的起步形态就更对路。

2. 跑起一个Univer表格:从集成到数据交互的核心逻辑

2.1 最简接入Demo:五分钟看到表格

我第一次接入Univer是在Vue3项目里,社区对React和Vue都有对应的包。按官方Quick Start走一遍,核心依赖就这几项:

npm install @univerjs/core @univerjs/sheets @univerjs/ui @univerjs/sheets-ui @univerjs/sheets-formula @univerjs/sheets-numfmt

我当时的初始化代码长这样(Vue3的封装思路,React同理):

import { Univer } from '@univerjs/core'; import { defaultTheme } from '@univerjs/theme'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui'; const univer = new Univer({ theme: defaultTheme, }); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsUIPlugin, { container: 'app', layout: { toolbarConfig: { undo: true, redo: true, font: true, align: true, }, }, });

注意这里的UniverSheetsUIPlugin里的container参数,需要的是一个DOM节点的id字符串。我第一次写成了传入元素本身,结果界面没渲染出来,控制台也不报错,查了半天文档才发现是这个坑。

跑起来之后,界面长得很清爽,默认自带一个空白工作表。用户可以直接输入、调字体、合并单元格、拖拽填充——这一套基础交互已经做得很像原生Excel了。

2.2 Univer的数据模型:一切皆命令,命令即数据

如果你只是把它当展示组件用,那就亏了。Univer最核心的设计理念,是所有用户操作都会转化成命令(Command),命令被推入命令队列执行,同时产生数据变更。这就让"撤销/重做"和"协同同步"共用一套机制,也让你可以做程序化操控。

比如我想通过代码给 A1 单元格写入"hello",可以用Command方式:

import { CommandType, ICommandService } from '@univerjs/core'; import { SetRangeValuesMutation } from '@univerjs/sheets'; const commandService = univer.__getInjector().get(ICommandService); commandService.executeCommand(SetRangeValuesMutation.id, { unitId: 'sheet-001', workbookId: 'sheet-001', worksheetId: 'sheet-001', cellValue: { 0: { 0: { v: 'hello' }, }, }, });

这套路径看起来有点绕,但它的好处是:你对数据的每次改动,都是可追溯、可回放、可协同广播的。就好比一个记事本应用,你的每一次按键都被记录成事件流,而不是只保存最终文本——后者的缺陷是多人同时操作时不知道谁改了什么。

我后来做需求的时候就形成了一条经验:不要直接去改Univer内部的workbook数据,而是尽量发Command。虽然直接改对象也能生效,但绕过命令机制会导致撤销栈错乱、协同不同步。这个坑我踩过,后文细说。

2.3 公式、格式与图表的实测结论

Univer的公式引擎是独立实现的,支持不少常用函数,比如SUM、VLOOKUP、IF这些。我用它跑过一线的业务报表,几十个单元格联动计算没什么压力。但也要承认,如果涉及特别复杂的跨簿引用、动态数组或者老旧的特殊函数,它可能还未完全覆盖,需要你自己扩展。

格式这块,Univer支持数字格式、条件格式、边框、底纹,基本键盘上的格式化操作都能做。我自己用的场景里,最常用的是numfmt插件,它能把单元格设置成"保留两位小数"、"百分比"、"日期格式"等。这个能力在做业务填报时非常关键,因为用户习惯了Excel的格式习惯,你要让他在网页里也能设置一样的显示效果。

图表属于加分项,目前Univer Sheet支持了柱状图、折线图等基础图表,插入后可以调整数据源。跟Excel原版图表比,配置项还没那么丰富,但胜在融在表格里可以直接看。我在项目里的做法是:如果客户需要复杂的商业图表,我不让Univer直接画,而是拿到范围数据交给ECharts去渲染,两边通过事件联动,这样体验反而更稳定。

2.4 如果你想把它嵌进自己的后端系统

Univer本身是纯前端方案,数据层需要你提供一套存储。最常做的有两种:

  • 方案A:全量JSON同步。每次操作后,用ICommandService拿到最新workbook数据,整体存到后端。优点是简单可靠,缺点是大表同步流量不小。
  • 方案B:操作日志回放。前端把Command序列发送到后端,后端追加保存,再同步给其他客户端。这其实就是协同编辑的基础版,占用空间小,但要求你处理命令的合并和顺序。

我自己一开始选了方案A,后来为了做协同改成方案B,才发现Univer这套命令架构的价值比想象中大得多。

3. 协同编辑:从一个"假需求"变成一个"真核心"

3.1 协同到底解决的是什么问题

客户说"多人同时填写"的时候,第一个想法当然是让两个人同时打开表格,一个看A区,一个填B区。但真正难的是冲突处理:如果两个人都改了同一个单元格,保留谁的?Excel的老答案是"最后保存的人赢",但企业的现实需求是"合并两个人的意图,别丢数据"。

Univer官方提供了协同方案,但本质思路很清楚:本地执行命令,执行后把操作以命令形式广播给远端,远端按同样的命令队列依次执行。这种模型下,处理的是操作序列的一致性问题,而不是最终数据的快照同步。就好比两个人在一块白板前讨论,每个人都把自己写的字贴上去,先来后到都会保留,而不是"我抢走你的笔最后写一行字"。

注意:Univer协同方案目前比较适合局域网或者可靠的网络环境。如果你的客户需要公网跨地域协同,建议在服务端加上可靠的消息队列和命令顺序保证,不能简单依赖WebSocket的到达顺序。

3.2 我把协同服务端拆成了三个角色

刚开始照着官方文档捣鼓的时候,很容易被"协同"二字吓住。后来我把整体拆成了三块,思路立刻清晰了:

  1. 前端命令采集:拦截Univer的Command执行事件,将新增命令发送到服务端。
  2. 服务端顺序化:维护一个自增的操作ID,所有客户端提交的命令由服务端排队编号,按编号广播。
  3. 客户端命令应用:收到服务端广播后,如果命令来源是别人,就在本地的Univer实例上执行对应Command;如果是自己发出的,则跳过,避免重复执行。

这里最关键的细节是:命令本身要无视来源地执行。也就是说,不管是本地用户改的,还是远端用户改的,执行的逻辑完全一致。这样的好处是,你不用为"谁改的"写两套逻辑,服务端也只用转发,不需要理解命令内容,复杂度一下子降下来了。

实际落地时,我用的是Node.js + NestJS + Socket.IO,命令消息长这样:

{ "id": 1023, "command": { "type": "mutation", "id": "SetRangeValuesMutation", "params": { "unitId": "sheet-001", "cellValue": { "2": { "5": { "v": "已审核" } } } } } }

服务端拿到后追加一个id,然后广播给同房间的其他客户端。客户端解析到SetRangeValuesMutation后,直接执行。这个流程我跑了一个月,基本稳定。

3.3 协同踩过的三个真实问题

第一个问题:本地操作后不应再执行远端广播的同一个命令。这个比较好解决,Univer执行命令时会有一个消息标识,我判断来源即可跳过。

第二个问题:Univer命令有些是"瞬时型"的,有些是"聚合型"的。比如一个拖动填充操作,实际会连续执行几十条子命令,你不会想让服务端每秒广播一百条消息。我的办法是把一个操作周期内的命令按时间窗口合并成一条批处理命令,再发给服务端。试下来网络压力下降了一个数量级。

第三个问题:光标位置同步。协同编辑时,A用户正在看哪一行、选中的区域是什么,B用户最好也能看到,否则填错了都不知道是谁在动。Univer的光标同步需要单独实现,我通过派发SelectionChange事件把用户的光标区域发给服务端,再广播给其他端。这个功能做完了,用户才会真正觉得"我们是在同一张表上干活"。

4. 插件系统:把一个表格库变成一张"业务白纸"

4.1 Univer插件到底是怎么注册和触发的

我第二篇文章里专门写过Univer的依赖注入设计。它整个容器是一个轻量的依赖注入(DI)容器,你注册的每一个插件,都会被当作一个可注入的服务。插件可以声明自己依赖哪些核心服务,Univer在初始化时自动把它们装配好。

我理解这种设计的一个好处:各功能模块之间不通过全局变量互相引用,而是通过插件容器按需获取服务。比如我写了个"天气数据自动填充"插件,它不直接操作表格UI,而是注册一个命令,通过命令调SetRangeValuesMutation填数据。UI层、核心层、插件层分开之后,乐意怎么扩展都不会污染主流程。

4.2 亲手写一个"成本估算"插件

为了演示,我写过一个给销售部用的"成本利润率估算"插件。需求是:选中B2到B10这些数量列,点击工具栏的"按成本利润估算",自动在下一列生成估算结果。

插件结构如下:

import { Plugin, CommandType } from '@univerjs/core'; export class CostEstimatePlugin extends Plugin { static override type = 'costEstimate'; override onStarting(injector: Injector): void { this._registerCommands(injector); } private _registerCommands(injector: Injector) { const commandService = injector.get(ICommandService); commandService.registerCommand({ id: 'estimate-cost', type: CommandType.COMMAND, handler: async (accessor) => { const selection = accessor.get(ISelectionService).getCurrentSelection(); // 拿到选中的单元格,计算利润,然后执行 mutation }, }); } }

注册之后,还需要把命令挂到工具栏上。Univer UI插件的工具栏配置支持自定义项,我用toolbarConfig.customMenu往里面塞了一个按钮,点击后执行estimate-cost命令。整个过程没有改一行核心源码,这也是我认为Univer值得选的一个重要理由——业务方的个性化需求永远改不完,插件隔离能让你少背很多"改坏主流程"的黑锅。

4.3 菜单、右键菜单和自定义弹窗的挂载思路

除了工具栏,Univer也提供了右键菜单的扩展点。我常用的是在单元格右键菜单里加"备注审批"这样的操作。做法上,通过MenuConfig或直接操作IMenuService注册一个MenuItem,绑定到onClick,在点击后弹出自定义的Modal或者侧边栏面板。

这里有个小提醒:Univer的弹层组件是基于它自己的UI系统渲染的,你用普通HTML/CSS也能做,但为了样式统一,建议优先复用它的Tooltip、Modal、Sidebar组件。如果你直接用第三方组件库,很容易出现层级错乱、样式冲突的问题。因为我试过用Element Plus的Dialog直接盖在Univer上,结果弹窗盖不住它的下拉框,原因就是两个库的z-index体系互相打架,最后还是老老实实切成Univer自带弹层,世界清净了。

5. 大数据量渲染与性能调优:一次真实的崩溃排查

5.1 为什么是Canvas而不是DOM表格

Univer的性能底气,很大程度来自它的渲染层用的是Canvas。传统DOM表格,单元格一多,浏览器就得创建成千上万个节点,你滚动一下,浏览器都得崩溃。Canvas的思路是:只用一块画布,根据当前可视区域动态绘制可见的单元格,滚动时重绘。

你可以把它理解成玩一个开放世界游戏,游戏引擎不会把整个地图一次性加载到显存里,而是只加载你镜头周围的一片区域。Univer的渲染也是这个逻辑,因此它支持几十万行数据的基础滚动查看,不会像DOM表格那样卡成PPT。

5.2 30000行数据的实测卡顿点

我拿一张3万行、8列的实际业务表做过压测。纯滚动浏览,流畅度没问题;输入筛选条件、点击排序,响应也在可接受范围内。真正卡的地方,是一次性设置大量单元格的值。

比如我后端返回一整批数据,直接循环构造cellValue然后发一个大命令,渲染层会把这个大命令拆成多次绘制,但是首次绘制的时间会非常长,界面会白屏一两秒。我的解决方案是分批提交,或在表格显示前先隐藏渲染层,数据set完再显示,这样用户感知不到中间的白屏。

另一个卡顿点是格式太多。如果你给整列都设置数字格式、条件格式,即使单元格数据没变,渲染层每次都要重新计算这些格式状态,性能会下降。我的经验是:数据多的时候,只在表头和关键列设置格式,正文区尽量克制使用样式。

5.3 懒加载、虚拟滚动之外的优化思路

Univer本身做了虚拟滚动,但这不意味着你不需要管数据量。我在实际项目里会额外做这几层优化:

  • 按需加载字段:后端接口只返回当前筛选条件下用得到的字段,而不是整张大宽表。
  • 数据分区加载:默认只加载前500行,用户滚动到底部时再请求下一页。因为我给一个报表系统做过一个9000行的明细表,全部加载后协同同步数据太大,滚动倒还好,但每次保存全量数据能把数据库写盘带宽吃满。
  • 防抖保存:用户编辑停止5秒后再自动保存,避免每敲一个数字就触发一次全量同步。

这些优化跟Univer本身无关,但在业务系统里叠加使用,效果非常明显。

6. 生产环境踩坑清单:从样式到权限的细节问题

6.1 为什么导出的文件要用服务端转换而不是前端生成

Univer的导出功能还处于半成熟阶段。前端可以实现导出XLSX,但遇到复杂的合并单元格、样式缩放、公式缓存时,导出的文件偶尔会和原生Excel表现不一致。最稳妥的方案是:前端把workbook数据JSON传给后端,后端用ExcelJS或者LibreOffice命令行转成最终文件。我印象最深的一次事故:客户用WPS打开导出文件,发现单元格背景色乱了,查下来发现是前端导出时少写了一个样式属性。后来改成后端统一转换,再也没出过类似问题。

6.2 权限控制:不要只靠前端菜单隐藏

因为Univer是网页应用,它的工具栏按钮项是可以动态配置的。当时老板说"财务的人才能看到导出按钮",我第一反应是前端配置一下就行。后来细想不对:只要功能逻辑在前端,就有被绕过或篡改的可能。

我的做法是:后端给每个会话下发权限数组,包含allowEdit、allowExport、allowViewFormula等字段。前端根据权限动态初始化工具栏和右键菜单。同时,在命令执行层做一遍权限校验——即使恶意用户伪造命令,后端也会拒绝真正影响数据的操作。简单说,前端管体验,后端管安全。

6.3 样式与回车的习惯差异

中文用户录入数据时,常常会在单元格里直接按回车换行。Excel的默认行为是回车会跳到下一个单元格,换行需要用Alt+Enter。Univer的默认行为同样遵循这个逻辑。但实际接需求时,有很多习惯了WPS或者某些国产在线表格系统的用户,并不知道要按Alt+Enter,于是打了一堆回车后发现光标跳到别的格子里了,就以为出bug了。

我在项目里加了一个设置开关,让用户可以在"回车是跳下一格"和"回车是换行"之间切换,还在设置面板里放了提示文案。这种细节看起来小,但实际上决定了业务方愿不愿意从Excel迁移过来。

6.4 移动端适配的现实

Univer官方对移动端的支持还在完善中,但我遇到过客户临时用iPad填报的需求。实测下来,触屏基础操作能做,但是工具栏太多按钮会挤在一起,下拉滚动也不如桌面端跟手。我的建议是:如果业务强依赖移动端,不要指望Univer一步到位,要么你封一层响应式布局,把工具栏精简到只保留必要的操作按钮;要么在移动端场景里,退回用普通表单组件收集数据,只在PC端使用Univer做复杂表格编辑。这样分工明确,两边体验都不会差。

7. 什么情况下我会劝你别用Univer

讲了这么多好话,我也要说点劝退的话。Univer不是万能的,如果你符合下面几种情况,选型时要慎重:

  • 团队没有前端基建能力。Univer虽然API清晰,但毕竟是框架级的东西,你需要有人能啃源码。如果你只是希望找个表格控件装上去就完事,那维护成本可能会超出预期。
  • 需要高度兼容Excel高级功能。比如宏、数组公式的极端用法、复杂的图表透视,Univer目前肯定做不到100%还原。如果客户要求"务必和Excel打开一模一样",那现阶段更适合用商业方案。
  • 项目周期特别紧。Univer的生态还在快速迭代中,接口偶尔会有breaking change,升级大版本时你需要预留适配时间。我记得官方一夜之间把初始化方式从new UniverSheet()改成new Univer()+注册插件的方式,老代码迁移起来非常费劲。
  • 需要离线编辑。Univer是一个在线优先的框架,如果你要的是完全离线环境下还能顺畅编辑、事后同步的体验,那它的协同架构还需要你自己做很多工作。

反过来,如果你的诉求是"在业务系统里嵌一个强交互、可扩展、未来能做协同的在线表格",那Univer确实值得押注。

写在最后

我从最开始只是想在页面上做个"好看一点的Excel",到后来把它做成了整套业务数据协作的中枢,整个过程最大的心得其实是:一个好的底层架构,能让你在遇到复杂需求时还有得选。Univer是不是最好的Web表格引擎,我不敢下定论;但它的命令模式、依赖注入、插件体系,确实让我在接需求时很少因为底层受限而改方案。

如果你也准备上车,我给三个建议:

第一,先花三天时间把官方的架构文档和Demo源码读透,尤其是命令和Plugin这两块,别急着堆功能。

第二,把Univer当成你业务系统的一个"引擎",所有数据输入输出都走它的Command体系,别贪图方便直改内部对象。

第三,协同功能尽量从第一天就规划好,哪怕你现在只做单机编辑,也要把操作日志、命令记录、版本号这些字段预留上,不然以后想加协同,数据层要动刀子的地方非常多。

最后送一个小技巧:遇到渲染或者协同的诡异问题,先用官方仓库的issue搜索一遍,经常能看到维护者的回复。它是一个活项目,比很多文档完备但几年不更新的老牌库要靠谱得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进