news 2026/10/2 11:28:00

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于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 有多少行多少列、每个单元格的值、公式、样式。你可以不依赖用户在界面上编辑,而是由后端下发一份已经设计好的 JSON,Univer 拿到这个 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 的操作路径太多了:直接敲键盘输入、Ctrl+V 粘贴、拖拽填充、双击进入编辑态、导入数据、复制粘贴带格式的数据、通过顶部菜单批量改样式。如果你只在 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的 id:

import { 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 的插件机制给了很大的扩展空间,后续我也打算把数据校验和多人协同逐步接进来,等项目有新进展,我再写一篇实操补充。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 11:27:21

Claude Code 报错排查实战:环境、认证与配置三步通关

1. 先说结论&#xff1a;报错不是玄学&#xff0c;九成问题卡在这三步最近我在好几个开发者社群里蹲着&#xff0c;发现关于 Claude Code 的求助帖出奇地统一&#xff1a;要么装不上&#xff0c;要么登录失败&#xff0c;要么配置不生效。有人折腾了一下午&#xff0c;最后发现…

作者头像 李华
网站建设 2026/10/2 11:27:07

水泥厂通风除尘系统设计:管网水力计算与风机匹配是关键

简介&#xff1a;一份面向安全工程、环境工程专业学生及水泥厂相关技术人员的完整毕业设计文档&#xff0c;围绕水泥厂破碎车间通风除尘系统设计展开&#xff0c;重点解决粉尘排放超标、作业环境恶劣等问题。资源共1个doc文件&#xff0c;压缩包容量为416KB&#xff0c;虽为单文…

作者头像 李华
网站建设 2026/10/2 11:25:52

COMSOL仿真指南:准BIC增强复合波导光栅的古斯汉森位移

上个月帮课题组跑了一个复合波导光栅的电磁仿真&#xff0c;目标就一句话&#xff1a;在1550 nm通信波段&#xff0c;用准BIC把古斯汉森位移做大。模型本身不算复杂&#xff0c;但真正把Q因子、反射相位和横向位移这几个量串起来&#xff0c;中间有不少容易翻车的地方。这篇把完…

作者头像 李华
网站建设 2026/10/2 11:23:23

ESP32-CAM低成本自制3D扫描仪:一机三用全攻略

上一回我跟朋友聊3D扫描&#xff0c;他说去打印店扫一个手办模型要两百多块&#xff0c;还得排队等好几天。我说你这两百多够我攒一台能反复用的扫描仪了&#xff0c;而且这台设备平时还能当网络摄像头用、当无线遥控手柄用。他不信&#xff0c;直到我把采购账单拍他面前——全…

作者头像 李华