Univer在线表格如何实现指定单元格可编辑、其余锁定
先说一个被问得最多的问题怎么在网页里做一张“只能填指定格子”的表很多时候需求听起来很简单但做起来到处都是坑。直到我在项目里用了 Univer 在线表格方案这个困扰了很久的问题才算真正解决。Univer 是一个开源的、长得像 Excel 的在线表格套件你可以在里面定义好表头和公式然后精确控制哪些单元格允许用户填写、哪些单元格完全锁死动不了。这篇就把我落地这个功能的全过程、原理和踩过的坑一次性说清楚适合正在做数据填报、预算收集、审批表单这类场景的开发者。1. 为什么“只填指定单元格”这个需求会难倒一堆人1.1 一个非常典型的业务场景上个月我们公司做年度预算填报需要二十多个部门各自填写属于自己的那几行数字。表头是财务部统一做好的产品型号、成本单价、历史数据这些列绝对不能动公式列更不能被覆盖。以前的做法特别原始财务把 Excel 模板发到群里各部门填完之后再发回来然后由财务手动汇总。结果就是灾难。有人把模板里的公式删了有人把表头改了有人填错了列还有人连续发了三个版本导致财务不知道该用哪份。最离谱的是有个部门把模板里其他部门的隐藏行给调了出来顺便把别人的历史数据也改了。这个场景说白了就是一个需求我需要给每个用户“一张表”这张表里有大量内容是不可编辑的只有特定的单元格区域能填。当时第一个念头是想基于现成办公套件做二次开发调研了一圈之后发现 Univer 正好能覆盖这些能力而且它本身是开源的不用担心采购成本和数据安全边界。1.2 常见替代方案的取舍在决定用 Univer 之前我把市面上能想到的方案都列了一遍每一类都有明显的短板。方案优点痛点邮件发 Excel 模板大家熟悉成本为零版本混乱、格式破坏、汇总困难WPS / 腾讯文档协作文档在线填写体验好无法精细控制单元格编辑权限而且商业授权费用不低纯前端表格组件AG Grid 等嵌入灵活公式能力弱、交互不像 Excel、保护逻辑需要自己造轮子自研 Canvas 表格完全可控工作量巨大公式、样式、协同都要重新发明Univer 开源套件Excel 级能力、开源可控、保护机制完善需要学习其插件与命令体系社区资料相对分散我们最终选择 Univer 的原因有三个。第一它本身就是一套完整的表格引擎公式、样式、条件格式、合并单元格这些都开箱即用不需要我从零实现。第二它的架构是插件化的所有操作都走命令系统这意味着我可以对“用户能不能编辑这个单元格”做精确干预。第三开放源码之后遇到特殊需求可以直接改底层行为而不是被商业产品的能力边界卡住。2. Univer到底是什么一套真正“长得像Excel”的开源基础设施2.1 它不是一个简单表格控件而是一个文档套件很多人第一次看到 Univer 会把它当成普通的表格组件但它的定位其实大得多。Univer 本身是跨端协作的办公文档套件包含表格 Sheet、文档 Doc、幻灯片 Slide 三大部分我这次只用到表格部分。表格部分的核心能力覆盖了绝大多数办公场景多工作表管理、公式引擎、条件格式、数据透视表、图表、批量样式编辑、协同编辑、撤销重做等。比起只提供一个canvas和一堆配置项的表格控件Univer 更像是一个“运行在浏览器里的 Excel”它的数据模型和交互模型都是按桌面办公软件的标准去做的。2.2 为什么用Canvas渲染而不是DOM表格这里有一个很重要的设计选择Univer 的表格渲染是基于 Canvas 的。传统 DOM 表格在处理上万行数据时光创建节点就能把页面卡死而 Canvas 把所有格子画在一个画布上渲染性能跟单元格数量不直接挂钩。实际使用中我加载了几千行的模板滚动和编辑都还是很流畅。当然 Canvas 的代价是每个单元格不是一个独立的 DOM 节点所以普通的前端手段——比如要对某个格子做 DOM 事件绑定、悬停提示、富文本注入——不能直接用。Univer 的做法是提供一套基于坐标的事件系统比如onCellClick、onCellEdited、onSelectionChange开发者只需要订阅这些事件就能拿到对应的行列坐标和单元格对象。2.3 插件化架构与命令系统是权限控制的地基Univer 里几乎所有的用户操作都是通过命令Command来执行的比如修改单元格内容、插入行列、合并单元格、复制粘贴。这个设计对我来说特别重要因为“不让你编辑某些区域”本质上就是“拦截/禁止对应命令在特定区域生效”。插件化还带来一个好处你可以在不修改核心源码的情况下向 Univer 注册自己的插件去监听命令的执行前后或者在菜单里增加自定义按钮。这个架构让我们能把“单元格保护”和“业务校验”都做成独立的插件层后续扩展业务逻辑的时候不会污染表格引擎的核心代码。3. 核心功能拆解让指定单元格可编辑、其余全部锁定3.1 先理解Excel的“保护工作表”模型做过 Excel 保护功能的人都知道Excel 的单元格可编辑性是由两个条件共同决定的。整个工作表是否开启了“保护工作表”开关。每个单元格自身的锁定期属性Locked是什么状态。只有工作表保护已开启并且目标单元格的 Locked 属性为 true这个单元格才不可编辑。如果防护开了但某个单元格的 Locked 属性是 false那么这个格子依然可以填写。如果工作表根本没有开保护Locked 属性再怎么样也不会生效。Univer 沿用了同一个模型这是一件好事意味着我们不需要想一套新的权限理论直接把 Excel 时代用户已经熟悉的逻辑搬过来即可。3.2 在Univer里开启保护并批量解锁指定区域在我实际使用的版本里开启工作表保护走的是设置工作表保护配置的命令。下面是一段最核心的示意代码import { Univer } from univerjs/presets; import { SetWorksheetProtectionCommand } from univerjs/sheets; async function enableProtection(univer: Univer, sheetId: string) { const commandService univer.getCommandService(); await commandService.executeCommand(SetWorksheetProtectionCommand.id, { unitId: univer.getActiveWorkbook()!.getUnitId(), subUnitId: sheetId, protection: { sheet: true, selectLockedCells: false, selectUnlockedCells: true, allow: { sort: false, autoFilter: false, pivotTable: false, editObjects: false, }, }, }); }开启保护之后默认所有格子都是不可编辑的。然后我需要把“允许填写的区域”的锁定属性手动关掉。批量设置单元格样式的代码如下import { SetRangeStyleCommand } from univerjs/sheets; async function unlockRanges(univer: Univer, ranges: Array{ startRow: number; startColumn: number; endRow: number; endColumn: number }) { const commandService univer.getCommandService(); for (const range of ranges) { await commandService.executeCommand(SetRangeStyleCommand.id, { unitId: univer.getActiveWorkbook()!.getUnitId(), subUnitId: univer.getActiveWorkbook()!.getActiveSheet()!.getSheetId(), range: { startRow: range.startRow, startColumn: range.startColumn, endRow: range.endRow, endColumn: range.endColumn, }, value: { protection: { locked: false }, }, }); } }注意解锁操作要把范围一次性传入不要写一个for循环去逐格设置样式。最开始我图省事用了双层循环一个单元格一个单元格去执行命令结果单元格稍微多一点页面就肉眼可见地卡顿后来改成按区域批量提交才恢复正常。提示不同的 Univer 版本命令常量名可能会有细微差异写代码的时候以你自己安装版本的类型声明为准。我这里给的是当前版本里可用的调用形态核心思路是一样的。3.3 动态权限让不同用户看到不同的可编辑区域实际业务里很难只做“整张表统一解锁几个区域”通常每个用户的可编辑区域都不一样。比如预算模板里市场部只能填 C7:E7销售部只能填 C8:E8财务部填 F列汇总区域。Univer 本身只管“锁与不锁”不管“谁能锁谁能填”。我的做法是把权限判断放在服务端前端登录后请求一个接口返回当前用户在当前模板上的可编辑区域列表{ templateId: budget-2025-Q1, editableRanges: [ { startRow: 6, startColumn: 2, endRow: 6, endColumn: 4 }, { startRow: 7, startColumn: 2, endRow: 7, endColumn: 4 } ], rowCount: 50, columnCount: 10 }前端拿到数据之后再调用上面那个unlockRanges函数把返回的区域依次解锁。这样一来同一个模板文件在不同用户眼里就是完全不同的填写边界。这里必须多说一句前端只做体验层面的限制数据安全性不能完全依赖前端的锁。用户按 F12 打开控制台完全可以绕过页面的限制强行改样式所以服务端在接收提交数据时一定要再做一次权限校验只接受本用户应当填写的区域数据其他区域的值一律丢弃或直接拒绝。3.4 编辑交互上的细节优化光有保护还不够用户被挡住之后需要知道为什么。我在集成里做了这么几个优化用onCellClick事件监听用户点击如果目标单元格是锁定状态就弹出一个轻量提示“该单元格为模板数据不允许修改”。被锁定的单元格区域在视觉上用浅底色区分让用户一眼看出哪些能填哪些不能填。给可编辑区域加上tooltip或者批注比如“单位万元”减少填错率。这些交互看起来是小事情但实际在业务方验收时反馈体验好不好往往就取决于这些细节。4. 用Univer搭一个真实可用的在线填表应用4.1 初始化项目并接入Univer整个应用我选了 Vite React TypeScript 的组合。接入 Univer 的方式很直接用官方预设包即可相关样式文件也要引入。npm install univerjs/presets univerjs/presets-sheet然后初始化一个 Univer 实例import { Univer } from univerjs/presets; import { UniverSheetPreset } from univerjs/presets-sheet; import univerjs/presets/lib/styles.css; const univer new Univer({ presets: [ UniverSheetPreset(), ], });初始化之后通过univer.createUnit创建表格单元并把容器挂到页面节点上。如果你用的是框架组件Univer 官方也提供了对应的适配组件用法类似。4.2 模板管理后台建表前端加载填表应用有一个必须解决的问题模板谁来建修改谁负责我的方案是把 Univer 同时用在两个地方。管理端是一个内部后台管理员用 Univer 打开编辑页面画好表头、设置好公式、填好下拉选项和单位然后点击“发布模板”。此时前端把整个表格快照序列化保存到服务端包括单元格的值、样式、合并信息、公式等。用户端就是真正填报的页面这个页面只做两件事。第一从服务端拉取模板快照并渲染第二根据当前用户的权限区域解锁指定单元格。管理员在后台改一次模板所有用户端下次打开就是新版再也不用在微信群里发“最终版/最终版2/最终版3”了。4.3 数据提交读取可编辑区域的值送回服务端用户填完之后提交动作不是直接把整个表格数据抛给后端——那样会提交一大堆无意义的模板数据。正确做法是只读取当前用户可编辑区域内的单元格值组装成一个数据包function collectEditableData(univer: Univer, ranges: Range[]) { const workbook univer.getActiveWorkbook()!; const sheet workbook.getActiveSheet()!; const payload: Recordstring, string | number | boolean | null {}; for (const range of ranges) { for (let row range.startRow; row range.endRow; row) { for (let col range.startColumn; col range.endColumn; col) { const key ${row}:${col}; payload[key] sheet.getCellValue(row, col) ?? null; } } } return payload; }服务端收到这个包之后先校验key是否都在该用户允许的范围内再校验数据类型、必填项、数值上限等规则。校验通过后再写入数据库同时把这条填报记录的版本号加一。注意不要只依赖前端读数据后端必须用自己的模板元数据重新生成一份“允许区域”集合然后逐一比对。否则用户完全可以构造一个包含其他单元格 key 的请求把不该提交的数据混进来。4.4 状态切换待填写、已提交、被退回填报流程通常有多个状态我在 Univer 之上做了一个很轻的状态机。待填写状态渲染模板解锁允许区域用户可以自由编辑。 已提交状态表格整体进入完全只读状态实际上就是关闭解锁的全部区域的锁定属性重新开启保护用户看到的是提交后的快照不能再改。 被退回状态后台管理员驳回之后用户端重新打开表格只解锁之前填过的区域同时追加一个“驳回原因”批注单元格。这个状态切换本质上就是在反复执行“设置保护”和“批量解锁”两个动作。由于 Univer 的表格数据模型和样式是分离的切换状态不会把用户填的数据弄丢这是它作为填表基础设施很舒服的一点。5. 落地过程中最值得记录的坑和调优5.1 撤销重做会踩到保护逻辑第一个坑就出现在撤销重做上。用户填完 C7 的金额后不小心按了 CtrlZ结果不仅撤销了填写内容连之前设置的保护配置也被撤销了一部分。原因是保护状态本身也是通过命令执行的撤销命令会把保护命令一起回滚。我的处理方案是在关键节点做强制态刷新在用户完成“提交”操作之后立刻重新执行一次完整的保护配置并且重置撤销栈。另外我还开了保护之后把撤销快捷键在某些场景下禁用。顶层的表格工具栏虽然还在但命令服务里对撤销和重做命令做了过滤只在填报状态下允许撤销。5.2 合并单元格、行列插入与保护的联动第二个坑来自合并单元格。模板里经常有“产品说明”这种跨两行合并的单元格如果用户填写的区域恰好与合并单元格重叠按单格解锁的写法会出现锁住了合并区域的一部分、但另一部分却可编辑的状况视觉表现特别怪。解决办法是提前在模板配置阶段把合并单元格纳入权限元数据处理可编辑区域时先做合并单元格集合运算如果解锁区域命中了某个合并单元格就直接解锁整个合并范围而不是只解锁单个坐标格子。同样的道理也适用于行列插入。如果开启了允许普通用户插入行那么原来设计的行列坐标会整体下移导致权限区域错位。我的填表场景里直接禁止普通用户做插入行列、删除行列操作这类操作全部合并到管理员后台的模板编辑流程里。5.3 大量样式指令会让首屏渲染明显变慢刚才提到批量解锁不可逐格操作这里再展开说一个性能教训。有一次我负责的模板有 30 多列、80 多行解锁区域有 40 多个我图省事把解锁命令拆成了 40 条逐个执行。结果是加载模板后用户看到表格出现一种“渐次解锁”的视觉效果部分格子还闪了一下才变成可编辑状态。后来我把同一次解锁操作合并成一个批量命令一次性提交整个ranges数组再配合服务端把“解锁区域”按最小单元格坐标集去重整个加载过程从可见渲染变成了瞬间完成。经验法则就是Univer 的每个命令都有一定开销样式类操作尽量批量提交能用范围绝不用单格。5.4 协同编辑开启后保护策略的变化最后一个要提醒的是协同编辑。如果只是我一个人改表格保护逻辑很简单。但一旦开启多人协同问题就来了两个人同时在编辑同一张表A 的权限区域和 B 的权限区域是动态的保护状态需要被所有客户端实时同步。我的做法是不直接使用 Univer 自带的协同能力而是在应用层做同步。每人打开表格时先拉一次最新的模板快照和后端权限配置所有修改都通过后端保存。填表场景里用户之间编辑同一个格子的概率极低因为权限区域本身已经隔离了应用层同步足够可靠比盲目叠加协同编辑协议要简单得多。最后再分享一点经验如果让我给后来者一个总结性的建议那就是把 Univer 当成“数据收集基础设施”来用而不是简单地当做一个表格页面。填表这个需求里大多数工作其实不在表格本身而在模板建模、权限下放、数据回传和状态流转上。Univer 负责把最难的一部分——表格渲染、公式计算、样式管理和单元格保护——处理得足够专业我们只需要在外面套一层业务逻辑。实际上线之后财务再也不用收那么多“最终版”文件了各部门打开链接就能看到自己该填的区域其他区域一眼就知道动不了。这种从根源上消除混乱的体验比任何事后校验工具都管用。如果你也正在被“自定义填表”这个需求折磨我建议先从 Univer 官方仓库的 demo 入手把工作表保护命令跑通再套上你自己的权限元数据。照着这个路径走一两天就能见到一个能交付的原型。后面等遇到上面这些坑了再用我今天说的这些经验去对号入座会替你省下不少排查时间。