☰
Univer实战:在线表格引擎的二次开发与性能调优
2026/11/3 15:24:04 网站建设 项目流程

如果你接触过在线表格、网页端 Excel 这类需求,大概率有过一个不太舒服的体验:生态里始终缺一个能打的、真正开源的基础引擎。早几年大家第一反应是 Handsontable,但商业化之后授权条款越来越让人犹豫;Luckysheet 火过一阵,社区维护节奏也慢了下来。Univer 是这两年我越用越顺手的一套东西,它把自己定位成 Web 端的在线办公套件内核,而不只是“一个画表格的组件”。很多人搜“univer 在线”,本质上想要一个可以在浏览器里直接打开、编辑、计算 Excel 文件的前端方案,Univer 正好落在这个位置上。今天我不复读官方文档,以一个实际接入者的身份,把我用它在项目里替换自研表格、做二次开发的完整经验梳理一遍:包括为什么选它、怎么最小跑通、插件机制怎么玩、大数据量怎么调,以及那几个让我浪费过整晚的坑。

1. Univer 到底在解决什么问题:不是画格子,是接管整个工作簿

1.1 我们以前是怎么在网页里做“Excel”的

过去自研在线表格,大多数团队走的是两条路。

第一条路是直接用 HTML table 渲染。简单场景没问题,比如做个固定行数的对账单,但一旦涉及滚动、合并单元格、公式、筛选、列宽拖拽,DOM 节点数量会迅速膨胀。1 万行乘 10 列就是 10 万个单元格,哪怕浏览器能撑住,事件绑定和样式重绘也会把页面拖成幻灯片。就算你用虚拟滚动去优化,也只是把“无限高”变成“有限高”,表格的交互模型仍然要自己造轮子。

第二条路是引入一个表格组件库。这类库确实帮你画好了表格,但问题在于:它们把“表格是页面里的一块区域”当成默认前提。可真实业务要的不是一块格子,而是一个完整的工作簿。用户会拿鼠标拖选区、复制粘贴跨表格数据、在多个 Sheet 之间切换、用 SUM 和 VLOOKUP 算数,甚至希望撤销 Ctrl+Z 能回到二十步以前。这些行为已经不是“组件”能覆盖的,需要一套带数据状态机的应用框架。

Univer 的切入点就在这里。它一开始就没打算做一个“精美表格控件”,而是做一套 Web 端的工作簿运行时:有独立的数据模型、命令系统、撤销栈,以及按需插拔的公式引擎、渲染引擎和 UI。你可以把整张工作表当成一个可以被程序完全控制的对象,而不是一堆<td>标签。

1.2 Univer 的技术底座:Canvas 渲染 + 命令式数据流

第一次打开 Univer 的示例页面,我的第一反应是:它怎么做到滚动这么顺滑的。其实核心原因很简单,可视区单元格是画在 Canvas 上的,DOM 只承担容器和少量浮层。这意味着哪怕底层数据有几十万行,实际参与绘制的只有屏幕里那几百个格子。每次滚动,它重新计算可视区域,用requestAnimationFrame重绘,滚动成本跟总数据量基本无关,只跟可视区大小和样式复杂度有关。

在数据层面,Univer 走的是和大型桌面软件类似的“命令式数据流”。用户点击工具栏、输入公式、拖动填充柄,这些动作不会直接改单元格 DOM,而是先被包装成一条 command(命令),命令经过校验后提交到数据模型,再由数据模型触发渲染层刷新。这个设计带来了两个直接收益:所有操作天然可撤销、可重放;所有外部程序都可以通过“发起命令”来修改表格,而不需要关心 UI 细节。

这套架构最大的意义在于,它让 Univer 不只是一个“画格子的引擎”,而是一个能承载业务逻辑的办公套件内核。你可以在它上面做在线审批、数据录入、报表核对,甚至把一个业务系统 90% 的交互都装进一张 Sheet 里。

1.3 和 Luckysheet、Handsontable 对比,差异在哪

我整理过一张对比表,纯属个人使用感受,供选型参考:

方案渲染方式公式引擎二次开发难度开源协议
HandsontableDOM 为主需另接中等,Pro 功能收费商用需要购买 license
LuckysheetCanvas自带简单公式中等,社区维护放缓开源,但周边资料偏旧
x-data-spreadsheetCanvas较弱较低,功能简单MIT 类开源
UniverCanvas + 自研多领域引擎自研公式引擎可高可低,插件化核心开源,高级能力单售

选型结论不难下:如果只是做一个“展示型表格”,Univer 有点杀鸡用牛刀;但如果你要的是一个“能在浏览器里替代 Excel 操作习惯”的在线工作台,Univer 是目前少数从架构层面就把这件事当核心目标的方案。它不像组件库那样要求你妥协业务,而是让业务去适配办公软件已有的心智模型,这一点在内部推广时非常省劲。

2. 5 分钟跑通一个能编辑、能算公式的网页表格

2.1 按需把几个核心插件装齐

Univer 的安装方式可以分成两类:用官方 preset 一键拉起,或者手动按需组装。我实际更推荐手动组装,哪怕代码看起来多几行,好处是你能明确知道项目里装了哪些东西,排查问题时有据可查。

我当时是在一个 Vite + Vue 项目里接入的,核心依赖大致长这样:

npm i @univerjs/core @univerjs/engine-render @univerjs/engine-formula @univerjs/sheets @univerjs/sheets-ui

入口文件的注册方式,以我当前使用的 v2 写法为例(不同小版本 API 有变动,这是实际经验,TS 类型会给你最准的提示):

import { Univer, LocaleType } from '@univerjs/core'; import { UniverRenderEngine } from '@univerjs/engine-render'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui'; import { UniverFormulaEngine } from '@univerjs/engine-formula'; const univer = new Univer({ locale: LocaleType.ZH_CN, }); univer.registerPlugin(UniverRenderEngine); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsUIPlugin); univer.registerPlugin(UniverFormulaEngine);

这段代码注册了四个核心插件:渲染引擎负责把格子画出来;Sheets 插件负责数据模型和选区逻辑;Sheets UI 插件提供工具栏、右键菜单、过滤筛选这些交互外壳;公式引擎负责处理=SUM(A1:A10)这类的计算。

2.2 挂载容器和创建工作簿的细节

Univer 需要一个确定的 DOM 容器。HTML 侧很普通:

<div id="app" style="width: 100%; height: 600px;"></div>

然后创建工作簿:

const workbook = univer.createWorkbook('book1', { sheets: [ { name: 'Sheet1', rowCount: 1000, colCount: 50, cellData: { '0,0': { v: '城市' }, '0,1': { v: '销售额' }, '1,0': { v: '上海' }, '1,1': { v: 128000 }, '2,0': { v: '北京' }, '2,1': { v: 96000 }, }, }, ], });

这里有个很容易踩的坑:容器必须已挂载到 DOM,并且必须有实际高度。如果容器高度为 0,Univer 确实能创建实例,但 Canvas 画出来的区域是 0 像素,渲染结果就是一片空白。这个坑我在第五节会专门展开说。

创建完工作簿后,你会得到一个带完整 UI 的可编辑表格:可以点单元格、输入内容、调整列宽、右键插入行。到这里,一个“能用”的在线表格就已经出现了。

2.3 用命令去读写数据,而不是直接动 DOM

如果你只停留在“能看到表格”这一步,那还远远没有发挥 Univer 的价值。它真正爽的地方是程序化读写。我在业务里最常用的三个操作,下面这段直接可用(API 名称仍以你安装版本为准,思路是一致的):

const sheet = workbook.getActiveSheet(); // 读取 A1:B3 区域 const values = sheet.getRange(0, 0, 3, 2).getValues(); // 写入 C4:D5 区域 sheet.getRange(3, 2, 2, 2).setValues([ ['深圳', 88000], ['广州', 92000], ]); // 给 A1 单元格写公式 sheet.getRange(0, 0).setFormula('SUM(B1:B5)');

注意,这里的方法名和 Excel JavaScript API 很接近,都是getRange(row, col, rowCount, colCount),行和列也从 0 开始数。这套 API 设计对曾经写过 Office 插件的人来说几乎是零门槛。

在数据流层面,调用setValues和用户在界面上敲键盘会走同一条命令链路,所以程序写入的数据同样支持撤销。这意味着你可以把一个后端返回的数据集灌进去,用户按 Ctrl+Z 还能把灌入操作撤掉,体验和桌面软件高度一致。

2.4 在线 Excel 文件读写的常见姿势

很多人搜“univer 在线”,实际需求是上传一个.xlsx后能直接在浏览器里看和编辑。Univer 官方有导入导出插件,但版本成熟度在不同阶段差异很大。我们项目早期选择直接用 SheetJS(xlsx 库)做桥接,稳定且便宜:

import * as XLSX from 'xlsx'; // 把本地文件读成 ArrayBuffer const file = input.files[0]; const buffer = await file.arrayBuffer(); const workbook = XLSX.read(buffer, { type: 'array' }); // 取第一个 Sheet,转成二维数组,再填进 Univer const sheet = workbook.Sheets[workbook.SheetNames[0]]; const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 });

得到rows后,只需要把它转换成 Univer 的cellData对象,一次性写入。导出的过程是反向操作:先批量读取表格内容,生成二维数组,再用 XLSX 的aoa_to_sheet转回.xlsx二进制下载。桥接方案虽然少了点“原生”感,但胜在路径清晰、易于测试,推荐大多数业务先用这个方案稳住。

3. 插件化不是概念:工具栏、命令路由和自定义扩展

3.1 渲染引擎、公式引擎、UI 都是插件的意义

第一次接触 Univer 注册插件这种写法时,我其实有点嫌麻烦:为什么不直接搞一个全含包?后来深入用了,才明白这种设计的价值。

Univer 的插件系统本质上是一个“按需装配”的依赖注入容器。你要展示一个只读表格,那就只装渲染和核心;你要公式,才装公式引擎;你要工具栏,才装 UI 插件。每个插件在自己的作用域内工作,通过一个统一的accessor去访问其他模块的服务。这样做的好处有两个:一是包体积可控,移动端场景可以砍掉大量 UI 依赖;二是当你需要替换某个模块时,不需要动全局代码。

这就像你买了一个毛坯房,水电管线已经铺好,但装不装空调、装什么牌子的空调,由你决定。Univer 提供的不是一个“所有功能的期房”,而是一个让你按房间需求选配的基础设施。

3.2 一个自定义命令的落地写法

插件机制最实用的扩展点,是注册自己的业务命令。我在一个项目里需要加一个“一键把当前表格导出为 PDF 送审”的按钮,官方工具栏没有这个能力,但我可以通过命令系统把它做进去:

import { CommandType, ICommand } from '@univerjs/core'; class ExportToPDFCommand implements ICommand { id = 'business.export-to-pdf'; type = CommandType.OPERATION; handler(accessor) { const univer = accessor.get(Univer); const workbook = univer.getActiveWorkbook(); const snapshot = workbook.getSnapshot(); // 把 snapshot 交给后端生成 PDF,或走浏览器的打印流程 // 这里只示范命令的挂载点 console.log('export snapshot', snapshot); return true; // 返回 true 表示命令执行成功 } } univer.registerCommand(ExportToPDFCommand);

这样注册之后,任何一个内部模块都可以通过univer.getCommandService().executeCommand({ id: 'business.export-to-pdf' })来触发导出。它和用户在界面上点击的行为完全一致,也继承了命令系统的可撤销语义(如果这个命令本身允许撤销)。更重要的是,你后续想通过企微、钉钉的消息卡片触发导出,也只需要发一条命令,不需要再写第二套逻辑。

3.3 官方插件到底覆盖了多少日常功能

到了 v2 时代,Univer 官方包里已经覆盖了办公里绝大多数高频动作。我按业务场景整理过一份清单:

  • 单元格编辑、选区、复制剪切粘贴:Sheets 核心 + 剪贴板插件。
  • 公式输入、跨 Sheet 引用、公式提示:公式引擎 + 公式 UI。
  • 数字格式(百分比、货币、日期):数字格式插件。
  • 筛选、排序、条件格式:数据校验、条件格式、筛选相关插件。
  • 批注、超链接、查找替换:独立插件按需引入。
  • 撤销重做:命令系统本身自带,不需要额外配置。

这个覆盖度对我而言已经可以满足 80% 的“在线 Excel”需求。剩余 20%,比如专有的审批流、数据源联动、自定义校验规则,恰恰是自定义插件可以补足的空间。你不需要在 Univer 外面包一层厚厚的业务壳,而是应该把这些业务逻辑写成一个个命令和插件,塞进它自己的体系里。

3.4 自定义单元格渲染适合做什么

Univer 的 Canvas 渲染器允许你替换单元格的内容绘制逻辑。我第一次看到这个能力时,想到的第一个场景是“表格里的迷你看板”。一个单元格里不只放文本,还能画一个进度条、一个趋势折线、一个状态圆点。

具体做法是注册自己的 CellRenderer,在绘制阶段画完默认的单元格背景和边框后,继续绘制业务图形。这个能力的好处是渲染性能和 DOM 无关,几百个带图形的单元格在滚动时也不会拖垮页面。

我用它做了一个库存预警表:每一个物料行的“当前库存”列,根据库存阈值画红黄绿圆点,旁边还有一个小进度条表示占比。用户不需要看数字就知道哪些物料需要补货。这种事情以前要么用不同的背景色单元格硬扛,要么引入额外的图表库做浮层,在 Univer 里都变成了渲染层的小改动。

4. 数据量大之后,Canvas 也救不了你:实测优化路径

4.1 10 万行数据来到面前,先看瓶颈在哪

很多人以为 Univer 用 Canvas 渲染,大数据量就高枕无忧了。这个想法要修正一下。Canvas 解决的是“绘制层”的压力,但数据模型、公式引擎、样式序列化仍然可能成为瓶颈。

我在一个供应链项目里压过 10 万行 x 20 列的数据,体感如下:

  • 初始灌入数据,如果逐格setValue,会明显卡顿,因为每个格子都会触发一次命令传播和校验。
  • 一次性写入整个区域,速度和体验会好很多,但如果每个单元格都带独立的样式对象(不同字号、不同底色、不同边框),内存占用会非常夸张。
  • 滚动本身基本保持流畅,但一旦公式区域跨度过大,比如几十个 VLOOKUP 同时存在,输入任意一个值都可能触发大范围重算,卡顿点在这里。

所以结论很清晰:性能瓶颈通常不在“画”,而在“数据变更后的传播链路”。

4.2 我实际采用的优化组合

针对上面这几个瓶颈,我的做法按优先级排列:

第一,批量写入。无论初始数据还是接口回填,一律用setRangeValues或构造完整cellData后再创建 Workbook,绝不循环逐格写。实测 5 万行数据,逐格写要十几秒,批量写一两秒。

第二,样式瘦身。能让用户批量设置的样式,绝不让每行每列生成独立对象。尽量把样式相同的区域合并成统一的 style id,Univer 内部会对样式做去重,但前提是代码里别自己造出十万个“看起来一样但引用不同”的样式对象。

第三,限制公式重算范围。业务上能把计算放到后端的就放到后端,前端只保留最核心的实时公式。否则用户每改一个单元格,都会带动整列整表重算。

第四,滚动过程不做业务。不要在 scroll 事件里实时查询表格数据、渲染浮层。所有可视区之外的业务逻辑,等滚动结束再执行。

经过这一套组合,那个表的滚动和编辑体验达到可接受状态:普通办公笔记本、Chrome 浏览器、40 帧以上稳定运行。

4.3 移动端和高 DPI 屏幕上最容易忽略的细节

移动端接入 Univer 时,我踩得比较深的一个点是devicePixelRatio。Canvas 绘制如果不按 DPR 缩放,在 iPhone 这种高密度屏上字体和线条会发虚。Univer 主体渲染已经处理了这个问题,但是如果你写了自定义 CellRenderer,一定要自己做 DPR 换算:

const dpr = window.devicePixelRatio || 1; ctx.scale(dpr, dpr);

否则你画的进度条可能在该清晰的屏幕上糊成一片,越新款的手机越明显。

另一个细节是容器宽度。移动端横向空间有限,如果表格列数很多,最好给容器设置一个最小宽度并允许横向滚动,或者优先展示关键列再让用户展开更多列。不要让表格在小屏上把所有列挤成一团,那既影响触控精度,也让数字阅读变得困难。

5. 接进生产环境后,我踩过的五个坑及排查过程

5.1 包版本不一致导致工具栏白屏

第一次在生产环境部署时,我犯了一个经典错误:用npm i @univerjs/sheets的时候不小心把@univerjs/core装成了另一个 minor 版本。结果页面能显示表格,但工具栏区域空白,右键菜单点不出任何东西,控制台报Cannot read properties of undefined (reading 'x')。

这个问题的坑在于,Univer 的插件系统高度依赖各包之间的精确匹配。不同 minor 版本的内部服务定义可能有变动,插件注册阶段就会注册失败或者注册到错误的服务上。

排查链路:先看 package.json 里@univerjs/*的版本是否全部一致;再看 node_modules 里实际安装的版本;最后在注册插件前打印univer实例的内部访问器看哪些服务缺失。

解决办法很朴素:所有@univerjs/*包统一锁版本,不要在依赖里写^:

"@univerjs/core": "2.0.1", "@univerjs/engine-render": "2.0.1", "@univerjs/engine-formula": "2.0.1", "@univerjs/sheets": "2.0.1", "@univerjs/sheets-ui": "2.0.1"

实在怕麻烦,就直接用官方 preset 包,preset 内部已经替你锁好了配套关系。这听起来像是废话,但在 Univer 直达生产环境的路上,版本不一致是最容易白屏整晚的头号原因。

5.2 容器高度为 0 导致整张表消失

另一个让我印象深刻的坑发生在一次弹窗改造里。我在一个 Dialog 组件内初始化 Univer,弹窗打开时 DOM 已经存在,但动态获取到的容器高度为 0。Univer 实例照常创建,工作簿也创建了,界面上却什么都看不到,连工具栏都不见。

排查方法挺直观:先在 devtools 里看容器元素的实际高度,然后在 Univer 创建前打印container.clientHeight。当弹窗或折叠面板使用动画时,初始化时机很容易落在元素尺寸尚未展开的时间点。

解决办法有两个方向:一是给容器写死一个 CSS 最小高度,至少保证创建时不为 0;二是等待弹窗动画结束,在transitionend或nextTick后再初始化 Univer。大多数场景下,固定高度更省事,因为 Univer 组件自身也建议你给它一个确定的工作区。

5.3 公式小数位数与金融计算精度

做财务场景时,0.1 + 0.2这种精度问题一定会碰到。Univer 的自研公式引擎内部仍然使用 IEEE754 浮点数,所以不可能因为换了渲染引擎就自动解决精度问题。我在一张对账表里看到过明明是=0.1+0.2,单元格显示 0.3,但导出到后端后收到的值是0.30000000000000004。

这个问题的坑在于:屏幕显示可能因为“显示精度”设置被截断或四舍五入,但底层保存的数值不会截断。你如果不对导出的 JSON 做处理,脏数据就落到库里了。

处理方式分两种。第一种是金额类,建议数据库直接存整数分,前端展示时再除以 100。第二种是通用小数,在导出和接口提交前统一做格式化:

const normalized = Number.parseFloat(value.toFixed(2));

同时可以在 Univer 的数字格式插件里把单元格格式设置为“两位小数”,让用户看到的和底层保存的一致,减少误解。

5.4 冻结行列、合并单元格的索引陷阱

Univer 的 API 大多数使用 0 起始索引,但 Excel 用户习惯从 1 开始数行号。这个差异在冻结行列和合并单元格时特别明显。我想冻结前两行,下意识传了个1,结果只冻了第一行,第二行还在滚动范围内。

正确姿势是明确区分“第几行”和“索引值”:冻结前两行,索引是0和1,接口里传的分隔位置通常是 2。合并单元格也一样,要把 A1:C1 转成{ row: 0, column: 0, rowEnd: 0, columnEnd: 2 },而不是{ row: 1, column: 1, rowEnd: 1, columnEnd: 3 }。

我还遇到过合并区域读取的问题。合并后只有左上角单元格有值,其他位置读到的是空字符串。业务侧如果按视觉上的“整块内容”去读,需要自己额外记录合并规则,不要指望 API 自动把合并区域的其余位置也填充成同一个值。

5.5 React / Vue 生命周期里实例回收不彻底

用 React 接入时,最容易出问题的点是组件的卸载。Univer 创建后会在全局注册一堆监听器、定时器和渲染循环。如果组件卸载时不调用univer.dispose(),这些资源不会自动回收。

我在 React 中推荐的做法是把创建和销毁放进useEffect:

useEffect(() => { const univer = createUniver(); return () => { univer.dispose(); }; }, []);

还有一个隐蔽问题:React StrictMode 在开发环境会执行两次 effect,如果你每次执行都创建一个新实例,就可能出现“两个 Univer 抢同一个容器”的现象。处理办法是组件内用一个 ref 缓存实例,第二次执行时先销毁再创建,或者干脆在 StrictMode 下只做一次创建。

6. 从在线表格到在线文档再到协同,Univer 的扩展边界

6.1 同一套内核里装下 Sheets / Docs / Slides

Univer 不止做表格,它还在持续完善文档和演示模块。这件事的意义在于:你可以用同一个数据协议,把“表格、文档、演示”拼在同一个业务界面里。

我在一个项目里做过一个“检查记录单”:上半部分是文档风格的检查项说明,中间嵌着一张需要填写的表格,下面还有签名区域。用传统方式实现,要么是多个组件拼装,要么是 iframe 嵌套两个系统。用 Univer 的路线,表格部分天然就是一个可交互的 Sheet,文档部分可以走 Docs 模块,数据层都在同一套内核里流转。

如果你有类似“一个界面同时克制化编辑多类型内容”的需求,Univer 的这套统一布局会显著降低系统复杂度。

6.2 协同编辑的接入思路:命令同步 + 快照兜底

提到在线办公,协同编辑是绕不开的话题。Univer 的命令系统天然适合做协同:每个用户的编辑行为本身就是一条命令,只要把命令转发给其他端,就能复现同样状态。这在架构上非常直观,但落到生产环境,还需要处理命令并发、冲突、历史记录同步的问题。

我的建议是分两步走。第一步先做“同步浏览 + 轻协同”,用 WebSocket 把命令广播给其他在线用户,同时定期把工作簿快照同步一次,作为异常兜底。第二步再考虑更复杂的 OT 或 CRDT 方案。不要一上来就上完整协同协议,因为在表格这种高并发、高频操作的场景里,协同的边界条件非常多,没有充分测试就上线风险很高。

对绝大多数内部系统用户来说,“能看到别人在哪一格、按顺序协同编辑”已经比之前的“各改各的再合并”体验好很多了。

6.3 把它嵌进审批流、CRM、数据中台的真实场景

最后说点我实际落地过的场景,给想引入的人一个方向参考。

在审批流场景里,我把 Univer 做成了“附件式审批”的核心组件。用户在发起审批时,可以在线填写一张标准化表格(供应商报价、库存盘点、项目排期),审批人打开单据看到的是同一张表,但权限不同,有些列只读,有些列可编辑。这个体验远好于让用户上传 Excel 后审批人再下载打开。

在 CRM 场景里,Univer 被当作客户的批量操作工作台。每次可以从接口拉一批客户数据,操作人直接在表里标记跟进状态、修改负责人优先级,一次性提交回后端。用户长期习惯了 Excel 的操作方式,这个切换几乎没有培训成本。

在数据中台场景里,Univer 更多被用来做“数据字典预览”和“指标口径核对”。数据团队生成好快照表,业务方在 Web 端直接查看、评论、批注,不通过 IM 来回传文件。

我个人的习惯是,任何组件接入之前,先做一次不打开文档的冒烟测试:生成一个 1 万行数据的工作簿,测创建、编辑、撤销、重新读取快照是否一致;这四件事没问题,再谈花哨功能。Univer 也值得用这个标准去验收。如果你正准备拿它做在线 Excel 的底层,先把版本锁定和容器高度这两个问题处理干净,后面会顺畅很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询