第一次被“怎么在网页里塞一个Excel”这个需求打翻脑袋的人,肯定不在少数。两年前我们做产品后台的报表模块时,产品经理甩给我一张需求图:页面上要能像本地表格软件那样直接改数据、算公式、还能导出给客户。我盯着需求文档想了很久,从研发角度把市面上能找的方案全部过了一遍,最后才落到Univer身上。
Univer是什么?一句话描述:一套完全开源、用TypeScript编写的Web办公套件框架,主打能力是表格在线编辑,同时也覆盖文档与幻灯片场景。它能让开发者把类似本地表格软件的编辑体验,原样嵌入到自己的Web系统里。换句话说,你的SaaS、后台管理、低代码平台,都可以借助它获得所见即所得的表格编辑、公式计算、数据筛选,甚至多人实时协同编辑能力。适合谁?适合所有需要在自研产品里做“在线表格”的团队,尤其是前端工程师、前端架构师,以及需要做技术选型评估的技术负责人。
后面这六千多字,是我从零接入Univer、把它从Demo推到生产环境、又基于它做了二次开发之后攒下来的完整记录。不绕圈子,直接说重点。
1. 为什么偏偏是Univer
1.1 业务场景的原始痛点
先说我们当初的真实处境。产品里有一个“月度销售报表”模块,业务方要求:
- 页面里能直接看到一张已经填好数据的表格
- 用户可以点某个单元格改数字,修改后自动触发整行合计、占比、同比
- 表格能保存回后端,下次打开数据还在
- 追加一个隐藏需求:未来可能多个人同时打开这张表,不能出现谁后保存谁覆盖的问题
当时组里第一个念头就是“用HTML写个table算了”。但刚动手就发现不对:表格编辑器不是“画一个静态表格”,而是要求单元格可以编辑、可以拖动选区、可以复制粘贴、可以右键菜单、可以冻结首行。如果全部自己用DOM去拼,光是选区交互和输入框的定位问题就能填满两周的开发量。
第二个念头是引入现成的Web表格库。我们把候选清单列了一圈:Luckysheet、Handsontable、SheetJS、SpreadJS,最后才是Univer。
1.2 市面上同类方案的横向对比
我把当时做的选型对比表放在这里,参数是我实际测试后记录的,不是抄文档:
| 方案 | 开源协议 | 技术架构 | 协作能力 | 包体/初始化体验 | 二次开发难度 |
|---|---|---|---|---|---|
| Luckysheet | MIT(后续项目交接方向有变化) | jQuery + Canvas 混编 | 依赖协同层Redis,搭建成本高 | 体积尚可,但生态老化,长期维护存疑 | 中等,文档较散 |
| Handsontable | 商用授权(非开源协议) | DOM表格渲染 | 非内置,需自行对接 | 上手快,但授权费按开发者席位收 | 低,但受许可证约束 |
| SheetJS | Apache 2.0(社区版) | 纯解析/序列化引擎 | 不支持,核心是文件格式转换 | 解析能力极强,但编辑交互弱 | 低,适合做导入导出,不适合做编辑器 |
| SpreadJS | 商用授权 | Canvas渲染 | 有协同方案,单独收费 | 功能完整,但价格不便宜 | 中高,依赖厂商支持 |
| Univer | Apache 2.0 | TypeScript + Canvas分层 | 原生支持协同体系(OT) | 模块化加载,可以按需裁切 | 中高,但架构清晰,愿意读源码就能驾驭 |
这张表排完,答案已经比较明确了。商业组件的问题不是不好用,而是“License能不能过法务”和“预算够不够”这两关。Luckysheet当年确实火,我也在不少项目里见过它的影子,问题是它后续的迭代节奏越来越慢,社区讨论区里经常有关于bug没人修的抱怨,把它放进生产环境需要自带“长期维护”的觉悟。而Univer那时候虽然版本不算老,但胜在架构从一开始就是按现代前端标准设计的,TypeScript编写、命令模式、模块解耦,这些恰恰是我这种喜欢改源码的人最看重的点。
1.3 选择Univer的三个决定性理由
第一个理由是许可协议干净。Apache 2.0意味着我可以放心地把核心包放进商用产品里,不需要担心哪天收到律师函,这对ToB项目来说是硬指标。
第二个理由是它把“编辑体验”和“数据模型”分开了。Univer内部有非常清晰的Model层与View层划分。我可以在不碰渲染的情况下,直接通过API改单元格数据,然后UI自动刷新。这对接后端数据回填来说太舒服了——我只需要把后端返回的JSON映射成工作簿结构,渲染层自己会搞定剩余事情。
第三个理由是它的协同能力不是“后加的补丁”,而是写进核心设计的方向。Ops、操作转换、服务端SDK这些都有明确模块。虽然我们第一阶段没上协同,但架构上预留了这条路,后面真要做多人编辑,不需要推翻重来。
2. 核心架构拆解:一次把Univer的设计逻辑讲透
2.1 Model层:工作簿的数据结构远比想象中规范化
很多人第一次看Univer文档时会被“Workbook、Worksheet、Range、Cell”这些术语绕晕。我当时也花了点时间,后来找到一个特别好的理解方式:把Univer的Model层想象成一个“内存里的Excel对象模型”。
在Univer里,一份数据文件的顶层是一个Workbook(工作簿)。一个Workbook里有多个Worksheet(工作表),每个Sheet又被划分为行列坐标系统,坐标系统里每个格子是一个Cell。Cell的值类型可以是字符串、数字、布尔值、公式,甚至公式计算后的结果缓存。Range则是对一块矩形区域的抽象,用来描述“从哪行哪列到哪行哪列”的范围。
这种分层模型带来的好处非常直接:当你做类似“把第2列所有单元格的值乘以1.1”这种批量操作时,不需要逐个操作DOM,只需要对Range对应的数据节点做更新,渲染引擎监听到变化后按需重绘。数据与视图分离,是这个框架比老一代jQuery表格库先进的最根本原因。
2.2 Command模式:为什么undo/redo能做得那么顺滑
Univer里几乎所有用户操作,最终都会被封装成一条Command。什么叫Command?简单说,就是“一个包含了操作类型、影响范围、新旧数据的不可变对象”。
比如用户在A1单元格输入了数字100,Univer内部不会直接去改A1的值,而是生成一条类似“SetCellValue,范围A1,旧值空,新值100”的命令,再由命令系统负责派发、执行、记录历史。当用户按下Ctrl+Z时,它只需要把命令队列往前退一格,用旧值去覆盖新值即可。
这一点在我做二次开发时帮了大忙。我想在工具栏加一个“一键清空所有公式”的按钮,实现方式就变得非常简单:把批量清空公式的动作封装成一条Command,既不用手动管理历史记录,也不用担心用户操作到一半想撤回。你只需要遵循“操作 -> 命令 -> 派发”的链路,而不是在各处散落可直接改数据的代码。
2.3 渲染层:Canvas为什么要分这么多层
Univer没有选择用DOM渲染单元格,原因是性能天花板太低。一张500行x20列的表格,如果每个单元格都是一个DOM节点,那就是一万个节点,浏览器滚动时的重排、重绘成本会直接拖垮CPU。Univer的渲染方案是Canvas分层绘制:基础网格画一层、单元格内容画一层、选区高亮画一层、浮动编辑框单独一层。滚动时只重绘视口范围内的内容,而不是整张表。
我当时在项目里第一次打开一个2万行的Excel文件时,滚动条拖动非常跟手,没有任何卡顿感;换回旧的DOM方案,同样的数据量早就白屏了。这就是分层渲染的价值所在。你不需要完全理解它的渲染管线的每个细节,但需要知道它为什么快——因为它的绘制单元是“像素操作”而非“DOM节点操作”。
2.4 插件化:Univer的边界在哪里
Univer的功能模块,本质上一堆插件。表格能力是插件、渲染引擎是插件、公式计算是插件、协同协议也是插件。这种插拔结构的直接意义是:你不需要把一个包含全部功能的“全家桶”塞进项目里。
比如我们最初只用表格,不需要文档和幻灯片,运行时只需要注册和表格相关的插件。包体积一下就压下来了。等到后面业务要再加富文本备注功能,再动态注册文档插件即可,不需要改业务代码。这种模块化的边界设计,很适合中大型前端项目,因为它让“按需加载”不是嘴上说说,而是真实可执行的技术方案。
3. 从零到一:把Univer接入真实项目的完整实操
3.1 最小化接入:10分钟跑通一个可编辑表格
我以我们在CRM系统里嵌入“月度销售报表”的场景为例,给出最小化接入方案。下面是某一版稳定接入代码的骨架,当前最新版本的API可能有微调,但核心链路是稳定的:初始化容器、注册核心插件、传入数据配置、挂载渲染实例。
先创建项目的HTML容器,要保证容器有明确的宽高且position不为static,否则渲染层无法计算视口:
<body> <div id="app" style="width: 100%; height: 600px; position: relative;"></div> </body>然后安装核心依赖。我这边是npm方式,项目基于Vue3,但Univer是框架无关的,React也可以按同样步骤接入:
npm install @univerjs/core @univerjs/preset @univerjs/sheets @univerjs/ui @univerjs/engine-render @univerjs/engine-formula再写初始化逻辑。这里我把远程后端拉回来的销售数据,构造成Univer需要的Workbook结构:
import { Univer } from '@univerjs/core'; import { UniverSheetsCorePlugin, UniverRenderPlugin, UniverFormulaEnginePlugin, UniverUIPlugin } from '@univerjs/preset'; // 不同版本的包名可能不同,以官方文档为准 // 模拟从后端接口返回的数据结构 const backendRows = [ { region: '华东', amount: 128900, target: 140000, date: '2024-06' }, { region: '华南', amount: 98600, target: 100000, date: '2024-06' }, { region: '华北', amount: 151200, target: 150000, date: '2024-06' } ]; function buildWorkbookData(rows: typeof backendRows) { const header = ['区域', '实际销售额', '目标销售额', '完成率', '月份']; const body = rows.map((row, idx) => { const rate = row.target ? (row.amount / row.target) : 0; return [ row.region, row.amount, row.target, Number(rate.toFixed(2)), row.date ]; }); return { id: 'workbook-sales', sheets: [ { id: 'sheet-1', name: '月度销售', rowCount: body.length + 1, columnCount: header.length, cellData: [ header, ...body ] } ] }; } const univer = new Univer({ locale: 'zhCN', workbook: buildWorkbookData(backendRows) }); univer.registerPlugin(UniverSheetsCorePlugin); univer.registerPlugin(UniverFormulaEnginePlugin); univer.registerPlugin(UniverRenderPlugin); univer.registerPlugin(UniverUIPlugin);跑起来之后,页面上就会出现一个带工具栏、可以直接编辑和计算的可交互表格。这时候公式会正常计算,但数据是纯前端的——下一步需要把它接上后端。
3.2 数据回填与保存:绕不开的序列化设计
接入编辑器只是第一步,真正麻烦的是数据怎么存。我们后端数据库并不是按“单元格坐标”存数据的,而是在业务库里存了“区域、金额、月份”这样的业务字段。所以我在做数据回填时,用的是上面代码里的映射方式:先把业务数据翻译成二维数组,再塞进Workbook配置。
存回去的时候方向相反。用户在前端改完数据后,我监听Univer的变更事件,拿到当前的整个Sheet数据,再反序列化成业务结构,POST给后端接口。这里面有一个很关键的注意点:不要在用户每次敲键盘的时候就发一次请求。Univer的变更事件触发频率很高,生产环境必须做节流,或者依赖命令队列做批量保存。
我当时采取的方案是:监听命令系统里的“命令执行完成”事件,记录变更命令数量,一旦达到20条或者距上次保存超过5秒,就触发一次自动保存快照。实际线上跑下来,服务器压力很小,用户也不会感知到保存卡顿。
3.3 常用API与操作套路
开发过程中,下面这些API基本天天用,列出来给后来者做个索引:
| 能力 | 调用入口 | 典型场景 |
|---|---|---|
| 获取当前活动工作表 | univer.getActiveSpreadsheet().getActiveSheet() | 读取用户当前编辑的表单 |
| 读取单元格数值 | sheet.getCellValue(row, col) | 保存前批量采集数据 |
| 写入单元格值 | sheet.getRange({ row, col }).setValue(值) | 工具栏按钮批量填充 |
| 设置公式 | sheet.getRange({ row, col }).setFormula('SUM(A1:A10)') | 动态写入计算公式 |
| 批量更新范围 | sheet.getRange({ startRow, startColumn, endRow, endColumn }).setValues(二维数组) | 一次性回填整块数据 |
| 监听数据变更 | univer.getCommandService().onCommandExecuted | 做自动保存和操作日志 |
这里有个我踩过的坑:setValue和setFormula不是同一个接口,如果你同时给一个单元格传了value和formula,最终显示以公式计算结果为准。刚开始不熟悉时容易把这两个字段同时放到配置里,结果表面看起来是公式失效,实际是value覆盖了渲染。正确做法是二选一,公式单元格只配置formula,不配置value。
3.4 样式与体验调优:从“能用”到“好用”
最小化接入跑通之后,距离交付还差一些体验细节。表格工具最核心的体验是“像Excel又不完全照搬”。我们做了三件小事,效果立竿见影。
第一件是设置列宽和行高。后端返回的业务字段中文较多,默认列宽会挤压得很难看。我在Workbook配置里增加了每个Sheet的defaultColumnWidth和rowHeight参数,把区域列设为120像素、金额列设为100像素、完成率列设为90像素。这样一来,用户打开表格时第一眼看到的就是整齐可读的列布局。
第二件是冻结首行。销售报表的表头必须固定在顶部,不然滚动两屏之后就分不清哪列是哪列了。Univer的Sheet配置里有freeze字段,设置 { row: 1, column: 0 } 就把第一行冻结了。
第三件是处理长文本换行。有的备注格子里业务人员会写一大段话,如果不开启wrapStrategy,内容会溢出到相邻空白单元格。在样式配置里把对应列的wrapStrategy设为1(自动换行),整体视觉干净很多。
4. 常见问题与排查技巧实录
4.1 挂载容器被CSS transform缩放后,单元格点击位置错乱
这是第一个遇到的诡异问题。当时项目里有个弹窗用了transform: scale(0.9)做缩放动画,Univer渲染层在容器内绘制的坐标没问题,但浏览器实际的鼠标事件偏移和Canvas绘制的逻辑坐标对不上了。表现为:点击A3单元格,高亮框出现在A1位置。
排查下来,问题不在Univer本身,而是任何基于Canvas绘制但没适配transform缩放的库都会遇到。解决方案有两个:要么不要在transform缩放的容器里挂载Univer,弹窗打开延迟到动画结束;要么在初始化前获取真实缩放倍率,做坐标修正。我们最终选择让弹窗动画结束后再渲染编辑器,简单稳妥,不碰坐标计算。
4.2 中文输入法首字丢失的问题
用搜狗输入法在Canvas上的Univer单元格里输入中文,偶尔第一个拼音的字母会直接进到单元格里,导致本来要输入“销售”,结果变成“xiaoshou”。这个问题的根源是Canvas层对键盘事件的监听时序和输入法composition事件的竞争。
新版本的Univer对中文输入的支持已经做了很大改善。如果你用的版本还有这个bug,可以考虑把输入框从Canvas层迁移到独立的DOM覆盖层。具体做法是:监听单元格进入编辑状态,在对应位置创建一个绝对定位的input盒子,输入完成后把值写回Model层。我们当时用这个方案做了一版临时修复,后面升级版本后原问题消失,覆盖层代码就被删掉了。
4.3 打开大文件时滚动掉帧
一个包含5万行数据的.xlsx转成Univer数据结构后,渲染虽然快,但滚动过程中偶尔还是能看到网格线闪烁。排查发现是页面同时加载图表库、地图组件,GPU资源吃紧,Canvas的绘制被浏览器降级成软件渲染。
解决的几个组合拳按顺序做:给Canvas元素加will-change: transform属性,让浏览器知道这块需要GPU加速;关闭页面中不必要的全屏模糊等CSS滤镜;最后把Univer所在容器设置成独立渲染层。三招下去,掉帧问题基本不再出现。如果还卡,就要考虑用Univer的增量渲染机制只渲染当前视口。
4.4 在线文件导入导出格式兼容性
Univer本身是个编辑器,但很多业务场景要求“用户上传一个Excel,在网页里打开编辑,再下载回Excel”。这里要区分清楚:上传解析和下载生成,严格来说不是Univer核心包做的最擅长的事情,它依赖文件转换相关的扩展库。
我们第一版上线时直接让用户传.xlsx,结果带复杂图表/数据透视表的文件转换效果很不理想。后来调整了产品逻辑:服务端先做一次文件解析与清洗,把复杂样式剥离成Univer支持的常规单元格模型,再推给前端渲染。前端再拿到标准JSON渲染。这一调整后,兼容性问题从“用户经常反馈打不开”降到了“偶尔复杂文件需要人工处理”。
| 问题 | 排查思路 | 解决手段 |
|---|---|---|
| 单元格点击偏移 | 检查外层是否有transform缩放 | 延迟挂载或坐标修正 |
| 中文输入吞字 | 确认input事件与composition事件的时序 | 独立DOM覆盖层或升级版本 |
| 大文件滚动卡顿 | 按F12看渲染性能,确认是否GPU加速 | 加will-change、关闭滤镜 |
| xlsx样式丢失 | 确认是否走了解析清洗层 | 服务端清洗后再推送 |
| 协同服务掉线 | 检查WebSocket心跳与token过期 | 重连机制+鉴权刷新 |
| 自定义工具栏按钮无响应 | 检查是否正确注册UIPlugin按钮 | 用官方工具栏配置项 |
4.5 嵌入到业务系统时的z-index层级问题
Univer的右键菜单、下拉选框都是浮层节点,默认z-index有时会低于我们自己的顶部导航。现象是:在表格区域点右键,菜单有一部分被导航栏遮住。处理方式很简单,给Univer容器设置一个独立的stacking context,比如position: relative + z-index: 10,并确保Univer内部的浮层默认值比导航低。如果不想改内部样式,就在初始化主题里自定义浮动层高度。这个问题不难,但第一次遇到时很容易怀疑是渲染Bug。
5. 二次开发:从框架用户到框架贡献者的路线
5.1 自定义函数:让公式引擎听懂业务语言
Univer的公式引擎支持注册自定义函数,这是我觉得它比普通表格库强一个档次的地方。
我举个例子。销售报表里要求计算“季度达成率”,业务口径是“实际销售额 / 目标销售额”,但目标销售额可能在下半年的某个月调整过两次,这个调整记录存在另一张业务表里。标准Excel函数做不到直接查业务表,所以我在Univer里注册了一个名为SALES_RATE的自定义函数,接收区域编号和月份,内部通过异步请求业务接口取数,返回达成率。
这样用户在表格里直接输入=SALES_RATE("华东", "2024-06"),得到的就是业务系统算出来的数字,而不是前端手动算完再填进去的静态值。数据的单一事实来源保持在后端,表格成了活的展示层。这个能力如果不用Univer而用其他DOM表格库,实现成本要高好几倍。
5.2 权限联动:只读模式与单元格锁定
很多后台系统需要不同角色看到不同的表格区域。我们做了这样一个联动:管理员登录时,整张表可编辑;普通销售专员登录时,只有“实际销售额”一列可以填写,“目标销售额”和“完成率”是锁定的。
实现上并不复杂。初始化Univer之前,后端在返回数据的接口里带一份权限矩阵,例如哪些列锁定。前端在Workbook配置里对sheet数据做一次遍历,把锁定列的cell属性设置上保护标记。同时用命令拦截器在命令执行前判断:如果操作范围涉及受保护单元格,直接吞掉这条命令,不允许执行。这样就算用户通过控制台手动调API,也改不了受保护区域。
5.3 把Univer嵌入React应用的生命周期管理
如果是React项目,建议在useEffect里做初始化和销毁,避免严格模式下的重复挂载:
import { useEffect, useRef } from 'react'; function SalesTable() { const containerRef = useRef<HTMLDivElement>(null); useEffect(() => { if (!containerRef.current) return; const univer = createUniverInstance(containerRef.current); return () => { univer.dispose(); }; }, []); return <div ref={containerRef} style={{ width: '100%', height: 600 }} />; }特别注意:在React 18的StrictMode下,useEffect会执行两次再清理一次。如果Univer实例没有正确销毁,第二次初始化时会在同一个容器里重复渲染。我们的统一做法是在初始化前先containerRef.current.innerHTML = '',再创建新的实例。印象里这是React接入Univer最容易踩的坑,没有之一。
5.4 引入协同编辑前的架构预演
Univer的协同是基于操作转换的,服务器会维护一份操作序列,客户端把本地命令序列化后发到服务端,服务端合并后广播给其他在线端。
我们虽然没有在一期上线协同,但在架构评估阶段做了预演:本地业务逻辑全部走Command,手动输入和批量导入都封装成命令,这样后续接协同服务时不需要改动现有交互代码。这个“先统一命令,再接入协同”的思路,后来被证明是非常关键的前置工作。如果在前期代码里直接通过API改数据、不走命令,协同那一步就会被卡住,因为服务端无法理解你的修改意图。
6. 我的个人选择与长期建议
说点不太会写在官方文档里的判断。Univer这个项目看起来是Office套件,本质上是一套“可编程的数据网格基础设施”。它的价值上限,取决于你把它当成一个“表格控件”还是一个“数据协作底座”。
如果你只想要一个普通表格,用它确实有点杀鸡用牛刀;但如果你的系统里有复杂的业务表格、公式计算、权限控制乃至协同需求,从Univer开始打底,远比自己从零渲染Canvas或者给另一个商业库付费要划算。我们项目上线至今,最满意的一点是它没有出现过那种“用着用着突然卡死,不知道问题出在哪”的黑盒状态。因为架构模块清晰,遇到任何定位不了的问题都可以追到对应渲染层去断点调试,这在商业黑盒组件上是做不到的。
有一点实际经验想送给准备接手的同学:一开始不要追求大而全的配置,先把一条“后端数据 -> 表格展示 -> 用户编辑 -> 保存后端”的最小链路跑通,之后再逐步加上公式、样式、权限、协同。这个顺序看起来慢,但每一步都稳固。我们就是先跑通了最小链路,后续加权限和自定义函数时才没有陷入“全世界都是bug”的泥潭。Univer值得投入,但要用工程化的方式去接,而不是拿它当Excel的Web版平替。