Univer实战:构建模板化填报表单的单元格锁定与数据回收
发布时间:2026/10/1 13:43:33来源:尧图网络
前一阵子接了一个内部管理系统的小项目业务方提的需求特别典型我们要一个表格可以自己设计表头、设置格式然后发给各个部门的人填数据。填的人只能改他们该填的格不能动其他区域最后所有数据能收回来汇总。听完这个需求我脑子里第一个冒出来的方案就是Univer。这不是我第一次拿Univer干这种活了但每次用它做模板化填报表单这类功能都会踩到几个重复的坑这次顺手把完整的思路和实现过程记录下来也给正在做类似在线表格需求的人一个参考。1. 为什么我把Univer当成首选方案先说说Univer是什么吧。它是一个开源的全功能在线表格/文档/幻灯片解决方案核心引擎用TypeScript编写UI层基于Canvas自研渲染引擎不是普通DOM堆出来的表格。这意味着什么意味着它的渲染性能和操作体验很接近原生Excel而不是那种网页版简化表格。很多人一听说在线表格就先想到网易表格、腾讯文档、散落在各家的Web Excel但那些是SaaS数据出不来流程也不好嵌入自己的系统。Univer不一样它是可以完全私有化部署、可以深度嵌入业务系统的开源引擎。目前Univer在GitHub上的star涨得特别快社区活跃度也在上升。它背后有个比较大的团队在维护不是那种一个人写两星期就丢上去的半成品。这一点对实际商用很重要因为你要在一个项目里用某个第三方组件最怕的就是作者弃坑、Bug没人修、文档断更。Univer目前的迭代节奏和文档完整度在同级别的开源表格引擎里算是很能打的。再说架构方面Univer最大的特点就是插件化。核心引擎只负责文档模型、公式计算、渲染这些底层能力具体功能比如筛选、排序、数据校验、协同编辑都拆成独立插件按需加载。这对业务集成是非常友好的设计你不需要一个什么功能都带、体积巨大、权限难控的全家桶而是可以按业务场景裁剪。比如只做填报表单就把工具栏、菜单、右键操作精简掉一半UI干净很多用户干扰也少。做个横向对比可能更直观方案渲染方式开源协议编辑能力二次开发难度典型使用场景UniverCanvasMIT/Apache需确认具体模块完整电子表格中等偏高深度嵌入业务系统的在线表格LuckysheetCanvas DOM部分开源较强中等企业报表展示与编辑HandsontableDOM商业授权为主强主打数据网格较低后台管理的嵌入式数据编辑xlsx/spreadjsDOM/Canvas商业授权各有侧重中等纯数据处理、复杂Excel兼容从我实际用下来的感受来说Univer在用代码控制表格干活这件事上API设计比很多同类产品顺手提供了一整套操作工作簿、工作表、单元格、样式、数据校验的程序化接口。这不光是给最终用户用的表格软件也是给开发者当表格控件用的底层引擎。2. 先拆需求让用户填指定格子到底难在哪很多刚接触这类需求的人会觉得不就是做个表格让别人填吗有什么复杂的真正上手就发现这个需求的难点根本不在填这个动作而在限制两个字。业务方通常的表达是支持用户定义表格然后让用户去填写一些单元格其他的单元格用户无法修改。听起来简单拆开来看至少有三层问题要解决。第一层是表格结构谁来定义。如果每个部门交上来的表格五花八门表头都不一样最后汇总就没法做。所以必须有人或者有一套机制先把表格模板固定下来这个模板里包含表头、字段、格式、参数这些元信息。在很多系统里这个模板定义角色往往是管理员或者系统初始化脚本。第二层是谁能填什么。同一个表格不同的用户看到的是不同的可编辑区域。比如预算表里财务部门填实际发生额业务部门填预估金额合计列只能系统算出来任何人不能手改。如果把整个表格开放给所有用户一定会出现误删公式、改表头、覆盖别人数据等等事故。要命的不是一次两次的手误而是你根本没办法控制。第三层是数据怎么收回来。用户填完不是完事了你还要把分散在各种单元格里的数据拿出来做校验、做入库、做汇总、做审批流程对接。如果只是在Excel里让用户填完再通过邮件发回来那又是一坨手动整理的存量工作完全抵消了在线填的效率优势。Univer能比较优雅地解决这几层问题核心在于它把表格展示和权限限制两个能力都做成了可编程的模块。你可以用代码生成一张完全锁定的表再精确解锁指定区域给指定用户你也可以用代码监听单元格变更用户填完一个格系统马上可以对内容做格式校验、自动联动、数据回写。这比先做一个Excel模板发给用户收回来再手工合并的处理方式高出一个维度。所以后面几节我按实际项目落地顺序来讲先定义表格模板再做锁定与权限控制再讲数据回收和联动校验最后聊一聊集成部署时踩过的坑。这套路线基本覆盖了所有填报表单类在线表格需求的核心链路。3. 用Univer搭表格模板一套初始化配置打天下3.1 最基础的Univer实例怎么搭先看一眼最简的搭建流程。Univer现在发布了一套面向开发者的初始化方式创建一个工作簿指定Sheet然后往里塞数据注册必要的插件。骨架大概是这样的。import { Univer, UniverInstanceType } from univerjs/core; import { UniverSheetsPlugin } from univerjs/sheets; import { UniverSheetsCorePlugin } from univerjs/sheets-core; import { UniverRenderEnginePlugin } from univerjs/engine-render; import { UniverSheetsUI } from univerjs/sheets-ui; // 在React/Vue里一般把实例挂到ref上 const univer new Univer({ // 初始化工作簿数据 snapshot: { id: workbook-001, sheetOrder: [sheet-01], sheets: { sheet-01: { id: sheet-01, name: 预算填报, rowCount: 50, columnCount: 12, // 可以在这里预设单元格数据、样式、合并、行高列宽 }, }, }, }); // 注册核心插件渲染引擎、电子表格核心、UI交互 univer.addPlugin(UniverRenderEnginePlugin); univer.addPlugin(UniverSheetsCorePlugin); univer.addPlugin(UniverSheetsPlugin); univer.addPlugin(UniverSheetsUI);注意这里的snapshot参数特别重要它就是整个工作簿的JSON快照。你在界面上做的每一次编辑、样式调整、合并单元格、冻结窗格本质上都在修改这个内部数据模型。Univer的序列化和反序列化能力不错所以模板完全可以保存成一份JSON下次打开直接喂给snapshot就能完整还原来表格状态。这个思路意味着你可以把几十个不同的表单模板存进数据库用户点哪个就加载哪个完全不用为每个模板写死代码。3.2 模板设计阶段应该定哪些东西在实际做预算填报模板的时候我通常会提前把下面这些东西想清楚然后全部落到初始化配置里表头行哪些列是主维度比如部门、月份、科目哪些列是填写列比如预估金额、实际金额。合并单元格多级表头比如上半年度下面再拆Q1、Q2通常需要跨行跨列合并。行高列宽统一设好防止用户看到的是错乱默认布局。冻结窗格行数多的时候必须冻结前几行否则用户往下滚的时候根本不知道当前填的是哪一列。默认样式把只读区域和填写区域用底色区分开。这个看起来小实际上能显著降低填表人的理解成本。比如把锁定区域背景设为浅灰把可编辑区域留白。给个具体例子比如要做一张最简单的部门月报模板部门负责人本月目标实际完成备注研发部张三100可填可填市场部李四80可填可填在这里面部门负责人本月目标都是模板数据任何人不能改实际完成和备注是填写区域。Univer那边可以用getRange拿到指定区域后批量设置值再统一设置锁定样式代码上不复杂。const workbook univer.getUniverInstance(UniverInstanceType.UNIVER_SHEET); const worksheet workbook?.getSheetByIndex(0); // 设置表头区域数据 worksheet?.getRange(0, 0, 1, 5).setValues([ [部门, 负责人, 本月目标, 实际完成, 备注] ]); // 设置模板固定数据 worksheet?.getRange(1, 0, 3, 3).setValues([ [研发部, 张三, 100], [市场部, 李四, 80], [销售部, 王五, 120], ]); // 设置区域样式底色、边框、字体 worksheet?.getRange(0, 0, 4, 5).setStyle({ backgroundColor: #F5F5F5, border: { b: { s: { style: thin, color: #ccc } }, r: { s: { style: thin, color: #ccc } }, }, });很多不熟悉Univer的人会纠结一个问题到底怎么把模板样式做漂亮我觉得这里有个判断标准——你的漂亮是给谁看的。如果给填表人看重点不是花哨而是一眼看到底哪些格子要填。所以底色、边框、必填标记比如红色星号比字体艺术、主题色重要得多。我一般还会在表头外加一条批注灰色区域无需填写白色区域请如实填写通过单元格备注或者顶部提示条展示。这个细节对实际使用体验的提升非常明显。3.3 模板数据的保存与复用模板做完了怎么存最简单粗暴的办法是用Univer的对象内置方法把快照转成JSON字符串存数据库。核心注意点是存档时要保留不同版本。为什么因为你可能要面向不同的填报周期生成不同模板比如3月模板和4月模板字段可能相同但目标值会根据上月复盘调整。把模板按版本存储后续追溯和修改都方便。除此之外模板里经常还隐藏着一种特殊数据——公式单元格和必填项元数据。Univer支持完整的公式能力模板里可以在合计列预埋公式比如合计行写SUM(D2:D5)。这个很重要因为公式单元格通常属于不能让人随便改的区域否则表就废了。同时公式也让汇总数据这件事自动化你不用等用户填完再写程序算合计引擎自己就算好了。4. 单元格保护与填写权限把只能改白格子做成硬约束4.1 先理解Univer的锁定-保护机制这是整个需求里最关键的一环。Univer的权限控制思路和Excel很像默认所有单元格都是locked状态但锁定本身不起作用你需要先对Sheet开启保护锁定才会生效开启保护后可选的允许操作里再单独放行指定单元格。这话有点绕我用Excel的类比说一下在Excel里你选中全部单元格设置锁定单元格格式然后保护工作表设置密码最后再单独把允许编辑区域的锁定取消勾选。Univer的这套逻辑基本一致好处是它是可程序化操作的。你要做的只是先让所有单元格处于锁定状态然后把允许填写的区域解锁。这个顺序不能搞反。搞反的话保护一开启就把只读区域也放开。伪代码思路// 1. 把所有单元格锁上 worksheet?.getRange(0, 0, rowCount, columnCount).setStyle({ locked: true }); // 2. 把允许填写区域解锁比如上面的D2:E4 worksheet?.getRange(1, 3, 3, 2).setStyle({ locked: false }); // 3. 开启Sheet保护 worksheet?.protect({ password: optional-password, // 可以配置允许的操作白名单 allows: [selectLockedCells, selectUnlockedCells], });注意protect方法的参数具体字段可能因Univer版本而异我这里写的是思路骨架。实际上Univer的Sheets插件里保护相关能力在不同版本演进中调整过几次。第一次使用的人得去翻当前版本的类型定义看WorkSheet保护的API签名。但核心逻辑跑不掉locked样式 保护开关 可选操作白名单。4.2 UI上让用户看到可编辑区域写代码锁格子很容易难的是让填表人不会被这也不能点那也不能点搞懵。我一般会在模板里叠加三层视觉引导第一层是区域底色。把锁定区域涂灰、可填区域留白这是最直观的。第二层是数据校验下拉。Univer的数据验证DataValidation支持下拉列表、数值范围、自定义公式等方式。比如部门这一列其实不需要用户填但如果有些非空的填写项想约束内容就做成下拉——部门只能从列表里选实际完成金额必须是大于0的数字。第三层是批注和提示在关键单元格旁边加单元格备注写明这里填实际销售额保留两位小数。数据校验这层对用户填了非法内容很有用。比如用户把文本填进了数字列Univer可以弹提示阻止。这个功能一定要用起来因为你根本没法预判用户会填出什么东西来。不加校验的模板上线后你会在后台看到销售额填了个约2000左右、备注写了完整小作文之类的数据清洗的时候想骂人。数据校验API示例逻辑worksheet?.getRange(1, 3, 3, 2).setDataValidation({ type: decimal, operator: greaterThanOrEqualTo, formula1: 0, showErrorMessage: true, errorMessage: 实际完成金额必须大于等于0, allowBlank: false, });4.3 针对不同用户做不同区域的放行有些场景比全局统一格子更复杂同一个表格A角色填预估部分B角色填实际部分。换句话说不同用户登录进来看到的可编辑区域不一样。Univer本身不负责登录和权限系统但你可以基于业务层实现。实现思路有两个。一个是在模板层面做**按角色生成不同快照**后端根据当前用户的身份动态返回一个已经锁好格的Univer快照A用户拿到的快照里可编辑区在预估金额列B用户拿到的快照里可编辑区在实际金额列。用户无感知代码也无非是锁定区域的参数由后端下发。另一个思路是在前端加载同一份快照再按当前用户角色调用一次解锁指定区域的程序化接口。两种方式我更推荐第一种因为它把权限逻辑收敛在后端避免前端因为历史操作残留导致解锁区域错乱。不过第二种也有使用场景如果你需要在同一张表里让多个角色分时协作比如同一个单元格先A填预估后B填实际那你不能把两个角色都放开否则A也能改B的域。这种时候一般需要后端存储单元格级别的编辑记录前端通过事件拦截非当前可编辑区域的写入。Univer的beforeChange类事件钩子能拦住单元格变动但具体怎么拦、要不要拦截、拦完之后要不要提示都取决于项目复杂程度。简单项目我建议直接不搞这种高难度玩法一张表一个角色、一个时间段一个角色实现成本会低很多。5. 填完之后怎么办数据回收、校验联动与导出5.1 监听编辑事件在填写过程中就介入模板锁定了用户也填了接下来要解决的是数据收回来。最不推荐的做法是等用户填完点保存你再去读整张表的数据。为什么因为用户可能没点保存就关了浏览器也可能写到一半出去开会回来刷新全丢了。所以更好的方案是在用户编辑过程中做实时监听和上报。Univer为开发者提供了单元格变更相关的订阅机制。粗略的用法类似于// 伪代码API以当前版本为准 worksheet?.getCommandService().onCommandExecuted((command) { if (command.id sheet.command.set-range-values) { // 取出本次变更的范围和值做增量保存 } });实际项目里我一般会在每个可编辑单元格上挂onChange式的回调或者统一监听命令服务再过滤出值变更类命令然后把变更的行、列、新值增量推给后端。这样做有几个好处用户改一格后端就存一格用户没点保存就刷新数据也不会丢太多汇总统计可以在后台实时算。需要特别提防的是编辑事件循环。如果你监听到变更后又用代码去写同一个单元格可能触发下一轮事件。Univer内部对命令循环有一定控制但你在业务层最好也要加一层是否是用户主动编辑的判断否则可能出现两个前端页面互相打架、数据反复覆盖的问题。5.2 从Univer里把数据安全地取出来如果用户一次性填完一批或者需要在提交时做最终数据采集那就用程序化读取。最简单的方式是遍历工作表的数据范围然后导出成JSON数组。const workbook univer.getUniverInstance(UniverInstanceType.UNIVER_SHEET); const worksheet workbook?.getSheetByIndex(0); const range worksheet?.getRange(0, 0, rowCount, columnCount); const values range?.getValues(); // 二维数组 // 转换成TableData const tableData values.map(row { // 业务处理过滤不可编辑列、格式化数字、去空格等 });这里有几个踩过的坑值得提醒读取范围别贪大。如果你创建一个了1000行×50列的空表格用户只填了前10行你最好先把最后有数据的行号计算出来只读有数据的范围不然每次导出都会带着大量空值接口载荷巨大。日期、金额的序列化。Univer单元格存的值可能是数字Excel日期序列值、字符串、布尔值、公式对象。你在底层拿到的值需要做类型映射否则从表格里读到小数日期传给后端就成45123这种用户看不懂的东西了。公式结果。如果你希望提交时拿到公式计算后的结果比如合计建议触发一次全表刷新或者读公式计算后的缓存值不要自己去解析公式字符串。5.3 导出Excel/CSV做线下备份即使你已经做了在线提交业务方大概率还会要求给我一个Excel导出。Univer在这块也提供了能力通过额外的导入导出插件可以把当前工作簿快照转换成xlsx文件或者调用浏览器下载。我的建议是线上数据永远以数据库存储为准Excel导出只是给业务方的交付物/备份不要拿Excel文件当数据源。因为用户在Univer里看到的表格可能包含样式、合并、公式、下拉转成Excel再导回来容易丢东西来回转换的边界Case很多。我没有太多时间折腾这些所以直接给业务方在线查看一键导出xlsx就够了数据汇总分析都用后端JSON去算不走Excel反过来再导入这条路。6. 真实项目集成中的几个坑与对策6.1 框架集成时实例生命周期必须管好拿React项目举例。Univer实例是重型对象如果你把它放进组件state里或者每次渲染都重新创建那页面基本不用干活了光初始化就要卡半天。我只在产品页面挂载时创建一次实例组件卸载时调用销毁方法并且通过useRef持有实例引用保证组件重渲染时不重置状态。const containerRef useRefHTMLDivElement | null(null); const univerRef useRefUniver | null(null); useEffect(() { if (containerRef.current) { univerRef.current new Univer({ /* ... */ }); // addPlugin等 } return () { // 组件卸载时销毁释放内存 univerRef.current?.dispose(); }; }, []);模板数据是异步拉取的我也要等在拿到模板JSON之后再创建实例不然空表先渲染出来用户会看到一闪而过的空白网格体验很差。可以在拉取模板期间放一个全局loading让表格一次成型。6.2 CSS覆盖与容器尺寸是薛定谔的BugUniver的UI基于Canvas渲染但外围的按钮、菜单、弹窗、下拉框还是DOM元素。如果你的系统里有全局CSS重置样式、换肤、暗黑模式很容易把Univer的工具栏、右键菜单、下拉列表样式搞乱。最常见的现象是表格内部正常顶部工具按钮字体变大、间距异常或者下拉列表出现在错误位置。解法也简单粗暴给Univer容器包一个独立命名空间在这个作用域里重置掉全局样式影响。言外之意就是让你的全局CSS别穿透进去。另外还有一个很有用的技巧表格容器必须显式设置宽高不能依赖内容撑开。Canvas渲染依赖容器尺寸容器尺寸为0时Univer可能变成一坨空白。用ResizeObserver监听容器尺寸变化动态调用表格刷新方法这个是必须做的。6.3 大数据量渲染的性能该怎么优化说到Univer的性能它已经比DOM网格方案好很多了但也不是无限强。纯展示型的10000行表格没问题但如果每个单元格都带复杂自定义样式、花哨边框、大量公式渲染和交互帧率还是会掉。我做过的有效优化有三条降低初始渲染的单元格复杂度模板不用的行列可以设置较小的行数/列数不要上来就建一个万级空白区域。懒加载业务数据大表格数据接口按需加载可视区域外先用占位值。关闭高开销功能拿不准的插件协同编辑、历史记录在非必要场景就不注册少一个插件少一份监听和渲染负担。6.4 版本升级带来的API断裂问题这个要单独说。Univer的版本演进速度比较快而且主版本之间API会有breaking change。你在网上搜资料经常会看到一个社区帖子用的还是老版API复制过来跑不了。我个人现在的做法是锁版本号不用latest升级前先看Changelog逐条核对用了哪些API升级后跑一遍自己的用例清单初始化、模板加载、锁定、数据读取、导出每个环节都过一遍。另外不要在项目里混用不同版本的Univer包。npm如果解析出多个univerjs/core副本会出现实例不在同一个包引用下导致API操作无效这类极其隐蔽的坑很难排查。用yarn/npm的resolutions字段强制统一版本号是值得花时间做的。6.5 用户习惯与移动端适配填表的用户里总有几个会用手机打开链接。Univer在移动端的体验没有桌面端那么完善尤其是锁定单元格后的滚动、缩放、下拉选择触屏交互会有些别扭。如果业务里确实有移动填报场景我的建议是专门做一版移动端表单页而不是硬套在线表格。道理很简单手机屏幕太小用户根本不想操作网格他们想要的是一个字段一个输入框的普通表单。后端接口可以共用前端就走表单渲染路线把Univer当桌面端主力。一开始就做好这个分工后面省很多事。7. 选型思考什么时候该用Univer什么时候还是用简单方案比较好最后聊聊选型。我见过不少团队在表格选型上摇摆不定。Univer强是强但也不是所有需求都应该上它。如果你的需求属于下面这种Univer是非常合适的表结构需要灵活变化表头、行列、样式都要随时调整用户要能像Excel一样编辑单元格有合并、拖动、填充、公式、下拉这些能力你需要深度控制单元格格式、保护、校验并且要和自己的业务系统打通权限数据最终要回收到后端做汇总、审批、流程管理。但如果你的需求其实只是展示一个报表给人看偶尔筛选一下那Univer属于杀鸡用牛刀投入产出比不划算。这种场景用老实的HTML表格、ECharts图表、甚至导出一个静态xlsx文件就够了。还有一类场景——重度复杂交互的企业级前端表格应用比如银行柜员系统、数据录入系统的复杂表格你也可以考虑商业授权的SpreadJS这类方案它们在企业级功能和售后支持上有自己的优势。Univer的价值在于开源可控、社区活跃、可裁剪程度高但这也意味着出了问题很多时候要靠自己啃源码。从我个人的实践经验看Univer目前最适合的定位就是业务系统中的嵌入式表格能力中枢尤其是模板化填报表单、在线表格编辑、项目协同Excel这类需求。给它配上后端模板存储、权限控制和数据汇总能组合出非常多实用的应用比市面上很多轻量级在线表格SaaS灵活得多。再说一个实际体会。我们做填报表单项目第一版赶工期直接用HTML table拼了个可编辑表格锁定区域用输入框readonly数据校验写在onchange里。刚开始也够用但需求一旦多起来——合并单元格、浮动图片、公式、行列拖拽、多人同时填写冲突提示——自己写的那套代码就开始爆炸式膨胀而且每个功能都在和浏览器渲染较劲。后来切到Univer等于把电子表格这个成熟的交互范式直接拿过来用我们只需要关心业务数据和权限逻辑省下的开发维护时间非常可观。如果你正好在做类似的事情我还是很建议花点时间研究一下Univer值得的。
网站建设高端定制企业官网