一张“用户自己画表,发给别人填,别人只能改该填的格子,其他单元格看得到但动不了”的需求,听起来很简单,真正落地时却让很多团队翻车。我最初也没当回事,觉得随便找个表格组件、套一层权限判断就行,直到接手项目才知道,这里面的坑比想象中深得多。
这个需求在线表格领域通常叫“受限填写”或“表单式表格”,而我们要用的方案是围绕开源的 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 这类组件解决的是“表格内核”问题,但真正的业务难点永远在边界规则上。不要指望打开保护就万事大吉,把命令拦截、样式扫描、提交校验做成一套完整的闭环,才敢放心交给用户去填。如果你们团队也在做类似的需求,可以在评论区聊聊你们遇到的怪问题,尤其是那些在纯前端表格组件上才能复现的诡异场景。