☰
Univer实践:用开源框架打造网页版在线Excel填报系统
2026/11/5 6:20:32 网站建设 项目流程

我在给一个业务系统做“数据填报后台”选型的时候,第一次接触univer。当时的需求很直接:客户希望像操作 Excel 一样在网页里编辑一张预算表,又要公式联动、又要实时保存、又要数据校验,还得能导回 xlsx。说白了,就是要做一个“在线 Excel”。市面上的方案我也翻过一轮,LuckySheet 用过、Handsontable 也搭过 demo,但真正落到这套系统里,最后让我决定花时间啃源码的,就是 Univer。

这篇文章不打算复述官方文档,而是从我实际接入、改造、踩坑的角度,把 Univer 这套开源表格框架说透。内容适合前端工程师、低代码平台开发者,以及正在做中后台在线文档产品的技术负责人。你会搞清楚 Univer 为什么值得选、它内部怎么组织的、怎么在三天之内把它接进自己的项目里,并且遇到那些奇奇怪怪的问题时,应该从哪里下手排查。

1. 为什么我会盯上 Univer:在线表格的场景与技术选型

1.1 业务场景:一个“在线 Excel”需求

很多所谓“在线表格”项目,最开始其实只是想让用户能看表格、填表格,并不需要复杂公式。可只要业务方用过 Excel,需求就会一路膨胀:合并单元格、条件格式、下拉选择、数据验证、跨表引用、图表、打印、撤销重做、多人同时填一张表。等你把这些都列出来,就会发现这已经不是一个普通“表格组件”能解决的,而是一整套电子表格引擎。

我当时接到的需求就是一个典型的“填报系统”:三十几个门店的运营,每个月要在系统里填写销售数据、库存周转、人员排班,总部要根据这些数据汇总分析。第一版我们做成了普通的动态表单,后面业务部门直接发来一个“母版 Excel”,里面有十多个 sheet、几十条公式、一眼望不到头的条件格式。他们的话术特别一致:“你们照着这个做就行”。

也就是说,前端得处理任意结构的工作簿,而不是固定字段的表单。这就排除了基于 Grid 的简单表格组件。

1.2 方案对比:表格组件、自研与框架化

当时的候选方案大概是这几类:

方案优点缺点
纯表格组件(如 AG Grid、Handsontable)渲染性能好、交互成熟公式、多 Sheet、合并单元格等“工作簿”能力弱
知名在线表格项目(如 Luckysheet / Excel.js 等封装)上手快,中文资料多维护状态、代码组织和二次开发体验参差
完全自研完全可控代价巨大,公式引擎这一步就够写一年
Univer模块化、公式引擎完整、数据层先进版本迭代快、国内资料集中在官方文档,社区偏新

我更倾向把 Univer 看成“表格基础设施”,而不是一个开箱即用的具体产品。它的定位不只是渲染一张表格,而是包含了工作簿模型、公式计算、协同编辑能力、命令系统这些东西。这就意味着,当你的业务逻辑复杂度超过“填个格子”的时候,反而比那些封装好的现成表格组件更好改造。

还有一个细节值得注意:Univer 的渲染层是 Canvas,UI 层可以换成任意前端框架。这意味着同样的核心逻辑可以挂到 React、Vue,甚至原生 JS 里。对我这种技术栈比较杂的团队来说,这是个性价比很高的选择。

2. Univer 的核心设计:从数据模型到渲染层的拆解

2.1 模块体系:千万别只装一个包

我第一次看到 Univer 的包结构时,第一反应是“这么多包,好复杂”。但理解之后会发现,这种拆分是它最大的优点。

Univer 把能力拆成了几个主要部分:

  • @univerjs/core:核心包,负责工作簿数据模型、命令系统、插件注册、Undo/Redo 这些最底层的逻辑。
  • @univerjs/sheets:Sheet 业务逻辑,比如单元格值、行列设置、合并单元格这些模型操作。
  • @univerjs/ui:用于渲染 UI 的插件系统,官方默认是原生实现,但它只负责“外壳”。
  • @univerjs/engine-render:Canvas 渲染引擎,负责把表格画到屏幕上。
  • @univerjs/engine-formula:公式引擎,负责解析计算 Excel 风格公式。

从架构上看,UI 是“壳”,数据模型是“核”,渲染引擎是“手”,公式引擎是“脑”。四者解耦后,开发者可以按需组合。比如我做的是纯展示型表格,完全不需要 UI 传统工具栏,那就只注册核心模块和渲染模块,界面全部自定义,只保留 Canvas 区域。

让我用一个生活类比来解释这件事:Univer 像一台发动机,拆开了卖缸体、活塞和电控系统。你可以只买缸体自己配电控,也可以整套买回去直接装车。前提是你知道自己的场景要动哪些部分。

2.2 命令系统与撤销重做:为什么它适合接入业务系统

Univer 数据层的另外一个关键设计是命令系统,这也是我敢把它接进审批流程类系统的原因。

Univer 里的每次操作(改单元格、插入行、合并单元格)都是一个 Command,也就是一个显式的动作。你可以拦截、修改、追加副作用,也可以针对命令做权限控制。和传统“直接赋值给 state”的思路不同,Univer 的每次变更都走统一通道,天然支持:

  • 撤销与重做
  • 操作日志记录
  • 协同场景下的冲突处理
  • 业务层面的鉴权

比如我们的填报系统要求“所有数据必须经过审核才能生效”,不能直接写入数据库。我在拦截层做了这么一件事:监听单元格修改命令,发现用户改的是禁用区域,直接返回false,让命令无法执行。这个逻辑如果放在普通 Element 表格里,你得挨个事件去控制,代码会散落一地。但 Univer 的命令系统让这件事变成一条清晰的路由。

2.3 公式引擎:不仅仅是“能算”

公式引擎是我最终决定押注 Univer 的最重要理由。业务方发来的“母版 Excel”里,除了最基础的SUM、AVERAGE,还有跨 Sheet 引用、IF嵌套、VLOOKUP,甚至条件格式公式。

Univer 公式引擎的兼容范围基本覆盖了常用函数,而且支持自定义函数。这意味着:运营同事用 Excel 写好的一套计算逻辑,系统可以原样在网页端复现,不用我去“翻译”成 JavaScript 函数。

更有意思的是它的计算机制。Univer 内部维护了一个“公式链”,每当单元格值变化,它会智能找出哪些单元格依赖这个值,然后只重算受影响的部分。这里的调度逻辑在源码里写得很清楚,不是打开表格就把所有公式全部刷一遍,效率完全不一样。

如果你将来的场景涉及到“用户填了 A2,B2 自动算出结果,C2 再根据 B2 做判断”,那这种计算链式联动就是刚需,用传统表单组件实现会痛苦得多。

3. 实操:五步把 Univer 集成进现有前端项目

下面这部分,我以一个最小的集成流程为例,演示如何把 Univer 从零接入到一个 Vite + Vue 3(或 React 也无妨,思路一致)项目里。我会尽量写出关键代码,同时说清楚每一步背后的原因。

3.1 第一步:初始化工程与容器

先准备一个基础前端工程。关键点是容器节点要有明确高度,Univer 才不会布局错乱。

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

这里给我的教训是:很多同学第一次集成后看到空白页,不是代码写错了,而是容器高度是 0。Univer 的 UI 插件默认会填满父容器,父容器高度不设,Canvas 自然渲染不出来。我习惯统一写成height: calc(100vh - 80px),给顶部导航栏留出空间。

3.2 第二步:安装依赖并注册插件

这一步比较劝退新手,因为 Univer 的包很多,而且版本演进比较快。以当前 0.x 的常用结构为例,命令大致是这个样子:

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

装完之后初始化:

import { LocaleType, Univer } from '@univerjs/core'; import { UniverRenderEnginePlugin } from '@univerjs/engine-render'; import { UniverFormulaEnginePlugin } from '@univerjs/engine-formula'; import { UniverSheetsCorePlugin } from '@univerjs/sheets'; import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui'; import { UniverUIPlugin } from '@univerjs/ui'; const univer = new Univer({ locale: LocaleType.ZH_CN, defaultWorkbook: { id: 'workbook-123', sheets: [ { id: 'sheet-001', name: '2025销售表', rowCount: 100, colCount: 20, cellData: {} } ] } }); univer.registerPlugin(UniverRenderEnginePlugin); univer.registerPlugin(UniverFormulaEnginePlugin); univer.registerPlugin(UniverUIPlugin, { container: 'app' }); univer.registerPlugin(UniverSheetsCorePlugin); univer.registerPlugin(UniverSheetsUIPlugin);

提醒一句:具体插件类名会随版本变化,在 v0.1.x 到 v0.2.x 之间有过调整。如果你安装的新版本里找不到某个类,去node_modules/@univerjs下面查看实际导出的类型,或者查官方文档的“Quick Start”。

这里的“为什么”在于:Univer实例就像一个操作系统,各种 Plugin 是被挂在系统上运行的进程。UniverUIPlugin负责外壳,UniverSheetsUIPlugin把表格 UI 和公式、渲染结合,如果没有UniverFormulaEnginePlugin,你输入=SUM(A1:A2)会直接变成普通文本。

3.3 第三步:创建 Workbook 并提供数据源

Univer 的工作簿模型,核心是“workbook → sheet → cell”。初始化时我们可以通过defaultWorkbook传入整个结构,也可以在不传的情况下,等初始化完成后通过 API 动态创建。

实际业务中,我更推荐初始化时不传静态数据,先渲染一个空表格,等接口把数据拉回来之后再填充。因为这样可以把“加载数据”和“初始化表格”解耦,避免网络慢的时候页面长时间白屏。

创建 Workbook 的核心代码大致长这样:

const workbook = univer.createUniverSheet({ id: 'workbook-abc', sheets: [ { id: 'sheet-1', name: 'Sheet1', rowCount: 100, colCount: 20, } ] });

如果你是从后端拿到一份 JSON 描述的工作簿结构,直接把sheets数组整个填进去就行。这一层就是 Univer 的“数据源”。和 Excel 里的.xlsx文件对应,我们系统里把后端返回的 JSON 存进数据库,下次打开时原样恢复,这也是一种“在线化”的实现。

3.4 第四步:写入、读取与保存

拿到workbook后,常见操作是通过命令系统操作单元格。下面这段代码演示的是“把 A1 设置为 100,然后读取 A1 的值”:

import { UniverInstanceType } from '@univerjs/core'; const currentWorkbook = univer.getActiveUnit(UniverInstanceType.UNIVER_SHEET); const activeSheet = currentWorkbook.getActiveSheet(); // 写入 activeSheet.getRange(0, 0).setValue('100'); // 读取 const value = activeSheet.getRange(0, 0).getValue(); console.log(value);

这里我强调的是“命令系统”。理论上getRange().setValue()这类 API 内部会包装成 Command,因此能自动进入撤销重做栈。如果只追求 UI 上看到数据,直接改内部 model 也行,但那样撤销功能就没了。

保存这块,我们通常不直接操作 Univer 的底层数据结构,而是把整个工作簿序列化成 JSON 快照,然后交给后端。

// 伪代码,示意快照上报 const snapshot = currentWorkbook.getSnapshot(); await fetch('/api/workbook/save', { method: 'POST', body: JSON.stringify(snapshot) });

快照的本质是整个工作簿的可序列化结构。拿到快照之后,下一次打开,把它作为defaultWorkbook传给new Univer()就行。这就是“在线保存”与“恢复编辑”的最小闭环。

3.5 第五步:接入导入导出

业务方的高频操作是“把做好的表导出成 xlsx 发到群里”。Univer 官方有 Excel 导入导出插件,名字在不同版本也调整过。我建议你在接这一步之前,先查看当前版本中是否存在以下插件:

  • @univerjs/sheets-import:负责 xlsx 文件的导入
  • @univerjs/sheets-export:负责导出 xlsx

如果你用的版本没有内置导入导出,也有一个务实的兜底方案:用xlsx库,把 Univer 的快照转换成 xlsx。核心流程是:

  1. 读取 Univer 快照中的sheet.cellData。
  2. 遍历每个单元格,把值写入XLSX.utils.aoa_to_sheet生成的二维数组。
  3. 通过XLSX.writeFile触发浏览器下载。

这种方式会丢掉一部分“高级特性”(如复杂格式、图表),但对付 90% 的“导出给业务方看”需求完全够用。

4. 完整案例:一个“月度销售数据填报页”

光说不练假把式。我把前面讲过的能力组合起来,做了一个小型的“月度销售填报页”。这个案例非常接近真实业务:门店运营填数字,公式自动汇总,管理员导出报表。

4.1 需求拆解与表格设计

表格结构的设计思路是模仿业务方的 Excel 母版:

  • Sheet1“录入表”:各门店每月销售额、订单量、退款金额。
  • Sheet2“汇总表”:用公式跨 Sheet 引用,计算“净销售额 = 销售额 - 退款金额”。
  • 录入区只保留当月列,其他列锁定保护。
  • 表格右下角放一个“保存并导出”按钮,前端把快照提交到服务端,并触发 xlsx 下载。

我把这些需求转化成 Univer 的实施清单:

需求Univer 能力
按门店分行,按月分列基础 Sheet 行列模型
录入销售额,自动计算退款率公式引擎 + 跨 Sheet 引用
不允许修改汇总区域命令拦截 + 单元格只读
保存后可恢复快照序列化
导出 xlsx导入导出插件/兜底方案

4.2 核心代码实现

这里我把关键代码片段贴出来,并配上说明。

第一步,初始化 Univer,注册必要的插件:

const univer = new Univer({ locale: LocaleType.ZH_CN, defaultWorkbook: { id: 'workbook-sales', sheets: [ { id: 'sheet-input', name: '录入表', rowCount: 50, colCount: 12 }, { id: 'sheet-summary', name: '汇总表', rowCount: 50, colCount: 12 } ] } }); univer.registerPlugin(UniverRenderEnginePlugin); univer.registerPlugin(UniverFormulaEnginePlugin); univer.registerPlugin(UniverUIPlugin, { container: 'app' }); univer.registerPlugin(UniverSheetsCorePlugin); univer.registerPlugin(UniverSheetsUIPlugin);

第二步,拿到 Sheet,设置表头和合并单元格。这里用了一个关键点:表头区域用 API 批量写入,而不是手动逐格 setValue:

const workbook = univer.getActiveUnit(UniverInstanceType.UNIVER_SHEET); const inputSheet = workbook.getSheetByIndex(0); const headers = ['门店', '1月销售额', '1月订单量', '1月退款额', '2月销售额']; headers.forEach((text, colIndex) => { inputSheet.getRange(0, colIndex).setValue(text); }); inputSheet.getRange(0, 0, 0, headers.length - 1).merge();

注意这里getRange的参数顺序,Univer 里一般是getRange(row, col, rowCount, colCount)。合并单元格之后,表头区域成为一个整体。

第三步,写入公式。汇总表里用跨表引用,比如“2月净销售额”:

const summarySheet = workbook.getSheetByIndex(1); summarySheet.getRange(0, 0).setValue('门店'); summarySheet.getRange(0, 1).setValue('2月净销售额'); // 录入表第 B 列是销售额,D 列是退款额 // 同一行门店,在录入表中第 2 行到第 31 行 summarySheet.getRange(1, 1).setFormula( 'SUM(录入表!B2:B32) - SUM(录入表!D2:D32)' );

setFormula就是喂给公式引擎的“菜”。此时你运行页面,只要录入表里填了数字,汇总表对应单元格会自己算出结果。比起自己写 JavaScript 监听事件、再手动重算,这个做法省掉一大截工作。

第四步,设置“只读区域”。我们不能让用户改汇总表的公式,所以给汇总表加一层保护性判断:

// 拦截命令,伪代码示意 univer.getCommandService().beforeCommandExecuted((command) => { const params = command.params; if (command.type === 'edit-cell') { const sheetId = params.unitId + params.subUnitId; if (sheetId === 'workbook-sales-sheet-summary') { return false; // 拒绝修改 } } });

真实拦截的写法会因为命令名和参数结构而变,但思路是通用的:汇总表区域的编辑命令全部拦截,让公式区变成一个只有管理员能改公式、其他人只能看的“结果区”。

第五步,保存逻辑:

const snapshot = workbook.getSnapshot(); localStorage.setItem('salesWorkbook', JSON.stringify(snapshot));

我之所以用 localStorage 做示例而不是请求后端,是想说明一个概念:Univer 的数据结构是可序列化的。真实项目里你把快照换成 POST 接口就行,整体思路不变。

4.3 运行效果与注意事项

跑起来后,界面上会有一个带工具栏的在线表格,录入表输入数字,汇总表的公式自动刷新。这个效果对业务方来说,基本就是他们熟悉的“网页版 Excel”。

但有几个细节要特别注意:

  • 公式引用名要用 Sheet 的实际名字:如果我的 Sheet 中文名叫“录入表”,公式里就得写录入表!B2,不能写Sheet1!B2。Univer 对 Sheet 名很敏感,这是新手最常踩的坑。
  • 快照里的单元格坐标是 0 基的:Excel UI 里第一行是 1,但 Univer 内部 API 往往从 0 开始。写代码之前先确认你的坐标语义,否则会出现“整体错一行”。
  • 保护单元格的优先级:拦截命令只对“用户可见操作”有效。如果你自己用代码去 setValue,命令仍然会执行。所以在代码里对汇总表写入前,你仍要严格控制业务逻辑。

5. 踩坑实录与排查清单

这一节是我最有感触的部分。Univer 这类新兴开源项目,最大的问题不是功能不够,而是报错信息不够“漂亮”。很多问题你搜不到,只能靠断点和源码排查。我把遇到过的典型问题整理成了清单。

5.1 白屏和三方依赖缺失

症状:页面加载完成,没有任何报错,但是一片空白。

排查步骤:

  1. 先确认容器高度是否正常。这个我前面说过,高度为 0 最常见的白屏原因。
  2. 打开控制台看有没有 “Failed to fetch” 或资源加载失败。Univer 早期版本依赖了一些静态资源文件,路径配置错就会白屏。
  3. 检查插件是否注册完整。如果你只注册了 UI 和 Sheets,没注册 RenderEngine,很多版本不会有显性报错,只是不渲染。
  4. 确认 Canvas 是否存在。Elements 面板里搜canvas,如果压根没有,说明渲染引擎没跑起来。

我的经验:遇到白屏时,别急着怀疑业务代码,先把初始化代码回退到官方 Quick Start 的最小示例,确认跑通了再逐步加业务代码。

5.2 公式不计算或区域错乱

症状:单元格里写了=SUM(A1:A5),回车后显示的却是纯文本,或者计算结果永远是 0。

排查思路:

  • 公式引擎没注册。这是最直接的:没有UniverFormulaEnginePlugin,公式全都会变成字符串。检查univer.registerPlugin有没有漏掉。
  • 公式前的等号格式。Univer 有时候会要求你在setFormula方法里不写等号,只写表达式SUM(A1:A5),而 UI 输入时需要带=。两种场景的规范不一致,最容易让人迷糊。
  • 依赖单元格没有触发重算。检查默认是否开启自动计算。如果关闭了自动计算,填完数据后需要手动触发计算。业务代码里保存之前最好拿到最新计算结果,否则可能出现“保存的值是旧值”。
  • 跨 Sheet 引用名称。重新检查 sheet 名,尤其是包含空格或特殊字符时的引用写法。尽量保持 Sheet 名单词简练,避免中文空格。

5.3 大数据量卡顿与性能调优

当我往表里塞入上万行数据、每行几十列之后,编辑开始掉帧。其实这不是 Univer 独有的问题,而是所有 Canvas 表格渲染都要面对的。

其中一个大元凶是“无脑填满整张表”。如果你初始化时搞了一个 10000 行 x 100 列的cellData,渲染引擎为了效率再高也会压力很大。正确做法是:只把真的有数据的单元格写入模型,其余空间保持为空。

另外,把“撤销栈”的历史深度调低。Univer 的每次编辑都会进入撤销栈,减少一点历史层级,换来的是更少的命令调度开销。

还有一个偏方:对高频更新的单元格,尽量用“只设置值”的 API,而不是触发完整的setRange流程。很多场景下,你根本不需要把一次值变更包装成可撤销命令。

5.4 版本升级带来的“隐形兼容问题”

Univer 的版本迭代速度相当快。今天我写的@univerjs/sheets-core-plugin,下一个版本可能就改名了。这里我必须说一句劝退帖式的经验:

不要把 Univer 的版本锁定在“终于跑通了”的旧版本上,也不要盲目升到最新版。我的做法是:指定精确版本,比如"@univerjs/core": "0.1.21",然后升级前查看 changelog,尤其是破坏性变更部分。这比在 npm 里直接latest稳得多。

另一个技巧是:把“初始化 Univer 实例”封装成独立模块,不要散落在业务代码里。这样即使升级版本,也只需要改一处,而不是全局搜索所有new Univer()。

6. 最后的个人经验:什么项目适合引入 Univer

如果用一句话总结我的判断,那就是:如果业务的核心是“让用户处理一张可以运算的工作簿”,Univer 值得选;如果只是“展示和填写固定表格”,用普通表格组件更省事。

我前面做填报系统时,团队里也有人说“这东西太重了,光初始化就几个插件”。但后来业务方要求支持任意加行减行、任意改公式、跨 Sheet 汇总的时候,那些“轻量方案”全部歇菜。这时候才显出 Univer 的价值:它给你的是 Excel 级别的基础设施,只不过需要你花一点学习成本去驾驭它。

具体到选型建议,我梳理出这么几条:

  • 项目里需要复杂公式联动、多 Sheet 结构时,Univer 的收益是远大于成本的。
  • 要做多人协同编辑,Univer 的命令系统数据层也能支撑,后续接入协同框架相对平滑。
  • 如果只是中后台里的一个“简单数据维护表”,那么不要为了炫技去引入 Univer,维护成本和交付时间都不划算。
  • 团队里至少要有一个人愿意读源码。Univer 目前的文档远称不上“事无巨细”,很多细节要从node_modules里的类型定义和源码注释去找。没这个人,遇到坑会非常痛苦。

如果你下定决心引入,我最后一个经验是:一开始就把 Univer 实例的创建、插件注册、工作簿加载、命令拦截这些封装成你项目里独立的数据层模块,而不是散落在组件里。这会让后续升级、维护、迁移都舒服很多。

Univer 还在快速进化,未来它的协同能力和本地化文档会越来越好。但我敢说,就在当前这个阶段,它已经是国内开源圈子里,把“网页端在线表格”这件事做得最体系化的一个项目。希望我这些踩坑经验,能帮你少走几步弯路,让项目早一天跑起来。

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

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

立即咨询