☰
用Univer实现指定单元格可编辑的在线表格权限控制
2026/11/2 9:47:51 网站建设 项目流程

1. 认识 univer:一个能放进业务系统里的在线表格引擎

1.1 univer 到底是什么

先说结论:univer 是一个开源的、可嵌入到前端项目里的“在线办公套件内核”,目前最成熟的是它的表格能力。你可以把它理解成一组代码组件,一旦集成到你自己的网站或管理系统里,用户就获得了一个接近 Excel 的在线表格界面:支持多 sheet、合并单元格、公式计算、条件格式、数据校验、复制粘贴、撤销重做等等。更关键的是,因为它是代码级的存在,业务可以对它做精细控制,比如“允许用户编辑 B 列,其他单元格只能看不许改”这种很实际的规则,就是本文要展开的核心主题。

1.2 热词背后的真实需求

我在搜索信息时看到最多的是“univer 支持用户定义表格,然后让用户去填写一些单元格,其他的单元格无法修改”,这其实是很多企业对表格工具的刚需。举个身边的场景:HR 在月底收集员工绩效自评分,如果用 Excel 发到群里,员工之间会互相改动,甚至删公式、改表头;如果做成问卷,又不能把 Excel 的表头、合并单元格、评分规则原样呈现。用 univer 做一个页面,员工打开就是一张正式表格,只有指定列可填写,填完点保存,这就是我们今天要聊的典型落地场景。

1.3 这篇内容适合谁

这篇内容适合三类人:第一,正在做“在线填报、数据采集、表单个性化”功能的前端开发者;第二,需要在内部系统里嵌入一个可编辑 Excel 的交付同学;第三,产品经理或运营同事,如果你已经搜到 univer,说明你有同样的业务诉求,这篇文章至少能让你知道可行性是什么,以及找开发沟通时要注意哪些关键点。不论你技术基础如何,后面每一步我都会尽量讲清楚“为什么这样做”,而不只是给一段能跑的结果。

2. 为什么我最终选了 univer,而不是其他表格方案

2.1 先把市面上的路线过一遍

在做“在线填表”这个需求时,通常有四条路可以走。第一条是用问卷工具,像问卷星、Google Forms,上手快,但它们的表格语义弱,跨行合并、自定义单元格样式、行内公式基本是瓶颈,复杂一点就非常拧巴。第二条是用老牌开源表格组件,比如 Handsontable 或 Luckysheet,这类组件渲染能力都不错,可一旦涉及“哪些单元格可编辑”这种规则,因为它们把编辑开关散落在每个 cell 的配置里,要在大表格上动态维护权限就会很吃力。第三条是买商业控件,比如 SpreadJS,功能完整、售后也好,但授权价格对很多中小团队不是小数目。第四条是自己用 div 加 contenteditable 造轮子,我劝你直接放弃,光是处理选区、滚动、公式、撤销,就不是一个月能做完的事。

2.2 univer 的架构取舍正好适合这个场景

univer 最吸引我的地方,是它把“视图渲染”和“数据命令”拆得很开。用户每次操作,比如双击一个单元格开始输入,会先变成一个命令,发到命令服务,命令确认后,再去改数据模型,最后渲染层响应变化。这意味着,如果我们想限制某个单元格不能编辑,只要在命令执行前把它拦下来就可以了,完全不用去操作 DOM 上的输入框。相比很多表格组件要在 beforeEdit 事件里手动修改内部状态,univer 的这种方式更干净,也不容易被后续升级打乱。这也是我敢在项目里大量使用它的重要原因。

2.3 它也有让人头疼的短板

必须说句公道话,univer 的官方文档和社区目前还不算特别完善,版本迭代快,API 在不同小版本之间偶尔会变。比如早期版本初始化是@univerjs/core加一堆模块,新版本又推荐用@univerjs/presets快速预设。这种过渡期的变化,对完全没有前端经验的人来说不友好。所以我建议:如果你准备上生产,先锁定一个稳定版本,把关键 API 封装成自己的模块,不要跟着最新版追。另一方面,正因为它是用 Canvas 来渲染表格内容的,性能上限比纯 DOM 高不少,几万级单元格的滚动、编辑都能保持流畅,这个优点在大量数据填报表单里尤其有价值。

3. 把 univer 跑起来:环境准备与基础表格搭建

3.1 你需要准备什么

开始之前,先确保电脑上有 Node.js,版本最好不低于 18,npm 或 pnpm 都行。我个人习惯用 pnpm,因为 univer 这类组件库依赖树很细,pnpm 能少踩很多磁盘和缓存上的坑。如果你用的是 React 项目,直接装@univerjs/presets和样式包,它会帮我们一次性引入表格和 UI 相关模块。当然你完全可以用 Vue,univer 框架无关,集成到 Vue 里的原理是一样的,在onMounted里初始化即可。

安装命令我贴在下面:

npm install @univerjs/presets

装完后在入口文件引入样式:

import '@univerjs/presets/lib/styles.css';

如果你用的版本和我的不同,样式路径可能会有一点差异,打包报错时去node_modules里看lib下的文件结构就能找到对应入口。

3.2 初始化一个最简单的表格

以 React 为例,我会在组件里用useRef存放容器,在useEffect里初始化 Univer 实例。注意,初始化动作一定要在容器 DOM 挂载之后执行,否则 univer 找不到挂载点,控制台会报错。代码大致是下面这样:

import { useEffect, useRef } from 'react'; import { Univer } from '@univerjs/presets'; export function SheetDemo() { const containerRef = useRef<HTMLDivElement>(null); useEffect(() => { const univer = new Univer({ locale: 'zhCN', presets: ['sheet', 'ui'], }); univer.createSheet('产品清单', { rowCount: 50, colCount: 8, }); univer.mount(containerRef.current!); return () => univer.dispose(); }, []); return <div ref={containerRef} style={{ width: '100%', height: 600 }} />; }

createSheet的第一个参数是工作表名称,第二个参数是初始配置。我最初没写rowCount和colCount,默认值其实也够,但业务上明确行数和列数有利于统一数据校验和权限范围,所以建议显式指定。mount就是把整个表格渲染到指定容器里,univer.dispose()是在组件卸载时清理资源,这在长时间单页应用里很关键,不清理会造成内存增长。

3.3 按真实业务定义表格结构和样式

创建空白表之后,我们肯定要填充表头、列宽和初始数据。univer 创建 sheet 时可以直接传一个data或cells参数,不同版本命名略有不同,但结构万变不离其宗:每个单元格坐标对应一个对象,里面是值和样式。比如我要做一张“员工成绩登记表”,A 列是姓名,B 列是部门,C 列是成绩,表头要有底色加粗效果:

const data = { rowCount: 50, colCount: 5, columns: [ { width: 120 }, { width: 120 }, { width: 100 }, { width: 100 }, { width: 200 }, ], cells: { 'A1': { v: '姓名', s: { bold: true, bgColor: '#EEEEEE' } }, 'B1': { v: '部门', s: { bold: true, bgColor: '#EEEEEE' } }, 'C1': { v: '成绩', s: { bold: true, bgColor: '#EEEEEE' } }, 'A2': { v: '张三' }, 'B2': { v: '技术部' }, }, }; univer.createSheet('员工成绩登记表', data);

这里的v代表显示值,s代表样式对象。列宽走了columns数组,也可以写成按列下标指定宽度。有一点要注意,如果表头有多行甚至带合并单元格,univer 有独立的merges字段,把startRow/startColumn/endRow/endColumn写清楚就可以,比如{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 1 }表示 A1:B1 合并成一个单元格。我就经常用跨行合并来做分组表头,看起来和纸质 Excel 完全一致。

3.4 顺便把数据校验规则加上

填表场景最大的问题不是没人填,而是乱填。univer 支持数据校验,比如给 C 列的成绩加 0 到 100 的范围限制,给部门列配置下拉选项。这样用户在输入不符合规则的内容时,表格会立即给出提示,成功率比我们事后校验高得多。配置方式通常是注册一个校验规则到指定范围,示意如下:

import { ValidationStatus } from '@univerjs/sheets'; sheet.addDataValidation({ ranges: ['C2:C50'], validator: { type: 'number', operator: 'between', value1: 0, value2: 100, showErrorMessage: true, errorMessage: '成绩必须在 0 到 100 之间', }, });

如果版本里没有这个链式 API,大致代码也很相似:创建一个校验规则对象,set 到某一片 range。这里我特别提一句:数据校验和“单元格只读”是两个维度,不要混在一起。只读是控制能不能进编辑态,校验是控制输入内容合不合法,两者都做,填报体验才完整。

4. 核心功能:怎么做到“指定单元格可编辑,其他只读”

4.1 先搞清楚 univer 的编辑链路

要在 univer 里实现这个功能,第一步不是写代码,而是理解它的编辑链路。用户从看到表格到完成一个单元格内容的修改,在 univer 内部大致要经历四个阶段:第一次鼠标选中某个格子,只是高亮变化,还不会弹编辑器;第二次双击或按回车,系统会定位到单元格坐标,接下来命令服务执行一个“开始编辑单元格”的命令,打开输入框;输入过程中产生的每次内容变化,也会陆续落到数据模型;最终收起编辑器时,再把新值真正写回单元格。我们所说的“禁编辑”,最稳妥的切入点就是中间那道“开始编辑”的命令。只要在命令执行前判断这个坐标是否在白名单内,不在就不让它打开输入框,用户看到的表现就是“双击没反应,只能选中查看”,这样既保留了体验,又堵住了入口。

4.2 方法一:命令拦截,按坐标白名单放行

下面给一个非常典型的“命令拦截”实现。先定义一个可编辑区域配置,业务上可以来自后端,也可以来自本地常量:

const editableRegions = [ { sheetId: '成绩登记表', startRow: 1, endRow: 50, startColumn: 2, endColumn: 2 }, ];

然后我们在 univer 初始化后,拿到命令服务注册一个前置拦截:

const commandService = univer.getCommandService(); commandService.beforeCommandExecuted((command) => { // 过滤出和数据修改相关的命令 if (command.type !== 'mutation' && !command.id?.includes('cell')) { return; } const payload = command.payload ?? {}; const { sheetId, row, col } = payload; if (sheetId && typeof row === 'number' && typeof col === 'number') { const canEdit = editableRegions.some( (region) => region.sheetId === sheetId && row >= region.startRow && row <= region.endRow && col >= region.startColumn && col <= region.endColumn ); if (!canEdit) { return false; // 阻止命令继续执行 } } });

注意这行代码里的return false表示“阻止执行”,不同版本的命令服务可能要用preventDefault或取消事件,你在实际项目里打印一下命令对象就能看到处理方式。这里最重要的思想是:不看command.id是不是精确等于某个已知常量,而是判断 payload 是否带坐标字段,再根据业务白名单决定去留。因为 univer 的命令 ID 版本间有变化,通过坐标字段判断反而比硬编码 ID 更抗升级。

4.3 别忘了拦截“样式修改、复制粘贴、填充柄”

新手容易漏掉:就算用户进不了编辑器,他仍然可能通过工具栏改单元格背景色,或者用鼠标拖拽填充柄,把一个可编辑格的值拉到旁边的只读格里。所以严格的只读控制,不只是拦截编辑命令,还要把几类关联操作一起拦掉。在 univer 里,修改样式、修改边框、合并单元格、复制粘贴、填充系列,都会走不同的命令或操作。我的做法是维护一个“可修改范围”的粗粒度名单,凡是命令 payload 里有坐标、且坐标不在可编辑范围内,统统返回 false:

const blockedCommands = [ 'style.setRangeStyle', 'cells.merge', 'sheet.paste', 'sheet.fill', // 按你的版本实际情况补充 ]; commandService.beforeCommandExecuted((command) => { if (blockedCommands.includes(command.id)) { const payload = command.payload ?? {}; if (payload.ranges) { for (const range of payload.ranges) { if (!isInEditableRegion(range)) return false; } } } });

这个代码只是骨架,关键是提醒你:权限控制的代码要集中在一个模块里,把“某个区域是否允许某类操作”抽象成一个通用函数,后面新增命令类型时,只要在一个地方加名单就行。否则每遇到一个操作就补一个 if,代码很快就会烂掉。

4.4 方法二:用工作簿保护配合“允许编辑区域”

如果你不想自己维护一大堆命令黑名单,univer 也提供了传统工作表保护的能力,类似 Excel 里的“保护工作表”。保护模式下,默认所有单元格只读,然后你再指定哪些区域是允许编辑的。这样的业务模型更直观,也方便产品界面表达。示意代码大致是这样:

sheet.enableProtection(); sheet.setEditableRanges([ { startRow: 1, startColumn: 2, endRow: 50, endColumn: 2 }, ]);

这个 API 在不同版本里可能叫setProtect、addRangeProtection,你打开编辑器时自动补全就能看到。实际生产里,我建议先用这种保护 API 做基础开关,再叠加一层命令拦截处理特殊操作,比如粘贴。这样既省心又更安全。底层逻辑仍然是:只允许名单内区域的修改命令进入数据层。

4.5 给运营同事的可视化配置面板

如果只是我们自己写死白名单,运营同学每调整一次都要找开发,很不高效。更好的做法是在 univer 里再建一个“管理配置”视图:让运营打开一张和真实模板几乎一样的表格,通过鼠标框选可以编辑的单元格,点一下“设为可编辑区域”按钮,选择结果就保存到后端。这个面板和填报页面共用一个模板文件,只是多了右侧工具区。

怎么拿到鼠标框选区域?很简单,我们在选择的单元格上挂监听,每当选区变化,univer 会把当前 selection 的坐标范围告诉我们。然后把坐标转成一个 range 对象存下来,发布填报页的时候,把这个 range 列表注入前面说的白名单就好了。我实际测试中最大的体会是:一旦有了可视化配置,这个工具的可接受度会直线上升,因为运营不用看坐标,直接看绿色高亮区域就明白用户能编辑哪儿。

4.6 后端校验永远不能省

最后一条,也是最容易翻车的一条:前端拦截只影响普通用户的正常操作,不能防住接口被直接调用。如果一个懂点技术的人,绕过页面直接向保存接口提交数据,前端规则对他没用。因此后端保存接口必须再次校验:客户端提交上来的每个单元格坐标,必须落在该表配置的“可编辑区域”内,否则整单拒绝,或者把非法字段直接忽略。这样做至少三个好处:防止跳过前端、防止并发下双方互相覆盖只读格、给审核留依据。

5. 实操:做一个员工培训报名表的完整流程

5.1 先定义需求和表格模板

我现在带着你完整走一遍。假设要给行政做一个“员工培训报名表”,条件如下:表头有“姓名、部门、联系电话、课程名称、备注”;王小明、李丽这些员工已预填在 A 列和 B 列;课程名称是下拉选择,比如“财务内训”“项目管理”“沟通技巧”;联系电话只允许员工自己填;备注可填可不填;其余所有区域都不允许修改。需求拆解下来,权限策略是:A 列、B 列、表头全部只读;C 列电话、D 列课程、E 列备注可编辑;且 D 列还要加下拉校验。

5.2 创建模板并配置表格数据

先用 createSheet 建一张带基础数据的表,并定义好列宽、表头、课程列的校验规则。课程下拉可以放在数据校验里,这样用户填表时能直接选,不用手打。电话号码列单独加一个正则校验,我先按最简单的 11 位数字场景写一下。下面是模板数据和校验规则一并提交的示意代码,具体方法名以你的 univer 版本为准:

univer.createSheet('培训报名表', { rowCount: 20, colCount: 5, columns: [ { width: 100 }, { width: 100 }, { width: 140 }, { width: 160 }, { width: 200 }, ], cells: { A1: { v: '姓名', s: { bold: true, bgColor: '#FCE4D6' } }, B1: { v: '部门', s: { bold: true, bgColor: '#FCE4D6' } }, C1: { v: '联系电话', s: { bold: true, bgColor: '#FCE4D6' } }, D1: { v: '课程名称', s: { bold: true, bgColor: '#FCE4D6' } }, E1: { v: '备注', s: { bold: true, bgColor: '#FCE4D6' } }, A2: { v: '王小明' }, B2: { v: '财务部' }, A3: { v: '李丽' }, B3: { v: '人事部' }, }, }); // 给 C 列添加手机号校验 sheet.addDataValidation({ ranges: ['C2:C10'], validator: { type: 'custom', formula: '=REGEXMATCH(C2,"^1\\d{10}$")', allowBlank: false, errorMessage: '请填写 11 位手机号', }, }); // 给 D 列添加下拉 sheet.addDataValidation({ ranges: ['D2:D10'], validator: { type: 'list', value1: '财务内训,项目管理,沟通技巧', allowBlank: false, errorMessage: '请选择课程', }, });

这里的公式语法是示意,如果版本中的公式引擎不同,需要自己调,重点是数据校验可以按单元格范围配置。工作簿创建完成后,把这张表作为模板存到后端。后面每个人打开填报页,都从模板复制一份,避免模板数据被污染。

5.3 加载权限配置并应用

保存模板的时候,同时把可编辑区域一并存下来。前端填报页启动时,先请求模板信息和可编辑区域配置:

const { sheetData, editableRegions } = await fetch('/api/register-template/{templateId}');

拿到后,初始化 univer 并加载模板数据,然后把 editableRegions 注入前面写的拦截器,同时用高亮色标记可编辑区域。高亮这个动作别省略,用户一眼能看到自己能填哪个区域,减少点击无权单元格的次数。我曾经做过一份没有高亮的尝试,用户普遍反馈“不知道哪里该填”,加了浅绿色背景后,几乎不用再解释。

5.4 填完之后怎么把数据提交回来

用户填完,需要一个保存按钮触发数据回传。最简单的做法是:页面里遍历所有可编辑区域,逐个把单元格值拿出来组装成一个对象,然后 POST 到后端。代码示意:

const result = []; for (const region of editableRegions) { const { startRow, startColumn, endRow, endColumn } = region; for (let row = startRow; row <= endRow; row++) { for (let col = startColumn; col <= endColumn; col++) { const cell = sheet.getCell(row - 1, col - 1); if (cell && cell.v !== undefined) { result.push({ row, col, value: cell.v }); } } } } await fetch('/api/register/{id}/submit', { method: 'POST', body: JSON.stringify({ templateId, data: result }), });

需要提醒一点:getCell返回的坐标索引是 0 开始的,而页面里用户看到的是 1 开始的,写遍历时很容易差一行一列,建议在代码里统一转换。提交成功后,再把表格整体切到只读状态,防止用户重复提交。我在正式项目里还会加一个提交确认弹窗,动态展示“你填写了哪些内容”,确认后再去请求接口,整体失误率低很多。

6. 常见问题与排查实录

6.1 遇到过的典型问题速查

现象可能原因解决方法
双击单元格还是能编辑拦截命令 ID 没匹配上或过滤器写错在拦截器里先用 console.log 打印 command,确认实际 ID 和 payload
权限配置生效但样式工具栏仍能改只读单元格只拦了编辑命令,没有拦样式、合并类命令把 setRangeStyle、merge、paste 等命令一并纳入黑名单
设置只读后撤销重做失效误拦截了 undo/redo 命令在拦截器开头判断命令类型是撤销、重做,直接放行
可编辑区域高亮色会挡住文字用背景色同时调节了单元格颜色高亮建议用边框色或只设置浅色背景,同时保证字体颜色不变
复制一个可编辑格粘贴到只读格成功粘贴操作没有按目标区域校验粘贴命令 payload 里有目标 ranges,遍历 ranges 校验
表格在移动端点不动univer 默认交互模型对触屏适配有限明确要移动端时,考虑叠加触摸事件或限制使用场景

6.2 我踩过的真坑:合并单元格只判断左上角

有一次用户反馈,C 列明明锁了,但从 B 列合并过来的区域还能改。排查后发现,我只在拦截器里判断单元格的 row 和 col,而合并单元格在数据模型里可能只有左上角有值,其他区域是空壳。用户点击合并区域时,坐标可能是合并范围里的任意一点,而不一定是左上角。解决办法是先把可编辑区域和合并区域做一次坐标膨胀:如果某个坐标命中了合并区域,就把这个合并区域的完整范围当作可编辑性判断的输入,而不是只看原始坐标。这个处理对跨行合并的表头尤其重要。

6.3 另一个坑:一次性拦截所有 mutation 导致表格无法操作

早期我为了让规则彻底,直接对命令服务里所有 mutation 类型的命令都返回 false,结果打开表格后,连滚动、选中事件都不正常了。后来才明白,univer 的渲染层和交互层很多基础行为也是通过命令来触发的,不能一刀切。我建议拦截规则尽量聚焦在“修改单元格数据”“修改单元格样式”“区域合并”“粘贴填充”这几类具体命令上,其他的比如视图切换、选区变化,一律放行。简而言之:宁可漏一点,不可全拦。

6.4 排查权限不生效的通用步骤

我把排查流程固定成一套,分享给你:第一,打开浏览器控制台,在拦截器里加console.log(command),重新点击只读单元格,看有没有命令输出,没有则说明事件绑定时机有问题;第二,有命令但没止住,检查是不是 return 值没被正确识别,有的版本要调用event.preventDefault();第三,确认命令 payload 里的行和列是 0 基还是 1 基,不要用肉眼对照表格;第四,把可编辑区域临时改成全表可编辑,看是否是区域匹配逻辑写错。每次我调试完,发现至少八成问题出在行列坐标或事件 ID 上,逻辑本身往往不难。

7. 最后,给你几条能落进项目的建议

7.1 把权限控制做成独立的“区域规则”库

我把这段时间做 univer 在线表格控制功能的经验浓缩成几句话:第一,权限控制的核心模块一定要做成独立的区域规则库,不要散落在页面逻辑里。一个区域可以定义成“哪张表、哪些行、哪些列、允许什么操作”,后续加批量导入、模板复制都方便。第二,表格模板和权限配置建议分开存,模板是静态结构,权限配置是业务规则,两者独立才能让运营自己在后台调整,不用动代码。第三,前端控制体验、后端兜底安全,两者缺一不可,任何只做一端的方案都经不起真实业务考验。

7.2 一点个人体会

最后再分享一个实际体会。第一次上线这种“只填指定单元格”的表格时,我特别担心用户操作不习惯,但上线后才发现,只要高亮标示清楚,大部分用户都能顺利填写。反而最需要你反复打磨的,是那些边界操作:合并单元格、填充柄、复制粘贴、撤销重做。把这些边界搞定了,这个工具就会成为你们团队一个特别可靠的“在线填表基础设施”。如果之后再遇到类似需求,哪怕不是一个团队,你也可以大胆说:用 univer 来做,没什么大问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询