拓冰建站拓冰建站
首页 / 资讯中心 / 正文

在线填报功能如何实现?Univer 开源表格接入实战与避坑指南

做后台管理系统时产品经理要求在网页里实现“在线填报”功能说白了就是嵌入一个像 Excel 的电子表格。这个需求听起来小实际一拆就发现牵涉到渲染、编辑、公式、撤销重做、导入导出好几个大的技术模块。调研一圈后我锁定了四个候选Luckysheet、Handsontable、Grist、Univer。其中 Univer 是让我最意外的——它不只是一个表格组件而是一整套以 Web 表格为核心的开发框架。这篇博文就围绕我接入 Univer 的完整经历展开包括选型理由、最小跑通步骤、架构理解以及五六个让我印象深刻的坑。先说清楚一件事本文讨论的是 GitHub 上 dream-num/univer 这个开源项目Univer 在国外也有一所同名大学但两者的技术栈毫无关系。我当时搜资料时就被混淆过一次差点翻到完全无关的文档这里先帮你排掉这个雷。1. 选型现场为什么“网页里要一个 Excel”会让人纠结1.1 需求方真正想要的东西“在线填报”这四个字在不同人嘴里含义差别巨大。我接触过的需求方第一版只说“能看就行”第二版变成了“要能改几个列”第三版冒出了“合并单元格、冻结表头、下拉选择”第四版直接要求“能导入 xlsx能导出 xlsx”第五版甚至有人在问多人同时编辑。如果一开始就奔着最简单方案去做后面每一次需求升级都相当于重写。所以我做选型时只问一个问题这个表格未来会演变成什么形态对照这个标准来筛选纯展示型方案比如直接把 Excel 转成 HTML 表格首先出局它完全没有编辑能力接着出局的是那些只能在某个框架里用的私有表格组件一旦项目换框架就得重来。真正值得对比的是几个开源“能编辑的 Web 表格”方案。1.2 我对比过的几个开源方案我花了一天半时间把 Luckysheet、Handsontable、Grist 和 Univer 都各自跑了一遍最小 Demo横向对比下来大致是这样的指标LuckysheetHandsontableGristUniver界面风格接近 Excel但整体偏旧专业表格控件样式干净更像 Airtable 的数据管理应用接近 Google Sheets 的现代感技术栈早期依赖 jQuery 生态自研引擎提供框架封装整体应用二次开发成本高TypeScript 编写框架无关公式引擎有但复杂函数支持一般支持常用公式有偏数据管理场景覆盖大多数常用 Excel 函数xlsx 导入导出依赖额外库兼容性一般官方提供商业插件导入受限需要处理官方提供导入导出插件社区维护状态较活跃但前几年有停滞期新版本已转商业授权专注自家产品迭代频率很高补充一句Handsontable 我了解到的信息是早期版本是 MIT 协议后面的版本改成了商业授权。如果你要放在商业项目里许可这一条会直接卡死选型这也是我当时没有继续深入尝试它的重要原因。1.3 Univer 凭什么站到最后Univer 最打动我的不是某个单一功能而是它的整体定位——它不把自己定位成一个“表格控件”而是做“Web 端 Office 基础设施”。项目仓库里同时有 Spreadsheet、Document、Slide 三套文档类型的规划底层共用一套核心数据结构和插件体系。这意味着什么意味着如果今天我用它做表格明天想在同一个页面里加一份富文本说明文档或者加一页演示 PPT不需要再另找一套方案。虽然我手头项目确实只需要表格但这种“向上兼容”的结构让我觉得后面的演进空间是敞开的。再加上它的技术设计TypeScript 重写、IoC 依赖注入、命令模式、Canvas 渲染引擎、插件按需加载每一条都戳中我对“可维护项目”的偏好。于是我没有太多犹豫选择了 Univer。2. 快速接入把一个可编辑表格跑进你的 Web 项目2.1 一条命令装完核心依赖我用的版本是 Univer 1.0.x 系列。先安装核心包和 UI 包npm i univerjs/core univerjs/sheets univerjs/sheets-ui univerjs/ui univerjs/design如果你后面要做公式再把公式引擎包一起装上npm i univerjs/sheets-formula univerjs/engine-formula这里有一个很实际的提醒Univer 的包名和 API 在不同小版本之间都可能变。我最初照着一段旧教程写结果发现createUniverSheet的写法在新版本里变成了createUnit。如果你卡在某一步编译报错先去看 npm 页面上实际的版本导出不要盲目相信网上教程包括这篇文章里的代码——我写的是我跑通的版本但你的版本号不同API 就可能对不上。2.2 最小初始化代码跑通一个可编辑表格核心代码其实很短import { Univer, FUniver, UniverInstanceType, LocaleType } from univerjs/core; import { UniverSheetsPlugin } from univerjs/sheets; import { UniverSheetsUIPlugin } from univerjs/sheets-ui; import { UniverUIPlugin } from univerjs/ui; import univerjs/design/lib/index.css; import univerjs/ui/lib/index.css; import univerjs/sheets-ui/lib/index.css; const univer new Univer({ locale: LocaleType.ZH_CN, }); univer.addPlugin(UniverSheetsPlugin); univer.addPlugin(UniverUIPlugin); univer.addPlugin(UniverSheetsUIPlugin); // 对外 API类似 Excel.Application 的概念 const api new FUniver(univer); // 创建一个工作簿 const workbook api.createUniverSheet({ id: my-workbook, name: 示例工作簿, sheets: [ { id: sheet-1, name: Sheet1, cellData: { 0: { 0: { v: Hello }, 1: { v: Univer }, }, }, }, ], });这段代码跑起来页面里会出现一个带工具栏、公式栏、单元格网络的电子表格双击单元格可以编辑输入内容后回车保存。能做到这一步基础链路就通了。2.3 在 React 组件里接入的正确方式我项目的前端是 React 18。直接在组件里初始化 Univer 时最容易踩的坑是React 开发模式下useEffect会被执行两次导致 Univer 实例被创建两次控制台会报重复实例的警告界面也可能出现两个表格叠在一起。我当时用 ref 保存实例并在清理函数里调用 disposeimport { useEffect, useRef } from react; export function SheetContainer() { const containerRef useRefHTMLDivElement(null); useEffect(() { if (!containerRef.current) return; const univer new Univer({ locale: LocaleType.ZH_CN }); univer.addPlugin(UniverSheetsPlugin); univer.addPlugin(UniverUIPlugin); univer.addPlugin(UniverSheetsUIPlugin); const api new FUniver(univer); api.createUniverSheet({ sheets: [{ id: sheet-1, name: Sheet1 }] }); return () { univer.dispose(); }; }, []); return div ref{containerRef} style{{ width: 100%, height: 600 }} /; }Vue 里的写法也类似在onBeforeUnmount里调用univer.dispose()。千万别把 Univer 实例挂到全局变量或者 Vuex 里长期持有除非你有明确的跨组件共享需求否则内存管理会很别扭。2.4 判断“真的能编辑”的标准很多新手以为界面里出现一个表格就是接入成功了其实那只是“能看”。我建议按下面四条标准自测都通过才算真正把编辑链路跑通双击单元格会出现单元格编辑态能输入文字或数字。在任意单元格输入SUM(A1:B2)回车后能计算出结果。右键单元格能弹出上下文菜单菜单里至少有剪切、复制、粘贴等基础项。工具栏上的加粗、斜体、背景色按钮能改变选中单元格的样式。当初我第一条就卡了很久界面有表格但双击一点反应都没有。后来排查发现是初始化配置里只注册了UniverSheetsPlugin没有注册UniverSheetsUIPlugin编辑交互那一层压根没有被挂载。所以这四条自测标准很有必要能帮你快速定位问题出在哪个插件层。3. 理解 Univer 的底层逻辑插件、命令、单元格与渲染3.1 IoC 容器和插件体系如果只看表面 APIUniver 就是一个普通的表格库但真正要在里面做扩展开发一定要理解它的 IoC控制反转容器设计。Univer 的核心包里内置了一个依赖注入容器所有服务、插件、控制器都由这个容器统一管理和装配。你可以把整个 Univer 想象成一条生产流水线核心容器是传送带插件是安装在传送带上的工位。每个工位各自做好一件事互不干扰需要配合时就通过容器取出对方暴露的服务。写一个自定义插件的基本套路是import { Plugin, Injector, CommandService } from univerjs/core; class MyPlugin extends Plugin { static override type my-plugin; override onStarting(injector: Injector): void { // 插件启动前可以在容器里注册自己的服务 } override onReady(injector: Injector): void { const commandService injector.get(CommandService); // 监听命令或注册新命令 } }这个机制的优点很明显官方功能和你自己的扩展代码不会纠缠在一起。比如我想在工具栏加一个“一键填充序号”按钮只需要写一个独立插件注册一个新命令不需要去改 Univer 源码。3.2 一切操作都是命令撤销和重做为什么这么好用Univer 内部把用户的每一次操作都建模为命令。输入一个字符是一条命令合并单元格是一条命令粘贴数据也是一条命令。命令模式带来的直接好处是撤销和重做变得非常自然Univer 维护一个命令历史栈每条命令都携带执行和撤销两个方向的逻辑。用户点撤销时栈顶命令执行它的 reverse 逻辑点重做时再从另一个栈取出来重新执行。我在业务里用到过一次这个机制产品经理要求“保存前允许用户批量撤回半小时内的操作”我本来打算自己记录操作日志后来发现可以直接监听 Univer 的CommandService分发事件把命令序列记录到后端再封装一个“回放”接口。这个设计让我的开发量少了一大半。3.3 单元格数据模型cellData 长什么样子Univer 的工作簿由一个或多个 Sheet 组成每个 Sheet 的单元格数据用cellData描述。它的结构是一个嵌套对象外层 key 是行号内层 key 是列号都用字符串表示const cellData { 0: { 0: { v: 项目名称 }, 1: { v: 预算 }, }, 1: { 0: { v: 官网改版 }, 1: { v: 120000, t: 2 }, }, };v是单元格的实际值t是数据类型标识。如果这个单元格有样式会通过s字段引用样式表里的一个 id而不是直接把样式对象塞进单元格里。我第一次看这个设计时觉得绕后来想明白了——一个单元格样式往往会被几十个单元格复用比如整列都是同一字体如果每个单元格都内嵌一份完整 CSS 对象内存占用会爆炸。用 id 引用样式表是一种经典的资源压缩策略尤其适合大表格场景。3.4 Canvas 渲染引擎虚拟滚动与按需绘制Univer 的表格界面不是用 DOM 网格渲染的而是用 Canvas 绘制。底层做事件命中检测和动画管理的部分用了 Konva 这个 2D 图形库。Canvas 渲染最大的好处是性能。浏览器对 DOM 节点的数量非常敏感一个 100 行 20 列的表格如果用 DOM 网格渲染就是 2000 个节点滚动时浏览器要反复布局和重绘。Univer 只绘制当前视口内能看见的单元格横向和纵向都做虚拟滚动——你滚动到哪一屏它就绘制哪一屏。所以表格行数很多时滚动依然流畅。这也带来了一个重要的开发习惯不要在单元格里塞 HTMLElement。很多人习惯用 DOM 做富交互但在 Univer 里普通单元格的“内容”最终都会被 Canvas 绘制你硬塞一个 HTML 元素进去它不会出现在正确的位置。正确的做法是用 Univer 的自定义渲染器 API或者通过覆盖层Overlay元素自己控制位置。这一点后面踩坑部分我会再详细说。4. 业务场景实战填数据、导入导出、做成录入界面4.1 从后端 API 批量填充数据我接的第一个真实需求是把后端返回的报表数据填充到表格里。后端返回的是一个 JSON 数组我把它转换成一个二维数组然后通过 FUniver 的 API 写入const rows data.map((item: any) [ item.projectName, item.budget, item.status 1 ? 进行中 : 已完成, item.updateTime, ]); const sheet api.getActiveSheet(); sheet.getRange(0, 0, rows.length, rows[0].length).setValues(rows);有几个细节需要注意如果某一行数据缺失不要留空最好补一个null或空字符串避免二维数组形状参差不齐Univer 会认为你某一行只有 3 列导致后面的列错位。数字字段要确保是 number 类型不要用字符串“12000”否则后面做 SUM 计算时会得到 0。因为 Univer 会把字符串当文本处理不参与数值计算。大数据量下不要一次性 set 十万行建议分页或者分批写入具体策略我在第 5 部分会展开。4.2 xlsx 导入导出的正确姿势Univer 官方提供了导入导出插件包名是univerjs/sheets-import和univerjs/sheets-export。装上之后工具栏会自动出现“打开”和“下载”按钮吗我实测下来通常要自己接 UI 事件插件底层提供的是读写能力不是替你做好按钮。导入的基本思路是用户选择 .xlsx 文件后用 file input 读取为 ArrayBuffer然后调用导入插件解析把工作簿数据塞进一个新的 Univer Sheetconst file fileInput.files[0]; const buffer await file.arrayBuffer(); const workbookData await importPlugin.parse(buffer); api.createUniverSheet(workbookData);导出类似拿到当前工作簿的数据交给导出插件生成 ArrayBuffer再触发浏览器下载。关于兼容性我测试过几种文件常规的单元格内容、背景色、边框、合并单元格、列宽行高这些都能保存下来但如果是数据透视表、宏、复杂的图表对象导出后大概率会丢失或变成图片。如果你的业务里有大量这类高级 Excel 特性建议在需求阶段就跟产品经理对齐边界别等交付了才说“这个我们支持不了”。4.3 用 Univer 搭一个简易数据录入界面第二个真实需求是做一个“月度预算填报”页面需要用户在线填写填完点保存。我在 Univer 上叠加了三个能力第一冻结首行和首列。初始化工作簿时在 sheet 配置里指定 freeze 行列位置用户滚动数据时表头始终可见体验接近 Excel。第二给状态列加下拉选项。Univer 支持数据验证插件可以把某一列改成下拉选择。我在配置里给单元格加了dataValidation规则用户点击单元格就能看到下拉列表不用手打文字减少了填报错误。第三监听变更保存。我订阅了 Univer 的命令分发事件凡是单元格内容变化的命令类型出现就把当前 sheet 的 cellData 序列化后发送给后端自动保存。用户不用点“保存”按钮就实现了类似 Google Sheets 的自动保存体验。这个方案比我预想的顺利主要功劳在命令模式——我不需要去 diff 每一个单元格只需要在命令层面判断“本次操作是否涉及单元格内容变化”再读取整个工作簿数据做覆盖保存。对于单用户填报场景这种粗暴保存足够了。4.4 多人协同的前置准备很多团队看到 Univer第一反应是“那是不是可以多人同时编辑了”这里我得泼一盆冷水Univer 本身是编辑器协同能力需要你自己实现或者使用官方云服务。Univer 官方推了一套协同服务但如果是自部署场景你需要考虑多人同时编辑同一个单元格时冲突怎么处理常见技术路线有两种OT操作转换和 CRDT无冲突复制数据类型。无论哪种都需要有服务端参与负责广播操作、记录历史、解决冲突。我当时项目的实时协作不是核心需求所以我采用了一个折中方案WebSocket 连接 简单房间锁。用户进入页面时锁定整张表其他用户只能查看编辑者保存后广播最新数据。这样虽然没有“多人同时编辑”的爽感但避免了冲突处理的复杂度对内部填报场景完全够用。等团队真正需要细粒度协同支持时再引入成熟的协同后端这个演进路径我觉得比较稳妥。5. 我在接入过程中踩过的几个坑5.1 样式引入顺序不对导致整页白屏我第一次跑 Univer 时页面一片空白控制台没有报错DOM 里也没有表格。排查到最后发现是 CSS 的问题Univer 的样式依赖顺序是univerjs/design的基础样式在先然后是univerjs/ui的布局样式最后才是univerjs/sheets-ui的表格样式。一旦顺序乱了或者某个包的 CSS 没有引入界面可能连最低限度的布局都渲染不出来。我的建议是在初始化组件的第一时间就把三行 CSS import 写上先跑通再考虑按需精简。另外要注意 CSS 的打包顺序如果你用的是 Vite在 main.ts 里 import 的顺序一般会被保留但如果你手动抽取公共 chunk可能会被重新排序这会导致样式错乱。5.2 日期序列号时区偏移从 xlsx 导入 Excel 文件时如果里面有日期类型我遇到了一个经典问题日期整体偏移了一天。原因在于 Excel 内部把日期存储为自 1900 年起的序列号Univer 解析时需要把它转成 JavaScript 的 Date。这个转换过程涉及 UTC 时区如果处理时没有校准本地时区1970 年之前的日期和某些跨时区的日期就会出现 ±1 天的偏差。这个问题我在网上搜到不少讨论最终解决方法是解析日期序列号时手动构造new Date(1899, 11, 30 serialNumber)这样从本地时区构造日期而不是直接new Date(serialNumber * 86400000)。不过这个修正只针对我遇到的场景不同 Univer 版本内部实现可能已经修复建议你在自己的测试文件里先放几个日期用例导入后逐一验证。5.3 React StrictMode 导致实例重复创建开发环境我用的是 React 18 的StrictMode它会在开发模式下自动执行两次useEffect挂载、卸载、再挂载。我最初没注意结果页面上出现了两个 Univer 实例控制台报了一堆“重复初始化”的警告。解决方法是必须在 useEffect 的清理函数里调用univer.dispose()并且把 Univer 实例的生命周期严格绑定到组件上。如果遇到二次挂载先 dispose 再重新创建。千万不要图省事在模块顶部创建全局单例因为组件卸载后表格 DOM 已经不存在了单例对象却还持有一堆事件监听内存泄漏会很隐蔽。5.4 十万行数据一次性注入卡顿我接手过一份十万行左右的报表数据直接一次性setValues后页面卡了好几秒滚动也不跟手。当时我以为 Univer 性能有问题后来分析发现是数据从 JSON 到 cellData 的转换和内存占用造成的。一万行二十列就是二十万个单元格对象。如果每个对象都带样式、类型等字段内存增长会很夸张。我的缓解策略分三步先只填充v和t字段不设样式。数据分批写入比如每批五千行用requestAnimationFrame隔开。如果只是展示不要求编辑只读模式下数据量上限可以放宽很多。5.5 自定义状态标签别在 Canvas 单元格里塞 HTMLElement这是我自己犯过的一个错误。当时想在“状态”列里展示一个带颜色的小圆点和文字我第一反应是给单元格塞一个 HTML 元素。结果刷新后小圆点要么消失要么位置错乱。原因前面提过Univer 的单元格内容由 Canvas 统一绘制不是 DOM 渲染。要自定义单元格视觉表现得走 Univer 的单元格渲染扩展机制在渲染器里自己绘制图形。例如你可以在渲染回调里拿到单元格的坐标和尺寸然后调用 Canvas 的 API 画一个圆点、画一段文字。相对于改 DOM这套机制的调试难度高一些需要习惯“绘制坐标”的思维方式。但理解之后收益也很大因为你可以画出任何你想要的内容不受 HTML CSS 能力限制。最后分享一个小技巧如果你在接入时遇到怪异问题先别急着翻文档去 Univer 官方的 Playground 把官方示例跑一遍确定你的版本能出正常效果然后再逐个模块往自己的项目里搬。这种“最小环境比对法”能帮你快速区分是环境问题还是 API 用法问题。Univer 的版本迭代很快网上很多旧教程已经过时最可靠的信息源永远是官方 Playground 和 npm 包的类型定义。我个人体验下来Univer 算是我今年用过“下限很高、上限也很高”的开源项目接入成本不算高跑起来之后又能支撑不少复杂玩法。如果你正在做在线表格相关的功能值得给它一周时间试错。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门