news 2026/10/1 18:35:06

基于 Univer 实现受限填写在线表格:单元格锁定与命令拦截实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于 Univer 实现受限填写在线表格:单元格锁定与命令拦截实战

一张“用户自己画表,发给别人填,别人只能改该填的格子,其他单元格看得到但动不了”的需求,听起来很简单,真正落地时却让很多团队翻车。我最初也没当回事,觉得随便找个表格组件、套一层权限判断就行,直到接手项目才知道,这里面的坑比想象中深得多。

这个需求在线表格领域通常叫“受限填写”或“表单式表格”,而我们要用的方案是围绕开源的 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,你需要封装出一个简单到“给运营同事用”的组件。我把流程拆成了四个阶段:

  1. 设计阶段:用户(模板创建人)打开编辑器,自由写表头、合并单元格、设置下拉数据验证,这一阶段完整开放编辑能力。
  2. 发布阶段:系统清空所有可编辑区域的历史值,打开工作表的保护,锁定全部非白名单单元格,生成一个只读的填表链接或嵌入地址。
  3. 填写阶段:填表人通过链接打开,只能点击进入白名单区域输入内容,其他区域无法选中或编辑。
  4. 提交阶段:前端读取所有填写数据,传给后端。后端可以再次校验必填项和格式,再落库。

这套流程最关键的是第 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 这类组件解决的是“表格内核”问题,但真正的业务难点永远在边界规则上。不要指望打开保护就万事大吉,把命令拦截、样式扫描、提交校验做成一套完整的闭环,才敢放心交给用户去填。如果你们团队也在做类似的需求,可以在评论区聊聊你们遇到的怪问题,尤其是那些在纯前端表格组件上才能复现的诡异场景。

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

基于大数据的B站青少年模式使用情况数据分析系统构建指南

既然要做“基于大数据的Bilibili青少年模式使用情况的数据分析系统”这种毕设项目&#xff0c;我得先跟你说句实在话&#xff1a;这题出得挺巧的。既有技术深度可以挖&#xff0c;又有社会话题可以做文章&#xff0c;答辩的时候比较好讲。B站本身就是年轻人聚集地&#xff0c;青…

作者头像 李华
网站建设 2026/10/1 18:33:21

SpringBoot+Freemarker实现代码生成器:模板设计、数据模型与实战踩坑

说实话&#xff0c;"代码生成"这几个字在很多初学者眼里挺神秘的&#xff0c;总觉得像是某种黑魔法&#xff0c;敲个命令就能从数据库里变出一整套CRUD代码。但真正自己动手用SpringBoot搭过一遍就会发现&#xff0c;它本质上就是一件事&#xff1a;把表结构信息填进…

作者头像 李华
网站建设 2026/10/1 18:33:09

掩模黑区并不黑:铬膜复折射率、菲涅尔公式与 AttPSM 6% 的设计来历

《掩模版光学仿真与缺陷检测》专栏 第 6 讲 副标题:复折射率、菲涅尔公式与薄膜——掩模"黑区"的光学真相 前置知识:第 5 讲(平面电磁波与偏振) 预计阅读 32 分钟 上一讲结束时留了一个悬念:掩模的"黑区"根本不黑。一片 70 nm 厚的铬膜,在 266 nm 检…

作者头像 李华
网站建设 2026/10/1 18:32:04

基于JSP的影视创作论坛系统毕业设计:数据表设计与部署避坑指南

简介&#xff1a;面向JavaEE毕业设计场景&#xff0c;这份影视创作论坛系统资源完整覆盖从系统设计、开发实现到项目部署、答辩展示的全过程。资源共18个文件&#xff0c;压缩包约151MB&#xff0c;主要包含项目报告、答辩PPT、完整源代码、SQL数据库脚本、界面截图和三段部署辅…

作者头像 李华
网站建设 2026/10/1 18:31:47

复数与共轭复数全解析:从几何意义到运算技巧一次讲透

每次带学生复习复数这个专题&#xff0c;我都会先抛出一个问题&#xff1a;你会不会觉得“虚数”这个名字取得特别唬人&#xff1f;明明叫“虚”&#xff0c;却在信号处理、量子力学、电路分析里无处不在。其实换个角度想&#xff0c;复数就是一套把“旋转”和“伸缩”打包计算…

作者头像 李华
网站建设 2026/10/1 18:29:28

Compose Navigation 时序图深度拆解:背栈、生命周期与状态恢复全解析

最近在把团队里一个跑了三年的 Fragment 老项目整体切到 Jetpack Compose&#xff0c;别的都还好&#xff0c;唯独导航这块争议最大。有人说直接用原生 Navigation-Compose 就行&#xff0c;有人说要自己封装状态机&#xff0c;也有人说干脆用单一 Activity 自行管理页面状态。…

作者头像 李华