2026/11/2 23:16:30

Univer在线表格引擎实战:白名单锁定、权限控制与数据落库

Univer在线表格引擎实战:白名单锁定、权限控制与数据落库 上个月接了一个挺典型的需求客户要在网页里提供一张“长得和 Excel 一模一样”的报表表格格式是提前定死的只有少数几个单元格允许填写其他全部锁定填完了要能提交到后台汇总。这类需求听起来简单但真做起来第一步就卡住了——到底选什么表格方案。选型选到最后我们换了思路与其在传统表格控件上缝缝补补不如直接上一套完整的在线表格引擎。最后落地用的是 Univer。这篇文章就把我们从选型、集成、权限控制到数据落库、性能调优的完整过程写出来尤其是“只允许用户填写指定单元格”这个核心场景网上能搜到的完整实践不多我这里尽量讲透。1. 为什么选了 Univer 而不是继续魔改 SpreadJS / Luckysheet1.1 需求拆解这不是要一个“表格控件”而是要一个“表格应用”先把这个需求的本质说清楚。甲方要的不是“在页面里嵌入一个能输入文字的表格插件”而是一整套“填报表单”的交互范式有表头、有合并单元格、有固定列、有下拉选择、有校验规则、有提交和回填。而且所有数据最终要进入我们自己的业务系统不可能交给第三方 SaaS 存。这种情况下如果你的底子是普通 HTML 表格或者 Handsontable后面会很难受合并单元格、冻结行列、公式联动、按区域锁定编辑这些都是“表格引擎”该干的活不是“表格控件”该干的活。SpreadJS 确实成熟但商业授权费用对中小团队不友好Luckysheet 曾经的社区热度很高可这两年迭代节奏明显放缓而且它更像一个“端到端的产品”想拆出底层能力嵌入自家业务系统改造量不小。Univer 吸引我的第一点就是它把自己定位成“引擎”。核心包只做数据结构、渲染、命令、公式这些基础设施外部工具链按插件加载业务方可以像搭积木一样组装也可以只取其中几块。这种架构对一个要深度集成的项目来说比什么都重要。1.2 三个方案的横向对比我把自己实际对比过的三个方案做了一个简单矩阵都是基于当时实测体验不同版本可能会有差异但大体趋势不会变能力维度SpreadJSLuckysheetUniver开源协议商业授权按开发者收费MIT 开源Apache-2.0 开源渲染方案Canvas 自绘Canvas 自绘Canvas 自绘数据与 UI 解耦好一般绑定较重很好命令驱动按区域锁定编辑成熟但配置偏旧式支持较弱权限模型独立易深度定制公式引擎完整度高基础公式够用内置常用公式可放 Worker 计算导入导出 xlsx很成熟依赖插件坑多有导入导出插件常用场景够用与前端的深浅集成偏重入侵性强产品形态重拆模块难插件化按需引入这个表里我最看重的是“数据与 UI 解耦”和“按区域锁定编辑”两行。前者决定了我们能多大程度把表格数据当成业务数据来处理后者直接对应用户的核心诉求——锁定不可填区域。后面我会展开讲这两点。1.3 决策点协议、性能、周边生态一个都不能少选 Univer 还有几个很现实的原因。第一Apache-2.0 协议对我们来说没有法律包袱。客户如果后续要把系统交付给别的单位我们不用替他们承担授权费用的隐性成本。第二Univer 本身是 TypeScript 全栈实现前后端能共用一套数据结构定义。我们后端是 Node.js 技术栈前端是 ReactUniver 的 TS 类型可以直接嵌入接口定义避免了两端各写一套 DTO 的尴尬。这一点在开发效率上的收益是后面才真切体会到的。第三它的渲染性能实测确实能打。公开资料里宣称的“百万级单元格快速滚动”我们没条件完整验证但拿 5000 行 × 30 列的真实业务数据去压测初始渲染和滚动流畅度都在可接受范围内。对比一下如果用纯 DOM 表格这么多节点早就把页面拖死了。2. Univer 的渲染管线与命令架构它凭什么敢叫“下一代”2.1 从 DOM 表格到 Canvas 渲染把性能问题从“元素数量”挪到“绘制面积”很多人第一次接触 Univer 会好奇它为什么用 Canvas 而不是继续用 DOM我们用浏览器做了这么多年表格不是一直用table吗核心原因在于 DOM 元素的创建和维护成本。一个 100 行 × 50 列的页面表格如果每个单元格都是一个td那就是 5000 个真实 DOM 节点换成 5000 行直接变成 25 万个节点。每个节点的布局、样式、事件绑定、重绘计算浏览器都要逐个处理滚动时立刻露馅。Canvas 的思路完全不同它不关心“有多少个单元格”只关心“当前视口里有多少个像素要画”。视口外的内容根本不会进入绘制流程。Univer 的渲染层会在滚动时实时计算当前可见行列只绘制可视区域内的单元格加上一个基于脏矩形机制的重绘策略——哪里变了画哪里不是整个画布推翻重画。打个比方DOM 表格就像在一条街上给每家每户都安排一个专人站岗人越多越难管Canvas 是只在大门口安排几个保安盯着进进出出的必要人流街上本来站了多少人他不管也不影响他的工作。2.2 数据层与 UI 层解耦权限能拦在“数据层”而不是只做“假遮挡”这是 Univer 架构里我认为最有价值的一点工作簿的数据状态和画布的显示状态是严格分离的。用户在界面上做的任何操作比如点选单元格、输入内容、拖动选区最终不是直接去改画布上的像素而是生成一条命令命令经过校验和权限过滤之后才会修改数据层的工作簿模型数据层变化之后再触发渲染层重绘对应区域。这套“命令驱动”的架构对业务系统的意义非常大。它可以让我对权限的控制做到“数据层拦截”而不是“UI 假遮挡”。什么叫假遮挡就是我们常见的做法把不允许编辑的单元格置灰、加只读属性、处理键盘事件拦截输入。这套方案本质上是“防君子不防小人”任何一个懂一点前端的人打开控制台改改样式、改改 DOM 属性就能绕过编辑限制。Univer 的数据层权限则是在底层判断这条修改单元格的命令有没有通过权限校验如果用户对某个区域没有编辑权限这条命令在进入数据层之前就会被拒绝画布上根本不会出现任何变化。在这个前提下UI 层的置灰、锁定样式反而是锦上添花的交互反馈而不是安全边界。2.3 公式引擎与插件包的边界Univer 的公式引擎是独立包默认提供了大量常用函数复杂公式还可以放到 Web Worker 里去计算避免阻塞主线程渲染。我们目前的填报表单里用到了SUM、IF、VLOOKUP、ROUND这些普通函数表现都很稳定。如果业务上需要更复杂的函数或自定义函数Univer 也允许注册扩展函数实现这块后续可以单独开一篇说。另外要注意 Univer 的“插件化”边界非常清晰基础的univerjs/core只是数据结构内核univerjs/sheets是工作表逻辑univerjs/sheets-ui才是界面渲染univerjs/sheets-formula是公式支持。你需要什么功能就引什么包不需要的功能不进入打包体积。我们项目里没用到图表和数据透视表所以这两个包就没引首包体积能小不少。3. 把 Univer 接进现有系统的第一公里初始化与工程结构3.1 最小可运行实例Univer 目前推荐的接入方式是用官方预设包下面是我们项目里一个最简可运行实例基于 Vite React TS 搭建import { Univer } from univerjs/core; import { UniverPresetSheet } from univerjs/presets; import { createUniverSheetToolbar } from univerjs/presets-sheet-toolbar; import { UniverInstanceType } from univerjs/core; const univer new Univer({ locale: zhCN, presets: [ // 这里只加载表格预设图表、数据透视表按需再加 UniverPresetSheet(), ], theme: { primaryColor: #2f54eb, }, }); // 有工具栏的预设用法 createUniverSheetToolbar(univer, { container: document.getElementById(toolbar-container), }); // 创建工作簿实例可传已经存在的快照数据 univer.createUnit(UniverInstanceType.UNIVER_SHEET, templateSnapshot);代码不长但有几个细节容易踩坑容器的父元素需要明确高度Univer 初始化时会读取容器尺寸来设置画布大小如果初始化时容器高度是 0画布可能渲染成空白。createUniverSheetToolbar依赖一个独立的容器节点不要把工具栏和表格主体塞进同一个div否则布局会乱。Univer 实例是典型的“重量级单例”一个页面上一般只应该创建一次。多标签页切换工作簿时用createUnit创建新实例而不是重新new Univer。3.2 用模板快照初始化线上填报表格我们项目里最常用的做法是先在线下把报表模板搭好然后在页面里导入 Excel 或者直接在界面上调整格式最后把当前工作簿数据导出成一个 JSON 快照存到后端。用户打开页面时前端只需要拿这个 JSON 快照去初始化一个工作簿。Univer 的快照结构大致如下{ id: wb-001, sheetOrder: [sheet-1], sheets: { sheet-1: { id: sheet-1, name: 月度填报, rowCount: 50, columnCount: 12, cellData: { 0: { 0: { v: 客户名称 }, 4: { v: 本月新增 } }, 1: { 0: { v: 张三 }, 4: { v: 3 } } }, mergeData: [ { startRow: 0, endRow: 0, startColumn: 0, endColumn: 3 } ] } } }不同版本的字段名会有细微差异比如cellData在部分版本里可能是二维数组也可能按上面这种 “行号: 列号: 数据” 的对象结构组织我们实际项目里以当前版本导出结果为准。关键理解是Univer 的整个工作簿本质上就是一个可以整体保存、整体恢复的 JSON 对象。这对我们做数据落库、模板管理、版本回滚都非常友好。3.3 在 React 里集成时的容器与管理细节我们在 React 里封装了一个UniverSheet组件核心逻辑是useRef保存 Univer 实例useEffect里完成初始化和数据加载。组件卸载时调用实例的dispose方法释放资源避免内存泄漏。外部传入的data变化时对比版本号决定是整体重新加载还是用增量命令去更新部分数据。有一个细节值得单独提醒Univer 在初始化时如果已经有数据推荐在new Univer之前就准备好快照对象然后通过createUnit一次性传入而不是先创建空工作簿再异步去塞数据。后者在输入频繁的场景下容易触发大量的渲染区域重绘白屏时间会更长。4. 核心需求落地把“可填写单元格”做成白名单4.1 权限模型先理清默认全锁白名单放行回到文章开头那个需求只允许用户在指定单元格里填写其他单元格全部锁定。这个场景在 Univer 的权限体系里对应三层层级工作簿级权限、工作表级权限、区域级权限。我在实际项目里的做法是反向思考与其去给每个不可编辑区域配置锁定规则不如把整张表默认设为不可编辑再显式地给一组“可填写区域”开放编辑权限。这是一个典型的白名单模型它的逻辑更安全新加的单元格如果不小心没配置权限默认是只读的不会出现“漏锁”的问题。Univer 提供的区域级权限接口支持对一个或多个范围做精细化控制。参考代码如下univerAPI.getSheet().setRangePermission({ unitId: wb-001, sheetId: sheet-1, ranges: [C5:K12, M5:P12], permission: { editable: true, }, });这段代码的意思是把C5:K12和M5:P12这两个区域设置为可编辑其他区域保持默认锁定。你可能会问其他区域是默认锁定的吗是的前提你在初始化工作簿时或者创建模板时对整张表启用了默认锁定。我们通常是在模板管理后台勾选一个“启用填报模式”的开关模板里已经把这层保护写入快照了。4.2 不同角色打开同一张表填写区域不同真实业务往往比“一张表固定几个区域”更复杂。比如销售填C5:K12财务填M5:P12管理员全部可见。如果前端写死区域后端每次要同步改配置维护成本很高。我们现在的做法是后端根据登录用户的角色返回一个editableRanges数组数据结构大概是这样{ templateId: tpl-001, editableRanges: [C5:K12, M5:P12] }前端拿到这个数组后再调用setRangePermission动态下发权限。这样“哪个人能填哪块区域”完全由后端控制前端报表代码一行都不用改新加一个角色只需在权限中心配置几行数据非常灵活。有一个坑需要注意权限配置必须在用户开始编辑之前完成否则用户已经操作了某个单元格再锁定它界面上会有短暂的“内容已输入但无法编辑”的体验而且数据层已经接收到输入内容了锁不回去。我们一般在工作簿初始化后、用户可交互之前立刻下发权限配置。4.3 只锁“数据单元格”远远不够还要锁“结构操作”只禁止编辑单元格并不能完全满足业务要求。用户还可以通过右键菜单插入行列、删除行列、拖拽填充甚至复制粘贴这些操作都可能导致填报表单结构被破坏。比如用户选中一列从右键菜单里“插入一列”整个表格头部结构就变了。我们要的是“模板不能被改动”不只是“单元格内容不能被改”。所以完整方案应该是三层策略叠加工作表级禁止插入行、删除行、插入列、删除列、移动行列、隐藏行列。区域级只开放白名单区域的可输入权限其余区域editable: false。操作级对复制粘贴、拖拽填充这类操作按实际需求决定是否放开。我们这个表单场景里拖拽填充是禁掉的因为很容易把模板样式带偏。在 Univer 中前三项通常是通过工作表的保护规则配置来控制。这套配置跟着模板快照走模板管理后台配置好之后所有使用者拿到的模板行为都是一致的。“不同角色开同一张表能填的区域不同”只靠区域权限调整不会因为某个用户把列删了就影响其他用户的数据。5. 数据不丢快照格式、增量变更与后端落库5.1 快照其实就是一个可以被整体保存的 JSONUniver 的整个工作簿数据是可以直接序列化成 JSON 的。我们用到的字段也就那几个sheetOrder、sheets、每个 sheet 的cellData、mergeData、行列数。这些数据不依赖任何前端运行时状态后端数据库完全可以存储同一份 JSON前端加载时原样塞回去页面就恢复了。这个设计帮我们解决了一个大问题用户填了一半刷新页面怎么办传统网页表单的做法是做草稿暂存而表格类应用天然适合整体暂存——因为表格的全部状态都在这个 JSON 快照里。用户在输入过程中我们定期把快照推到后端他哪怕立刻关掉浏览器下次打开也能从暂存快照里无缝续填。5.2 监听变更事件做增量落库虽然整体快照可以保存但如果每次用户输入一个字符我们就把整个工作簿 JSON 推给后端那数据量太大而且毫无必要。我们采用的是两种事件配合的方式监听工作簿的变更事件拿到变更范围做增量 patch。用一个防抖函数把增量操作合并2 秒内无输入才真正发起一次请求。Univer 的事件系统会暴露单元格值变化、合并变化、行列变化等事件。我们是把这些事件统一包一层转换成业务自定义的数据结构再推给后端。这里有一个实际经验不要依赖前端原始事件直接落库因为一次操作可能触发多个事件比如粘贴一段数据会同时触发选区变化、单元格内容变化、样式变化。后端如果分别处理这些事件很难保证一致性。我们选择的做法是前端在防抖后统一取当前快照的“变更摘要”后端只接收一个“快照整体 变更批次号”的结构简单可靠。5.3 一个可落地的存量数据设计我们最终的数据库设计非常朴素核心就两张表表名字段说明form_templateid,name,snapshot_json,editable_ranges_json,version模板表存基础快照和默认权限配置form_submissionid,template_id,submitter_id,data_json,version,status,created_at,updated_at填报记录表每次保存生成一条记录保存逻辑用户每次暂存或提交后端插入一条新的form_submission记录data_json存完整快照version递增。这个设计牺牲了一点存储空间换来的是一份完整的填报表单历史版本。后续做数据审计、回滚、甚至多人轮流填报时追溯都非常方便。有人可能会问为什么不用增量保存因为我们这个业务场景是“一次填报、多次暂存、最终提交”单张表单的数据量很小完整 JSON 也就几十 KB增量省不了多少带宽反而增加实现复杂度。如果你的业务是千人同时在线编辑一张大表那就要好好设计操作日志和版本合并了那是另一个故事。5.4 防抖与版本回滚落库还有一个细节用户快速输入时千万不要每敲一个字就发一次请求。我们设置了一个 2 秒的防抖窗口窗口内有新变更就重置计时器窗口结束后把当前快照整体提交。实测下来正常填报一张 50 行的报表一天下来请求数量也非常有限后端负载毫无压力。回滚功能同样依托于快照每次提交都带版本号管理员在后台可以按版本号恢复。因为form_submission表保存了每次提交的完整数据做回滚就是在界面上选一个历史版本把它的data_json复制成一条新版本记录。6. 上线两个月踩过的坑与性能优化记录6.1 大表格内存与渲染性能的实测我们之前用 5000 行 × 30 列的真实数据压测过。初次全量加载要稍微等一会儿但一旦渲染完成滚动和编辑的体验是比较流畅的。这里有个性能热点值得说如果单元格里塞了太多富文本样式比如一个单元格里混用多种字体颜色、多段换行绘制成本会明显升高。如果有大量这类单元格建议把富文本单元格区域控制在视口范围内不要做成几十列全是富文本的大宽表。另一个经验是合并单元格不要滥用。Univer 对合并单元格的渲染是支持的但大量复杂交错合并会增加计算量。我们实际项目中模板里有几个区域是“标题跨行跨列”的合并单元格数量控制在两位数以内渲染没有出现明显卡顿。6.2 初始白屏与容器高度问题初始化白屏是我们踩到的第一个坑。原因是父容器当时还在加载异步布局高度没有确定Univer 拿到的高度是 0于是画布没画出来。这个问题的修复很简单给容器设置一个明确的最小高度或者等布局稳定后再初始化 Univer。如果你遇到“页面一片空白控制台也没报错”的情况优先检查容器尺寸其次检查快照里是否给工作簿设置了正确的行列数量。我们有过一次报表显示为空排查到最后是因为某个 sheet 的rowCount意外变成了 0。6.3 导入导出客户永远会要求“导一份 Excel 给我”做了在线表格应用就一定会遇到导出需求。Univer 有对应的导入导出插件支持常见功能的 Excel 读写。但我们实测下来一些复杂的自定义格式比如特殊的打印样式、非常规的页眉页脚导出后会有丢失这也正常。我们的方案是基础导出用 Univer 插件复杂报表导出走后端转换服务用 Python 的 DataFrame 重新生成最终的 Excel 文件。如果你只是要“数据能导出成 xlsx”Univer 插件完全够用如果客户要求“导出的 Excel 和网页上一模一样包括打印边界、固定页宽”建议提前和后端约好一个独立的导出模板方案不要指望表格引擎全包。6.4 小心公式重算引发的连锁更新填报表单里如果单元格有公式比如合计行用了SUM那用户每次修改数据都会触发公式重算进而更新合计单元格。这个机制本身没问题但我们在做增量保存时最开始只拿了用户“主动修改”的单元格漏掉了“被公式联动更新”的单元格导致后端落库的数据和用户看到的界面不一致。后来我们调整了方案增量保存时不只监听主动输入事件还要监听公式重算后产生的新值变化。简单来说就是“以快照差异为准而不是以用户操作为准”每次防抖结束后对比一下当前快照和上一版快照的差异范围把差异范围整体上报就不会漏掉联动更新的数据。最后再说一点这项目从选型到上线前后大概两个月。如果让我重新选一次我大概率还是会选 Univer但会吸取两个教训一是权限配置要提前设计成“模板快照 角色区域”两部分而不是等需求变了再临时改代码二是数据落库方案越简单越好整体快照加版本号在处理“填报表单”这一类业务时远比花里胡哨的增量同步可靠。最后分享一个我们内部养成的习惯每次修改完报表模板都会把导出的模板 JSON 提交到 Git 仓库里做一次提交注释写清楚变更内容。这样一旦线上模板出了问题随时能找一个历史版本对照排查也方便不同环境之间同步模板配置这个习惯挺值得保留。