ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Univer实现用户自定义表格与单元格只读保护实战指南

基于Univer实现用户自定义表格与单元格只读保护实战指南 干业务系统这行的人大概率都有过这种体验手里攥着一张精心设计好的 Excel 模板发给十几个仓库管理员去填收回来的时候表头被改了三五行公式被删了两个有人还在空白区域写了一整页工作日志。你去怪人家吧人家还委屈Excel 本来就能随便改啊。所以很多团队最终都会走到同一个需求上要找一张“在线表格”它既能像 Excel 一样让用户填数据又能把不该碰的区域焊死。甚至再进一步让每个用户自己定义一张表别人只能填允许填的格子。Univer 就是在找这个方案的过程中闯进我视野的。它是一个开源、基于 TypeScript 和 Canvas 渲染的在线表格引擎支持公式、条件格式、数据验证、XLSX 导入导出和插件体系。这篇文章我会从选型开始把“用户定义表格 指定单元格不可修改”这件事完整做一遍最后附上我踩过的坑。1. Univer 到底能干什么先想清楚再动手1.1 用三个关键词理解 Univer如果你之前没接触过 Univer先别急着看 API我建议从三个关键词入手。第一是开源。Univer 的核心代码托管在 GitHub社区版基于 Apache 2.0 协议可以免费商用也可以自己拉分支做深度定制。这一点在 To B 项目里非常关键因为商业在线表格控件的年费不便宜而且经常卡在“能不能部署到客户内网”这种授权条款上。开源意味着这套东西从代码到数据完全在你手里不必担心哪天供应商改规则。第二是命令架构。Univer 的内部不是一堆函数互相调用而是把每一次操作封装为 Command比如“设置单元格值”“插入一行”“合并单元格”都是独立命令。这个设计看似麻烦却给权限控制留了后门你可以在命令执行前插入一道拦截逻辑决定这次操作是放行还是拒绝。后面我要做的“单元格只读”本质上就是守住了这道门。第三是插件生态。Univer 的编辑器能力分为核心引擎和 UI 插件你不想要工具栏、菜单栏、状态栏这些额外的界面完全可以只引核心包把表格嵌入到自己的业务页面里。插件体系还让公式引擎、导入导出、剪切板这些能力可以按需加载首屏加载体积控制起来比传统重型表格框架舒服得多。1.2 和主流在线表格方案对比选型心里才有底我每隔一段时间就要给人做在线表格选型市面上的方案大概可以分成四类纯前端展示型、重型文档型、商业控件型、数据驱动型。方案开源/授权部署成本单元格级权限协同二次开发曲线典型场景Luckysheet开源低弱基本没有中低但社区活跃度下降纯前端表格展示、简单编辑OnlyOffice开源版/商业版高服务端较重一般靠文档权限实现完整高需理解整服务企业文档在线预览编辑SpreadJS商业中支持需另配服务中但受制于 License报表系统、项目型交付Grist开源中强按列按行控制有基础协同中表格逻辑类似数据库数据收集、工作流Univer开源低纯前端可通过命令守卫实现社区版需自建中文档齐全在线表格嵌入、自定义表单选型时我最看重两点。第一点是能不能在单元格粒度上做定制Luckysheet 虽然轻但权限和协同这块基本是空白的商业系统里用起来心里没底。第二点是中国团队主导的开源项目对中文场景的支持Univer 的 locale、公式中文名、导入导出编码都处理得相对顺手遇到问题提 issue 响应也快。1.3 什么场景不该选 Univer我不太喜欢无脑吹一个方案Univer 也有不适合的场景。如果只是要在网页上展示一张静态表格直接拼 HTML Table 或 Ant Design Table 就够了引入 Univer 反而增加成本和渲染负担。如果业务核心是复杂报表打印比如像素级还原的财务结算单Univer 社区版的打印能力目前还比较基础BetterSheet 这类商业打印控件更合适。如果要求数据库级别的行列权限比如某个人只能看某些字段建议直接考虑 Grist 这种以记录和权限为核心的方案。还有一点容易被忽略Univer 的 API 版本迭代比较快早期版本的代码升级到新版可能需要改不少地方。如果你的团队没有前端资源或者项目一锤子买卖、后期没人维护我更建议直接用现成的低代码表格组件而不是投入精力二次封装 Univer。技术选型没有最好只有适不适合。2. “用户定义表格”的三条落地路径2.1 路径一让用户直接在编辑器里画第一种实现方式最简单打开 Univer 就是一个完整编辑器用户想加列就加列想改表头就改表头想合并单元格就合并单元格。这种方式的本质是“自由表格”它适合内部协作文档、原型设计、临时数据登记这类场景。优点是几乎没有开发成本你把 Univer 挂上去再把编辑结果定期存快照就行。缺点也很明显不设防。用户可以把整个表结构改得面目全非公式被删了也不会有人发现。我接触过不少团队一开始都觉得“让用户自由画表格挺好的”上线两周后就开始收到投诉某个物料编码列被人改成了日期格式某个公式被人复制成了死循环。所以路径一我只能作为“低频内部工具”推荐如果你面对的用户量超过 50 人或者数据需要汇总分析直接走路径三。2.2 路径二后端下发工作簿快照Univer 的工作簿本质上是一份结构化的 JSON 数据官方叫 Snapshot。它定义了工作簿里有哪些 Sheet、每个 Sheet 有多少行多少列、每个单元格的值、公式、样式。你可以不依赖用户在界面上编辑而是由后端下发一份已经设计好的 JSONUniver 拿到这个 JSON 后直接渲染成一张完整的表格。这里的“用户定义表格”变成了“代码定义表格”{ id: wb-stock-001, name: 库存盘点表, sheetOrder: [sheet-stock], sheets: { sheet-stock: { id: sheet-stock, name: 盘点明细, rowCount: 40, columnCount: 7, cellData: { 0: { 0: { v: 序号 }, 1: { v: 物料编码 }, 2: { v: 物料名称 } } } } } }这种方式的优点是模板统一、可控性强缺点是需要写代码维护模板业务人员没法自己上手。它比较适合表单字段固定、改动频率低的场景比如固定资产登记、巡检表等。如果你要做的是“业务人员自己也能定义表格”的产品比如低代码搭建平台光有路径二还不够。2.3 路径三模板注册 填写回收这是我推荐的做法把路径一和路径二结合起来才是真正解决“用户定义表格但只能填指定格子”的方案。具体链路是这样管理员打开 Univer 编辑器像画 Excel 一样把表头、公式、校验规则都配好然后点“保存模板”。前端把工作簿 Snapshot 提交到后端后端将它存为一个模板资产。普通用户打开页面时后端下发模板 Snapshot同时下发一份“可编辑区域配置”前端根据这份配置把模板里指定范围设置成只读。用户填完提交前端把填好的 Snapshot 再存回去管理员导出汇总。我实际在项目里就是这么做的。管理员体验到了 Excel 般的自由普通用户只能填实盘数量、备注这种真正需要填的格子而表头、物料信息、计算公式全部存活在模板里谁也破坏不了。这套链路让“用户定义表格”和“数据规范化”两个矛盾的需求同时得到满足。3. 单元格“只可填写、不可乱改”的拦截原理3.1 先认清 Univer 的三级权限模型Univer 的权限设计可以分为三个层级工作簿级、工作表级、单元格范围级。工作簿级控制谁能打开、谁能导出、谁能创建 Sheet工作表级控制某个 Sheet 是可见还是隐藏、能否改名单元格范围级控制具体哪一片区域能被编辑、被设置样式。在做填报类业务时我们最关注的是单元格范围级。Univer 内部把权限封装为 Permission Point每个权限点有 ALLOW 和 DENIED 两种状态。理论上你可以注册自定义权限点然后让某个区域的编辑权限在运行时动态计算。不过我踩过一个坑不同版本的 Univer 对 permission API 的命名变化比较大从IPermissionService到PermissionStatus都有过调整直接依赖这套 API 写业务代码升级版本时维护成本会偏高。所以我更建议的做法是把权限模型当作业务设计来理解但实现时用更稳定的命令拦截机制。两者并不冲突命令拦截做的是“最后一道闸门”权限模型做的是“决策来源”。3.2 只锁 UI 等于没锁真正的入口是命令很多第一次做单元格只读的人会先想到“把右键菜单里的编辑入口禁用掉再给选区加一个 readonly 样式”。这思路不能说错但远远不够。Excel 的操作路径太多了直接敲键盘输入、CtrlV 粘贴、拖拽填充、双击进入编辑态、导入数据、复制粘贴带格式的数据、通过顶部菜单批量改样式。如果你只在 UI 层做了几个按钮的开关用户粘贴一个大区域时底层的 SetRangeValues 命令照样会把数据写进去。那感觉就像你关掉了房子的前门却忘了后门和窗户都开着。Univer 把每一次底层修改都归为 Mutation 类命令。无论用户在界面上点击、键入还是粘贴最终都会通过命令服务执行对应的 Mutation。所以只要你在命令服务上挂一道拦截器检查命令目标区域是否属于可编辑范围就能从根上控制所有编辑行为。这个思路比 UI 层禁用要干净得多而且天然覆盖粘贴、拖拽这些旁路。3.3 用 beforeCommandExecuted 挂一道只读守卫Univer 的命令服务提供了一个事件beforeCommandExecuted。这个事件在命令真正执行之前触发监听函数如果可以返回false就能取消本次命令执行。这就相当于给命令装了一个可编程的前置校验器。我会维护一个可编辑区域白名单然后在拦截器里判断当前命令的ranges是否完整落进白名单import { CommandType, ICommandService } from univerjs/core; import type { IRange } from univerjs/core; // 允许编辑的区域白名单这里代表 E 列第 2~39 行和 G 列第 2~39 行 const EDITABLE_RANGES: IRange[] [ { startRow: 1, endRow: 39, startColumn: 4, endColumn: 4 }, { startRow: 1, endRow: 39, startColumn: 6, endColumn: 6 }, ]; // 需要拦截的底层修改命令 const BLOCKED_MUTATIONS [ sheet.mutation.set-range-values, sheet.mutation.set-range-style, sheet.mutation.insert-row, sheet.mutation.remove-row, sheet.mutation.merge-cell, ]; function isEditable(ranges: IRange[]): boolean { return ranges.every((range) EDITABLE_RANGES.some((allow) range.startRow allow.startRow range.endRow allow.endRow range.startColumn allow.startColumn range.endColumn allow.endColumn ) ); } export function registerCellGuard(commandService: ICommandService) { commandService.beforeCommandExecuted((command) { if (command.type ! CommandType.MUTATION) return true; if (!BLOCKED_MUTATIONS.includes(command.id)) return true; const params command.params as { ranges?: IRange[]; range?: IRange }; const ranges params?.ranges ?? (params?.range ? [params.range] : []); if (ranges.length 0) return true; return isEditable(ranges); }); }这段代码的逻辑很直白任何改动单元格内容的命令来了我先看它要改哪些区域。如果目标区域完全落在白名单里放行只要有一格越界整个命令拒绝执行。这里用every的原因是“一个区域里不能有半个格子可编辑”否则用户粘贴时会把锁定区域一并写穿。需要注意的是命令 ID 在不同版本里可能有调整比如早期版本里某个命令叫sheet.mutation.set-range-values新版本可能加了命名空间。我建议你在浏览器控制台给beforeCommandExecuted加一个console.log(command)实际操作一次粘贴把真实的命令 ID 记下来再填进数组这是最可靠的办法。3.4 白名单区域如何设计更合理只拦编辑命令还不够你要有一个清晰的“哪里能填、哪里不能填”的配置模型。我的习惯是把表格每一列划分成三类锁定展示列、用户填写列、公式计算列。锁定展示列包括序号、物料编码、物料名称、账面库存这些用户不该动的字段它们由模板生成用户不参与修改。用户填写列是实盘数量、备注这类真正要采集的数据它们进入白名单。公式计算列是差异、累计、汇总这类由公式实时算出的字段也要锁定否则用户手改公式结果会导致汇总对不上账。这种按列划分的好处是配置简单、理解成本低。如果你遇到更复杂的场景比如“某些人只能填 A 列某些人只能填 B 列”那就把白名单从全局常量改成按用户角色动态生成的数组。设计上只要保证一件事白名单是运行时后端下发的配置而不是写在代码里的硬编码。4. 从零做一张“库存盘点表”的完整实操4.1 初始化项目并接入 Univer我先用 Vite 搭一个最简前端项目然后安装 Univer 的依赖。Univer 官方推荐使用 preset 包把常用插件一次性打包注入对大多数业务场景够用了。pnpm create vite univer-demo --template vanilla-ts cd univer-demo pnpm install univerjs/preset-sheets初始化 Univer 实例的代码如下注意容器要指定一个div的 idimport { Univer, LocaleType } from univerjs/core; import { UniverPresetSheets } from univerjs/preset-sheets; const univer UniverPresetSheets.create({ locale: LocaleType.ZH_CN, container: app, });如果项目里需要公式计算和 XLSX 导入导出preset 也已经包含对应能力。我在写这个样板时没有额外引入旧版分散插件目的就是为了让初始流程最短、最不容易出错。4.2 用工作簿数据定义一张填报表接下来我准备了一张库存盘点表的模板数据。这张表的列结构是A 列序号、B 列物料编码、C 列物料名称、D 列账面库存、E 列实盘数量、F 列差异公式、G 列备注。其中 E 列和 G 列是用户要填写的其他列由模板锁定。import type { IWorkbookData } from univerjs/core; export const stockTemplate: IWorkbookData { id: wb-stock-2024, name: 库存盘点表, sheetOrder: [sheet-stock], sheets: { sheet-stock: { id: sheet-stock, name: 2024-06-30 盘点, rowCount: 40, columnCount: 7, cellData: { 0: { 0: { v: 序号 }, 1: { v: 物料编码 }, 2: { v: 物料名称 }, 3: { v: 账面库存 }, 4: { v: 实盘数量 }, 5: { v: 差异 }, 6: { v: 备注 }, }, 1: { 0: { v: 1 }, 1: { v: A001 }, 2: { v: 电阻 10K }, 3: { v: 500 }, 4: { v: }, 5: { f: D2 - E2 }, 6: { v: }, }, }, }, }, };这里的cellData是一个两层对象第一层 key 是行索引第二层 key 是列索引。单元格内容里v表示原始值f表示公式。实际项目中你可以用循环为第 2 到第 40 行生成数据我这里为了展示只写了一行意思到了就行。把模板塞进 Univer 的姿势是这样的const workbook univer.createUnit( UniverInstanceType.UNIVER_SHEET, stockTemplate );这一步执行完页面上就能看到一张和 Excel 一样可交互的表格了。公式会随着 E 列的变化自动重算差异这是我最喜欢用 Univer 的原因之一它内置的公式引擎把这个场景天然带入到了在线表单里。4.3 配置锁定区域并实现只读模板渲染出来以后我要把 A、B、C、D、F 列都锁死只留下 E 列实盘数量和 G 列备注可以编辑。按照之前的拦截思路可编辑区域白名单就是const EDITABLE_RANGES: IRange[] [ { startRow: 1, endRow: 39, startColumn: 4, endColumn: 4 }, // E 列 { startRow: 1, endRow: 39, startColumn: 6, endColumn: 6 }, // G 列 ];然后把上文的registerCellGuard用起来。注意这里需要拿到 Univer 内部的命令服务官方示例里常见写法是通过univer.__getInjectedDependency(ICommandService)虽然带下划线属于内部接口但在很多示例里确实这么用。如果封装得规范一点建议在你的插件类里通过依赖注入直接拿服务。我把守卫挂载的完整代码贴在下面这段已经可以直接跑import { ICommandService } from univerjs/core; import { registerCellGuard } from ./guard; const commandService univer.__getInjectedDependency(ICommandService); registerCellGuard(commandService);挂上之后用户试图在锁定区域输入内容时命令执行会被直接取消界面上表现为“敲字没反应”。如果用户从 C 列复制一块数据粘贴到 C 列同样会被拦截因为目标区域不在白名单里。4.4 数据回收填完的表怎么存回来只读保护只是前半场数据回收才是业务闭环。Univer 保存的是一个工作簿快照也就是先前定义的IWorkbookData结构。回收的思路很简单监听命令执行一旦用户编辑了可编辑区域就把整个工作簿的 Snapshot 拿到手再发给后端。import { CommandType, ICommandService } from univerjs/core; commandService.onCommandExecuted((command) { if (command.type ! CommandType.MUTATION) return; if (command.id sheet.mutation.set-range-values) { const snapshot workbook.getSnapshot(); // 这里是示例实际项目请使用防抖批量提交 fetch(/api/stock-sheet/save, { method: POST, body: JSON.stringify(snapshot), }); } });我这里为了演示写得太粗糙了直接把 fetch 放进了监听里实际项目中千万别这么干。正确的做法是引入一个 debounce用户停止输入 3 秒后再提交或者做一个“保存”按钮由用户手动触发提交。原因很简单用户在表格里连续输入时set-range-values会被高频触发你总不能每个字符都发一次全量快照。还有个细节要注意公式列 F 在用户编辑 E 列后会自动重算但公式重算不一定走set-range-values这类数据变更命令。如果你保存的时机不合适可能把“旧的计算结果”存下去。稳妥的做法是保存前先主动触发一次公式刷新或者保存时把整张 sheet 的快照原样存回去别自己拼字段。5. 实战中踩过的坑逐条排查给你看5.1 表格白屏先查容器高度我第一次接 Univer 时首页表格死活不渲染只剩一个空白的 div。排查了半天发现是容器高度为 0。Univer 的 Canvas 布局依赖父容器的高度如果你的根节点没有设置高度它就会缩成一个竖线。解决方法是给容器设置明确高度html, body, #app { width: 100%; height: 100%; margin: 0; }同时在创建 Univer 实例时container传入的 DOM 必须已经挂载到文档中。如果你是 React 式地在useEffect里初始化记得先等 DOM 渲染完成。5.2 命令 ID 对不上拦截器形同虚设有段时间我发现拦截器没有生效怎么敲键盘都能改锁定单元格。最后在beforeCommandExecuted里打了一行日志才发现用户键入时走的命令 ID 和我拦截的列表对不上。Univer 的命令 ID 在不同版本里发生过调整而且同一个交互可能触发多个命令。我的排查方法是先打开调试工具在beforeCommandExecuted回调里打印command.id然后手动执行一次你关心的操作比如输入文字、粘贴、删除行列把日志里出现的 ID 全部收集起来再填进拦截数组。不要凭记忆写命令名直接看运行时日志是最准的。5.3 粘贴路径钻了空子粘贴是最常见的绕过套路。用户从锁定区域复制一片数据再粘到另一个锁定区域如果拦截器只拦了某一个命令粘贴操作很可能从其他入口溜进去。Univer 的粘贴通常也会落到set-range-values但粘贴可能先触发剪切板读取再由另一个 mutation 写入。所以我把常见 mutation 命令都列入黑名单包括设置值、设置样式、合并单元格、插入行、删除行。这样无论从哪个入口进来只要底层是这些 mutation都会被我的守卫拦住。5.4 行列增删把模板结构弄坏只拦单元格值修改还不够用户还可以右键插入行、删除行、调整行高列宽。插一行会把模板里的公式区域整体下移删一行会让公式错位。这些操作属于结构型修改一样要进拦截名单。如果你允许用户增加明细行就需要更精细的控制。我现在的方案是锁定区域完全禁止行列增删用户要增加数据只能通过表单提交由后端插入到数据表。这样可以保证模板结构和公式引用永远稳定。5.5 上万行单元格判断卡顿如果你把白名单判断写得低效比如每来一次命令都遍历所有单元格或者用二重循环去区间匹配数据量到几千行时就会有明显卡顿。我的做法是白名单区域的数量很少通常就是两个矩形。用矩形包含判断就行逻辑复杂度极低。如果遇到几十个分散区域建议先把这些矩形合并成互补的非重叠区域或者按行索引建立分段索引。总之不要逐个单元格判断一定要做区域级判断。5.6 保存时机兜不住输入节奏最后坑在数据保存。用户输入一个字符就触发一次保存数据库压力大且会产生大量无意义历史版本等用户填写 10 分钟再手动保存又担心中途断网。我的经验是防抖和节流二选一。输入事件密集用防抖比如停止输入 2 秒后自动保存。同时监听浏览器的beforeunload事件在页面关闭前强制保存一次未提交数据。如果业务要求严格还可以把每个 mutation 都记成操作日志断线重连后做回放。但这一步要看业务体量别一上来就整重型的。最后分享一点我的实战体会这套“模板下发 区域锁定 快照回收”的链路我已经在好几个项目里复用过了。它最值钱的地方不是把 Univer 接进来而是把整条业务逻辑想清楚了管理员定义结构、普通用户只填数据、后端统一回收。Univer 给了一张足够灵活的画布而真正让表格可控的是前面那套命令拦截白名单的设计。如果你只是想在现有系统里快速加一个可填写、不可乱改的在线表可以直接复制我上面这段守卫代码然后把你自己的区域配好。等你把这一版跑顺了再考虑公式引擎深度定制、协同编辑这些进阶能力。Univer 的插件机制给了很大的扩展空间后续我也打算把数据校验和多人协同逐步接进来等项目有新进展我再写一篇实操补充。
返回列表