
一张“用户自己画表发给别人填别人只能改该填的格子其他单元格看得到但动不了”的需求听起来很简单真正落地时却让很多团队翻车。我最初也没当回事觉得随便找个表格组件、套一层权限判断就行直到接手项目才知道这里面的坑比想象中深得多。这个需求在线表格领域通常叫“受限填写”或“表单式表格”而我们要用的方案是围绕开源的 Univer 组件来做的。Univer 是一套用 TypeScript 写的在线办公套件支持表格、文档、幻灯片最核心的特点是把渲染层和业务状态彻底分离所有编辑动作都走命令通道这让“单元格锁死、指定区域放行”这类权限控制有了很好的发挥空间。这篇文章会把我在实际项目里的实现过程、权限模型理解、封装思路和踩坑记录完整写出来适合正在选型在线表格组件、或者想用 Univer 做填写模板、数据收集场景的团队参考。1. 为什么是 Univer一张“只能改指定格”的在线表比想象中难做1.1 表面是权限问题实则是四件事“让用户定义表格然后让用户填写只能改的单元格”这句话拆开来看至少有四个独立问题一是用户要能在网页里自由画表格、设置表头、调整列宽行高二是发布后要保护绝大部分单元格只允许填写少数白名单格子三是填表人要有一个低门槛的编辑体验不能把 Excel 的复杂度全端上来四是填完的数据要能被收集、导出、汇入后端。只盯着“权限控制”去选型后面三个问题很容易被忽略。这也是为什么很多团队拿 Excel 导入导出方案或者纯 DOM 表格方案做这类需求做到最后会发现功能全堆在界面上底层的单元格定位、公式计算、范围选择全都要自己从零实现。1.2 我拉出来对比过的几条路这个需求真正跑起来之前我花了一周做了一个简单的选型验证把常见路线都过了一遍。第一条线是纯 HTML 表格加contenteditable或者直接上 VTable、AG Grid 这类数据表格组件。这条线对“展示数据”来说很快但一旦用户要自定义表头、合并单元格、跨表格引用、拖拽填充基本上是无解。AG Grid 的 editable 配置是行级的做不出 Excel 那种单元格级、区域级的精细权限。而且Excel 用户习惯的 Tab 跳格、Enter 下移、区域选择全都要自己模拟。第二条线是 SheetJS也就是 xlsx 这个库做读写配合一个简单的界面。SheetJS 本质是文件解析和生成库不负责交互想实现“用户画表 填写”等于只是拿了个文件解析器其他还是要自己造界面最后会陷入上图那种“界面越来越重、代码越来越乱”的境地。第三条线是 Luckysheet。界面是仿 Excel 的开箱即用体验不错但问题出在架构上它的数据模型和渲染层耦合得比较紧公式引擎在高负载场景下容易吃力权限控制想落地到单元格级需要硬改它内部的数据结构。我这边做了一个小 Demo 之后发现每次想加一个业务规则都要深入源码改状态更新逻辑风险很高。第四条线是 OnlyOffice 这种全家桶。功能确实全但它是面向文档服务器的重型产品部署链路长前端的二次开发更像是在改一个大型成熟桌面软件的网页版跟我们要做的轻量填表场景不匹配。最终留下来的是 Univer。Univer 的名字在圈子里这几年越来越常见它是把渲染、公式、命令、协同拆成可插拔模块的在线表格引擎。从开发者角度看它的定位介于“SkillSheet 到 Luckysheet 到完整 Office”之间比纯解析库更完整比大型 Office 套件更轻而每天开发时最关键的一点是它的所有写操作几乎都要过命令系统这给权限拦截留了天然的口子。1.3 核心判断命令通道决定了权限控制的上限这里多说一句为什么“所有操作走命令通道”对受限填写这么重要。Excel 这类桌面软件把用户操作变成命令再去改数据模型网页表格如果直接操作 DOM、直接改 state权限拦截就很难做到“时时有效”。Univer 的架构粗看就是用户操作触发 UI 事件 - 命令派发到 CommandService - 对应 Handler 修改底层数据 - 渲染层根据响应式数据刷新画布。既然统一走命令我们就可以在命令派发前后插入“检查”和“拦截”这是它的插件机制和权限设计能落地的根本前提。我把选型对比整理成一张表方便你们直接抄作业方案在线交互编辑单元格级权限二次开发成本公式/协同扩展维护状态纯 HTML 表格 / AG Grid 类一般很难做到区域级想要的功能都要自己造几乎没有看团队SheetJS 自研界面需全部自研需自研权限层高等于从零做表格低主动维护Luckysheet较好要硬改内部逻辑中高公式尚可、协同有限已停止部分活跃OnlyOffice完整强但面向办公场景偏重高且部署重完整但重活跃Univer好有基础模型可二次定制中低公式/协同都有活跃开源对做 B 端产品、SaaS 表单工具的团队来说Univer 至少在“起点”上是更合适的它不是让你从零写一个表格而是给你一套稳定的内核业务规则交给你自己封装。2. 读懂 Univer 的保护权限模型整表锁死按区域放行2.1 这个模型其实和 Excel 是同源的如果你用过 Excel 的“保护工作表”功能应该很清楚它是什么逻辑先把整张工作表设置成保护状态然后单元格默认就是锁定不可编辑的如果希望某些区域还能编辑就把这些区域的单元格锁定属性关掉。Univer 在权限模型上借鉴了同样的设计思路。这个模型的核心有两个概念一个是“工作表是否开启保护”一个是“每个单元格样式的 locked 属性”。单元格的 locked 状态只有在“工作表被保护”的前提下才生效。所以我们的实现思路就是发布时把整个工作簿的保护打开同时把允许填写的区域标记成 unlocked其他区域保持 locked。填表人进来后Univer 的 UI 会默认阻止对锁定单元格的输入。有个地方要注意Univer 作为开源组件权限 API 在不同版本之间变化很快。我这个项目验证是在 0.x 中后期版本上跑的代码结构整体稳定但具体命令名、构造参数在不同 release 下可能会有差异。下面的示例代码表达的是实现思路你们落地时以自己锁定的依赖版本为准。2.2 用样式标记“可编辑区”在 Univer 中一个工作簿的数据结构里单元格的值、样式、合并关系都放在 workbookData 的 sheets 对象中。样式中有一个protection字段里面的locked显式控制该单元格在保护状态下是否可编辑。看一段模板初始化的示例结构// 这里的结构基于 Univer 0.x 的 IWorkbookData实际字段名可能随版本略有差异 const workbookData { id: template-workbook, name: 报销填写表, sheetOrder: [sheet-001], sheets: { sheet-001: { id: sheet-001, name: Sheet1, rowCount: 20, columnCount: 8, cellData: { 0: { 0: { v: 部门, s: headerStyle }, 1: { v: , s: editableStyle }, }, 1: { 0: { v: 姓名, s: headerStyle }, 1: { v: , s: editableStyle }, }, // 其他单元格默认不带样式锁定即为 true }, styles: { headerStyle: { protection: { locked: true }, bg: #f5f5f5, bl: 1, ht: 2, // 字体、对齐、边框等你需要的样式 }, editableStyle: { protection: { locked: false }, bg: #ffffff, border: { b: { s: 1, c: #cccccc }, l: { s: 1, c: #cccccc }, r: { s: 1, c: #cccccc }, t: { s: 1, c: #cccccc }, }, }, }, }, }, };重点是样式里的protection.locked字段。我在封装阶段把“用户自己画的模板”中所有表头、说明、公式列都统一加上locked: true把允许填写的那几列或几个格子显式标成locked: false。这样发布后填表入口就会天然区分“能碰”和“不能碰”的区域。2.3 发布时开启工作表保护保护工作表在 Univer UI 上可以通过右键工作表标签或者工具栏找到入口底层会发起一个保护命令。在我们自己的产品里不会让每个模板发起去点右键而是要封装成一个“发布”按钮自动调用保护接口。这里提供一个伪代码版本的发布逻辑async function publishTemplate(univer, sheetId) { // 1. 先清空所有可编辑区域的历史填写数据让模板回到空白状态 resetEditableCells(); // 2. 开启当前工作表的保护 // 在 0.x 版本中保护命令可以从 univerjs/sheets 的 export 里找 await univer.commandService.executeCommand( protectSheetCommand.id, { sheetId, protection: { // 这里根据你的业务选择是否允许选中锁定单元格、是否能复制等 selectLockedCells: false, selectUnlockedCells: true, }, } ); // 3. 再确认一遍所有 editable 样式仍然可编辑 // 这一步是双保险防止用户自定义模板时不小心把样式覆盖了 const editableStyles collectEditableStyleIds(); // 后续可以基于 editableStyles 扫描单元格输出白名单坐标 }值得提醒的是Univer 中“保护工作表 单元格锁定”这套机制更多是提供 Excel 风格的基础能力。它本身不一定提供多用户到单元格的高维权限比如张三只能填 A1:B5李四只能填 C1:D5。要实现这种效果还需要在命令层加一层自定义校验这也是下一节要展开的。2.4 这个模型的边界在哪如果你们的需求只是“整张表大部分锁定某几个格子可以填”那直接靠保护机制就够了。但如果要更细的“不同人看到不同可填区域”“某区域填完自动锁定”原生机制满足不了。我当时的处理方式是把 Univer 的保护机制当成“最底层的物理防线”负责挡住绝大多数的常规编辑操作然后把业务层的权限规则放在命令通道里做“逻辑防线”负责按登录用户、角色、填写状态再过滤一遍。物理防线让用户顺手、逻辑防线让业务可控这才能覆盖住真实场景。3. 封装受限填写表单设计模板、发布锁定、提交回收3.1 整体流程拆解真正的项目里不能让业务人员直接接触 Univer 的底层 API你需要封装出一个简单到“给运营同事用”的组件。我把流程拆成了四个阶段设计阶段用户模板创建人打开编辑器自由写表头、合并单元格、设置下拉数据验证这一阶段完整开放编辑能力。发布阶段系统清空所有可编辑区域的历史值打开工作表的保护锁定全部非白名单单元格生成一个只读的填表链接或嵌入地址。填写阶段填表人通过链接打开只能点击进入白名单区域输入内容其他区域无法选中或编辑。提交阶段前端读取所有填写数据传给后端。后端可以再次校验必填项和格式再落库。这套流程最关键的是第 2 阶段和第 3 阶段的转换。很多团队做到一半会发现编辑器模式一切正常但点完“发布”后用户还是能通过某些按钮或快捷键改掉锁定区域原因就是只做了“样式锁定”没有做“命令拦截”。3.2 设计模式与发布模式的切换实际落地时我建议不要把“设计模板”和“填写内容”放在同一个 Univer 实例上跑而是用两份初始化参数区分模式。在设计模式下Univer 正常打开工具栏全部可用保护不开启。当用户点击“发布”你把这个模板对应的 workbookData 存到后端再基于这一份数据生成一个新的“填写实例”。填写实例的初始化参数在渲染时禁止展示工具栏、禁止右键菜单里的“插入行/删除行/合并单元格”同时开启保护。这样做的另一个好处是填写模式不会破坏模板本身。填表人无论怎么折腾都不会把模板的公式、表头、合并信息改掉。后端存下来的模板始终是干净的。初始化填写实例时我用了一个很朴素的方式隐藏非必要 UI在注册 UniverSheetsUIPlugin 时通过menu的配置或toolbar配置把不需要的菜单项剔除。不同版本对菜单配置的写法不一样但思路一致填写模式下只保留“显示格式化、恢复上一步”这非常有限的操作其他一律不进 UI。3.3 在命令通道做第二道闸前面说过Univer 的写操作基本都会走 CommandService这是我们做业务权限拦截的主战场。虽然保护机制能挡住 UI 上的点击操作但业务上还要防几类情况填表人通过浏览器控制台直接调用命令、复制粘贴触发批量写入、或者某些操作路径绕过了 UI 保护。这里给一个框架级的拦截示例表达思路而不是某一个版本的精确 API// 这一段是思路框架命令 id 请根据你项目锁定的 univerjs/sheets 版本确认 const WRITE_COMMANDS [ sheet.command.set-range-values, sheet.command.set-range-style, sheet.command.insert-row, sheet.command.remove-row, ]; function setupFillGuard(univer, allowedRanges) { univer.commandService.beforeCommandExecute((command) { if (!WRITE_COMMANDS.includes(command.id)) return; const params command.params || {}; // 从 params 中解析出本次修改涉及的 range行、列范围 // 然后判断这个 range 是否完全落在 allowedRanges 之内 // 如果越界弹窗提示并阻止命令执行如果版本不支持阻止就执行后回滚 }); }这种做法的判断规则很简单允许编辑的单元格白名单是发布时根据locked: false的样式自动扫描出来的不是写死的坐标。因为模板是用户自己画的表头可能三行也可能五行列也可能中途调整只有发布那一刻动态扫描出来的白名单才是准的。具体扫描方法也不复杂遍历 sheet 里所有cellData看每个格子引用的样式如果样式中protection.locked false就把这个坐标收进白名单再用 Univer 的 Range 服务把稀疏坐标合并成连续区域减少判断次数。3.4 读取填写结果并回写后端提交那一步需要把填表人编辑过的内容从 Univer 里读出来。实现上可以遍历 sheet 的所有单元格读取值也可以只遍历白名单区域。组件内部封装一个collectDraft()方法function collectDraft(univer, sheetId) { const workbook univer.getCurrentWorkbook(); // 具体方法名以版本为准 const worksheet workbook.getActiveSheet(); const matrix []; const rowCount worksheet.getRowCount(); const colCount worksheet.getColumnCount(); for (let r 0; r rowCount; r) { matrix[r] []; for (let c 0; c colCount; c) { // getCellRaw 在部分版本里返回包含 v / s / m 的对象 const cell worksheet.getCellRaw(r, c); matrix[r][c] cell ? cell.v : null; } } return matrix; }拿到矩阵后后端可以按模板配置逐个字段校验也可以直接把 JSON 存库方便做二次分析。如果院方需要导出 Excel再把这份数据套到 SheetJS 上生成 .xlsx 文件就行这一步和 Univer 解耦。这里有个小细节清空可编辑区域的时候别把模板里 predefine 的默认值也清掉。比如一张排班表某些格子预置了“休”这些也是用户设计模板时故意填的不是历史填写数据。我的做法是在设计模式保存模板时记录每个 editable 单元格的“模板默认值”发布时只清掉与模板默认值不同的部分。4. 实战踩过的大坑撤销、粘贴、公式与协同4.1 撤销栈会把“已发布”的表打回原形第一个让我半夜爬起来看日志的坑是撤销栈。流程是这样的设计模式下用户一直在编辑表格内容操作全部进了撤销历史里。然后点“发布”开启保护此时如果用户或者我们自己的代码不小心触发了一次 undo整个工作表可能直接回退到 5 分钟前的状态保护设置被回退掉刚清空的填写区域又冒出来一堆测试数据。解决办法是在发布动作执行前把当前 workbook 的历史记录清掉。Univer 的命令系统底层有历史栈我在发布封装里会主动清空跟数据修改相关的历史或者干脆在发布后的填写实例里禁用 undo 对结构性命令的回退。这一步不做后面所有保护都形同虚设。4.2 复制粘贴和拖拽填充是绕过保护的头号通道第二个坑比第一个更隐蔽单元格的 locked 属性虽然能挡住光标点击编辑但复制粘贴往往不读 locked 属性。填表人从 Excel 里复制一整行数据然后在 Univer 里选中一个可编辑格子直接粘贴某些版本会把这一行的数据批量写到相邻连续区域。如果读取到的相邻单元格是锁定的保护机制理论上要挡住但实测在批量粘贴的场景下校验逻辑会出现“边界未闭合”的问题比如只检查了首个单元格后续单元格越权了却没拦住。我们的对策是双管齐下一方面在 UI 上禁用外部粘贴这个入口或者对粘贴事件做预处理先校验剪切板内容要写入的所有目标坐标是否都落在白名单里另一方面在命令通道里对SetRangeValuesCommand这类批量写入命令做全范围校验不通过就直接丢弃。拖拽填充也是同理。填表人按住单元格右下角往下拖Univer 会触发填充命令如果填充的目标区包含锁定单元格保护机制不一定逐格检查。所以填充相关的命令同样要纳入拦截清单。4.3 公式区域的锁定与刷新问题第三个坑来自公式。模板里经常会有合计行、状态列这些格子的公式是设计者写好的普通用户不该去动但公式又必须根据填写结果重新计算。如果简单地把所有公式格都标成locked: true保护开启后公式确实不会被手改但有个隐含问题受保护工作表环境下某些版本默认会禁止一切对锁定区域的写入包括公式引擎自己更新缓存值。结果就是用户填了数字合计列迟迟不刷新。排查下来发现保护功能一般会有类似 Excel 的“允许计算”开关需要显式开启让公式引擎在锁定区域内部刷新缓存又不允许用户手动输入。这个配置选项在 UI 上通常藏得比较深代码里要用到的是保护命令的可选参数。建议做模板时先跑一个最小 Demo 验证公式刷新确认没问题再接入业务。另外建议单独把公式单元格和普通锁定单元格用不同的样式类区分因为后续你可能要做“公式格统一校验”“公式格不可清空”这类规则没有样式区分就只能逐个坐标硬编码维护起来很痛苦。4.4 协同模式和受限填写天然不对付如果你们还有多人同时编辑同一张填报表的需求要提前想清楚Univer 支持协同编辑但“受限填写”这种业务对协同是有限制的。原因很简单协同的本质是多人并发改同一份文档权限校验要在每次 op 合并时执行。两个填表人同时操作一个在白名单里写数据、一个在名单外试图改表头协同服务端必须保证越权操作被拒绝且不回滚掉合法操作。这在多人同时对同一张表操作时会带来不少冲突。我的建议是填写阶段的表单默认关闭协同。如果一定要协同就按“格子粒度”做乐观锁把白名单区域分配给不同的填表人互不重叠才能把冲突降到最低。4.5 API 版本漂移如何管理接入工程最后说一下接入工程层面的坑。Univer 的版本迭代很猛从 0.1 到 0.x 再到 1.0 系列API 变动频繁。我第一次接入时照着 0.1 的 demo 写一个星期后升级了包所有初始化代码全部报错。后来我把 Univer 封装成了一个内部组件暴露四个稳定的业务方法designTemplate()、publishTemplate()、fillForm()、collectDraft()。内部无论 API 怎么变上层业务的调用接口不动这样再做版本升级时需要改的地方被收敛到了一个文件里。还有一个小建议给 Univer 组件写系统性的自动测试尤其是“越权写入被拒”“粘贴越权被拒”“撤销后保护仍在”这三条用例。这类表单场景是高频使用功能每次升级依赖后跑一遍测试能提前暴露很多隐藏问题。我个人的体会是Univer 这类组件解决的是“表格内核”问题但真正的业务难点永远在边界规则上。不要指望打开保护就万事大吉把命令拦截、样式扫描、提交校验做成一套完整的闭环才敢放心交给用户去填。如果你们团队也在做类似的需求可以在评论区聊聊你们遇到的怪问题尤其是那些在纯前端表格组件上才能复现的诡异场景。