用 Univer 做一张“只允许用户填写指定单元格”的在线表格
在线表格这个赛道,过去大家第一时间想到的往往是那种单体重量级集成方案,要么绑死后端,要么前端渲染沉重。直到我实际接触了 Univer 这个开源项目,才意识到“在线表格”其实可以拆成一层非常干净的前端能力来用。Univer 是一个以 TypeScript 为核心、面向 web 场景的开源电子表格 SDK,它不要求你放弃原有系统,而是可以把完整的表格交互塞进任何页面里,同时又支持细粒度的单元格控制。我上手后第一个想到的真实需求,就是标题里说的那种典型场景:管理员定义一张表格模板,开放某些单元格给用户填写,其余区域一律只读锁定。这类需求在企业内部的数据收集、外部客户的信息申报、甚至教学场景里的评分表里都非常常见。这篇文章会围绕 Univer 的这项能力,把从选型、初始化、锁定单元格到联调后端的一整套做法讲透,适合正在做在线协作、数据采集、低代码表单的开发者参考。
1. 项目定位拆解:Univer 解决的是“表格”还是“表单”问题
1.1 核心需求解析:你需要的不是表单,是受限填写的表格
先说一个容易混淆的点。如果你只是要给用户填几个字段,那用现成的 Form 表单组件就够了,完全没必要引入一个表格引擎。但当你需要的是一张带行列结构、具备复杂表头、需要批量录入、还要能锁定大部分区域的“可填写表格”时,传统表单就力不从心了。标题里的关键词“支持用户定义表格,然后让用户去填写一些单元格,其他的单元格用户无法修改”,本质上是把表单的“字段约束”和表格的“二维编辑体验”合二为一。
这种需求在企业里大量存在。比如一张项目进度周报,固定列是任务名称、负责人、计划时间,这些内容由管理员维护,用户只需要填写“完成进度”和“风险说明”两列;再比如一张门店月度销售目标表,区域、门店名称、去年基数是固定数据,用户只能填“本月目标”和“实际完成”。如果做成表单,体验很割裂;如果放开整张表格让用户随便改,数据又会被搞乱。Univer 的 cell 权限控制恰好能把表格变成“半开放”的录入界面。
1.2 适用场景与目标用户:谁需要关注 Univer
我把适用人群分成了三类。第一类是内部系统开发者,比如行政、人事、运营系统的前端负责人,他们经常要做报表收集模块,以前用 Excel 模板发下去再回收,现在希望直接在 web 上完成在线填写;第二类是做低代码平台或 aPaaS 的技术选型者,需要开源的表格能力嵌入到自己的表单引擎里,Univer 提供了比纯表格展示库更完整的编辑模型;第三类是数据中台或 B 端产品经理,他们需要评估在线表格方案的交互边界,方便和研发对齐。
对于这三类人,Univer 的核心价值在于两点:一是开放,代码完全可控,可以按需改造;二是模型清晰,sheet、range、cell 的分层设计让锁定逻辑非常直观。我后面说的所有实操,都是围绕“如何把一张表格做成可填写模板”展开,不会涉及太虚的架构概念。
2. 核心设计思路:为什么 Univer 适合承担这类业务
2.1 选型背后的对比:自研渲染、Luckysheet 与 Univer 的差异
你可能会问,为什么不用 Luckysheet,为什么不自己用 canvas 写一个。我先说结论:如果只做展示型表格,自研确实可行;如果有轻量在线编辑需求,Luckysheet 是备选;但一旦涉及多实例协作、复杂权限、后续可持续迭代,Univer 的架构更占优势。
我早期也试过基于 Luckysheet 做二次开发,印象最深的痛点有两个:一是它整个 store 和渲染器耦合比较紧,想要单独控制某个 cell 的编辑态,需要去翻内部 API;二是官方维护节奏后期放缓,很多能力升级滞后。Univer 则把渲染层、命令系统、业务模型拆得更清晰,特别是命令(Command)这个抽象:任何用户操作都会变成一条命令,你可以在命令层面拦截和校验,这让“禁止编辑某些单元格”不再是一句口号,而是成为一条可审计的规则。
Univer 还提供了良好的 sheet 插件体系,做数据校验、单元格保护、协同编辑都能找到对应的扩展点。这类工程上的“留余地”很重要,因为业务场景总会从“锁单元格”延伸到“锁行”“锁列”“整表只读”等需求,架构能撑住才能让你少写重复代码。
2.2 细粒度权限控制背后的模型:sheet、range 与 cell 的关系
要理解 Univer 的锁单元格能力,得先理解它抽象出的三层模型。最外层是 workbook(工作簿),一个 workbook 里可以包含多个 sheet(工作表);每个 sheet 由网格组成,所有数据定位都通过 range(区域)来描述,比如 A1:B10 就是一个矩形区域;最细的粒度是 cell(单元格),承载具体值、样式和元数据。
Univer 的权限控制是落在 range 层面的,这和我以前想象的不太一样。我一开始以为应该给每个 cell 设一个 isReadonly 属性,后来发现更合理的设计是:针对某个 range 区域设置保护规则,命中的区域统一生效。这种模型做“锁定一片区域”非常顺手。你只需要描述“A 列到 C 列锁”“第 1 行到第 3 行锁”,或者精确到某个坐标范围即可。
这个分层的第二个好处是能组合出复杂逻辑。比如先锁定整张表,再单独放开某个区域;或者反过来先放开全部编辑,再锁定关键信息列。规则之间有优先级或叠加逻辑,你可以按照业务需求做配置。
2.3 交互保护与数据校验:不只是 readonly,还要引导用户正确填写
锁定单元格只是第一步。在实际业务里,用户看到一片灰色区域时,第一个反应是“这块是什么,我要不要动”。如果同时还有可填写区域,你需要通过交互提示让用户明白哪里能填、哪里不能碰。这就涉及到三类能力的配合:一是单元格保护(锁定不受编辑影响),二是高亮或标记可填写区域(视觉引导),三是数据校验(填写内容是否符合预期)。
Univer 的校验能力尤其适合表单类场景。比如某列只能填数字、某列必须是下拉选择、某列需要限制长度。这些校验可以在用户输入时实时反馈,也可以在后端二次校验。把锁定和校验结合起来,才能真正交付一个让人觉得“好用”的填写体感。
3. 实操准备:初始化 Univer 并搭建基础表格
3.1 环境准备与工程初始化
在开始写代码之前,先把基础环境配好。Univer 提供了包管理方式,pnpm 或 npm 都可以。以 npm 为例,创建一个 Vite 或 Webpack 工程后,安装核心依赖:
npm install @univerjs/core @univerjs/sheets @univerjs/sheets-ui @univerjs/ui如果你的业务里需要协同编辑,还要装@univerjs/sheets-collaboration相关包;如果不需要,可以暂缓,避免首包体积过大。这里有个经验:Univer 的包划分得比较细,按需安装是关键,前期不要一口气把官方 demo 里的所有依赖都搬进来,否则构建配置会变得很重。
初始化时,重点是一个Univer实例以及在其上注册的插件。比如SheetsUniver这个插件承担了大部分表格逻辑。用你已经熟悉的任意 UI 框架(Univer 官方 demo 常用 React,但并非必须)来容纳容器节点,然后做初始化。
3.2 创建一个带初始数据的 sheet
初始化后,关键是主动往表格里写入模板数据。你不要指望用户从零开始建表,而是应该在初始化时通过 API 预置好表头、列宽、合并单元格等。下面这段 TypeScript 展示了如何创建一张示例“销售额填报”表:
const univer = new Univer({ locale: 'zh-CN', theme: defaultTheme, }); const univerApi = univer.__getAPI(); const workbook = univerApi.createWorkbook({ id: 'sales-report', sheets: [ { id: 'sheet-01', name: '2025Q1销售填报', rowCount: 20, columnCount: 8, cellData: { 0: { 0: { v: '门店名称' }, 1: { v: '区域' }, 2: { v: '负责人' }, 3: { v: '上月实际' }, 4: { v: '本月目标' }, 5: { v: '本月实际' }, 6: { v: '达成率' }, 7: { v: '备注' }, }, 1: { 0: { v: '华东一店' }, 1: { v: '上海' }, 2: { v: '张伟' }, 3: { v: 120000 }, }, }, }, ], });这里我把表头放在第一行,固定数据放在第二行,第三行及以下留给用户填写。注意cellData的 key 是行索引,第二层 key 是列索引,v是显示值。这个结构是 Univer 底层比较重要的数据格式,后面对单元格做样式的修改,本质上都是在操作这类数据描述。
3.3 导入导出与数据绑定
模板如果只是一张空表,用户体感不好。实际项目中,管理员可能已经有一份 Excel 模板,你需要在页面提供导入解析能力。Univer 支持导入 xlsx 文件,但也依赖对应的 parse 包。我建议审视一下使用场景:如果模板结构固定,直接用 API 在代码里定义更可控;如果非要用 Excel 模板,那需要额外引入@univerjs/sheets-import-xlsx并配置跨域和文件解析。
关于数据绑定,Univer 本身不是数据层,它并不要求你把每个单元格实时同步到后端。常见做法是用户填写完成后统一读取,或者监听单元格变更事件做自动保存。后面我会专门讲事件监听的位置,因为这里容易踩坑:如果你对每次 cell change 都发请求,极端情况下会触发大量请求。
4. 锁定指定单元格:实现“其他区域不可修改”
4.1 使用保护区域的 API 快速锁定
Univer 的单元格保护逻辑,从用户角度来说就是“这块区域能不能选中、能不能编辑”。在 API 层面,最直接的方式是通过setWorkbookProtection或针对 sheet 的保护配置。这里我给出一种常见且干净的做法:先设置整张表保护,再放开指定填写区域。
const sheetApi = univerApi.getActiveSheet(); // 先保护整张表 sheetApi.setSheetProtection({ sheet: true, }); // 放开指定区域,例如第4行到第20行,第4列到第5列(D、E列) sheetApi.unprotectRange('fill-range', [ { startRow: 3, endRow: 19, startColumn: 3, endColumn: 4 }, ]);这段代码的含义是:除了 D4:E20 这个区域外,其他单元格都不可被编辑。注意坐标是从 0 开始,所以第 4 行的行索引是 3。你可能会问,为什么不用遍历每个 cell 设置 readonly,因为那样性能差,而且调整范围时要改几十处,维护成本极高。基于区域保护是正确思路。
4.2 精确控制:既锁定整张表,又开放填写区
有实际开发经验的话会发现,区域保护有两个容易被忽略的点。第一,unprotectRange需要传入一个能唯一标识区域的名称,方便后续取消保护或覆盖规则;第二,保护规则的叠加顺序也很重要。Univer 内部会合并不同保护规则,我的建议是保持最简单的模型:全局保护 + 少量白名单区域。不要搞太多重叠规则,否则排查起来非常痛苦。
还必须考虑用户编辑时,对锁定区域的视觉反馈。默认情况下,受保护区域可能没有明显标识,用户点了以后只会得到“不可编辑”的提示,缺乏引导。我建议用样式区分,比如给可填写单元格加浅黄色底纹,给锁定区域加浅灰色底纹:
const fillRangeCellStyle = { 3: { // 第四行 3: { style: { backgroundColor: '#FFF3D1' } }, 4: { style: { backgroundColor: '#FFF3D1' } }, }, };对大量单元格设置样式时,不要逐格设,而应使用setRangeStyle这类批量 API。性能差异很大,尤其当表格扩大到几千行时,逐格循环会卡到无法接受。
4.3 行、列与整表级别的只读策略
有时候业务要求更粗粒度,比如整个表只读,或前五列只读。Univer 提供了多种保护维度,不只作用于选区,也能对行列维度生效。你可以用protectRows和protectColumns来完成这类需求。
// 保护第1行到第3行(表头和固定数据) sheetApi.protectRows(0, 2); // 保护第1列到第3列(基础信息列) sheetApi.protectColumns(0, 2);这类行列级保护在做“报价单”特别有用:产品规格、单价、折扣由管理员设定,用户只能填写“数量”这一列。直接保护除数量列以外的所有行与列,效率比手动圈多个区域高很多。
4.4 锁定样式值:防止用户破坏格式结构
单元格保护默认控制的是“编辑内容”,但格式也容易被破坏。用户改不了单元格里的值,但可能通过复制粘贴、拖拽填充等操作覆盖样式。Univer 没有开放特别繁琐的格式锁 UI,但你可以借助命令拦截来实现。核心思路是监听命令,针对非白名单区域过滤掉样式变更操作。
我在实际项目里用的方法比较粗暴但有效:在onCommand钩子里,判断涉及的区域是否在白名单内,如果不是就阻止样式命令生效。下面是一个示例:
univer.registerCommandMiddleware({ onCommand: (context, next) => { const { command, unitId, subUnitId } = context; if (command.id === 'sheet.setRangeStyle') { const range = command.params.range; if (!isRangeInWhitelist(range, whitelistRanges)) { return { type: 1, error: new Error('该区域不可修改样式') }; } } return next(); }, });这个思路可以非常灵活地扩展出更复杂的规则,比如“某些区域允许填值但不允许改字体颜色”“某些区域不允许增加行”等。我在落地时体会很深:只要理解了命令流,Univer 的绝大部分行为都可以被业务策略覆写,这比依赖一堆 UI 上的隐藏配置要可靠得多。
5. 增强填写体验:数据校验与交互反馈
5.1 为可填写格配置数据校验
锁定单元格之后,用户只能填写我们开放的区域,但这不意味着用户可以随便填。我们得保证填进去的数据符合预期。Univer 支持在单元格上配置校验规则,常见的数字范围、下拉列表、日期格式都能做。
const sheetApi = univerApi.getActiveSheet(); sheetApi.addDataValidation({ range: [ { startRow: 3, endRow: 19, startColumn: 3, endColumn: 3 }, ], validator: { type: 'number', operator: 'BETWEEN', formula1: '0', formula2: '100000000', allowBlank: true, showErrorMessage: true, errorTitle: '金额超出范围', errorMessage: '请填写0到1亿之间的数字', }, });如果你做的是下拉选择,可以把type换成list,并提供formula1作为可选项列表。需要注意的是,校验规则不会阻止用户输入非法值,它更多是在单元格上打上“无效”标记,真正提交时还要后端再做唯一校验。这种“前端友好提示 + 后端强校验”的组合是最稳的。
5.2 监听编辑事件实现自动保存
Univer 提供了变更事件,你可以在用户每次修改单元格后,读取当前 sheet 的数据并发送到后端。一个合理的事件监听方式如下:
univer.getCurrentWorkbook()?.onCommandExecuted((command) => { if (command.id === 'sheet.setRangeValues' || command.id === 'sheet.setRangeStyle') { const range = command.params.range; // 判断是否涉及白名单区域 if (!isRangeInWhitelist(range, whitelistRanges)) return; // 防抖处理 debounce(() => { const snapshot = sheetApi.getSnapshot(); saveSnapshotToServer(snapshot); }, 500); } });这里有两个细节值得注意。第一,一定要做防抖,否则用户输入一个字就会触发一次请求,很浪费;第二,不要依赖“最后只读整表快照”作为唯一保存策略,因为用户在快速录入时可能丢失中间状态。更好的方案是同时监听onCellChange和表单提交按钮,前者用于草稿自动保存,后者用于正式提交。
5.3 撤销栈与协作冲突的处理原则
Univer 默认实现了撤销/重做栈。如果你只是做单机填写,撤销没问题;但如果你开了协同编辑,撤销操作可能会影响另一个用户的写入。我建议在多用户场景里,将撤销能力限制在单用户操作范围,或者干脆关闭撤销。
我踩过一次坑是:A 用户在填写区录入时,B 管理员在锁定区域调整样式,结果 A 的撤销操作把 B 的样式修改也回滚了。后来我统一改成“管理员操作不走用户命令栈”,才避免了问题。具体做法是把管理员样式修改命令标记为特殊来源,不进入用户可撤销队列。
6. 常见问题排查:锁定失效、样式冲突与性能优化
6.1 锁定区域为何还能被编辑
这是最常被问到的问题。通常有几种原因:没有真正开启 sheet 级保护;调了unprotectRange但坐标算错(行列搞反);插件注册顺序问题导致保护逻辑未生效。我的排查建议是先输出当前保护配置对象,检查是否包含自己放开区域的记录。
还有一个坑是:如果你使用setSheetProtection({ sheet: true })时传入的配置里还需要显式放开某些可执行能力,否则可能连填表区域也无法操作。Univer 的保护配置语义比较丰富,除了编辑约束,还包括插入删除行列、筛选等权限。如果只是想锁定单元格内容,把其他能力也一并放开,避免用户操作受限带来困惑。
6.2 表格卡顿与大区域数据渲染
如果模板很大,比如有几千行,Univer 在交互上通常能扛住,但如果你一次性全量更新快照会阻塞 UI。遇到这类问题,我通常用分片加载 + 虚拟渲染的思路。好消息是 Univer 渲染层本身做了可视区域渲染优化,但业务上尽量避免对不可见区域频繁操作,也不要生成超大范围 data validation。
另一个性能隐患是多次调用setRangeStyle而不是用批量 API。每调用一次都会触发 refresh 操作,汇总成一次替换或更新能明显提升流畅度。建议把节点列表先收集好,集中处理。
6.3 单元格保护与协同编辑的兼容性
协同编辑场景下,保护规则是否即时生效,受文档状态同步影响。如果你的协同引擎训练不到位,有可能出现“用户本地已经改了,但服务端保护规则还没推送”的短暂窗口。常规做法是把保护规则也纳入协同同步的指令序列,并在前端展示短暂 loading 状态。做 H5 轻量应用时,我倾向于不用协同,只保证单用户编辑锁定,避免复杂度太高。
6.4 其他常见问题速查
| 问题现象 | 可能原因 | 解决建议 |
|---|---|---|
| 表格内容能看不能选 | 整表保护且未放开选区和操作 | 配置保护时放开 selection 操作 |
| 复制的样式能贴入锁定区 | 样式命令未拦截 | 使用命令中间件过滤 |
| 数据校验不弹出错误 | 设置了校验但关闭了错误提示 | showErrorMessage: true |
| 保存后重新打开,锁定区域样式丢失 | 快照未包含样式字段 | 保存时使用getSnapshot全量数据,不要只保存 values |
| 区域放开失败 | 传入区域坐标超出 sheet 行列 | 检查行列 index 和 sheet 边界 |
这张表是我从几个真实项目里总结出的高频雷区,特别是样式丢失那条,最容易被误判成“Univer 保护失效”,其实只是保存了不完整的快照。
7. 进阶扩展:从模板填报到完整业务闭环
7.1 接后端保存与数据回显
前面说的保存都是前端快照,真实业务里一定涉及后端。我的建议是存两层数据:第一层是 sheet 全量快照(JSON),用于让 Univer 完整恢复现场;第二层是抽取结构化字段,用于后端查询、统计和审批。比如销售填报表里,除了整表快照,还要把“本月目标”“本月实际”“达成率”这些字段单独抽出来入库。
恢复现场时,用loadSnapshot或createWorkbook传入快照即可。注意回显时锁规则也要同步恢复,否则用户刷新后保护失效,问题很严重。最简单的方式是前端存一套 rule 配置,加载时重新执行unprotectRange等操作。
7.2 自定义工具栏和 UI 组件
Univer 允许你定制 UI,但我不建议从零开始搭复杂工具栏,成本不小。比较合理的做法是精简默认工具栏,只保留用户需要的按钮,比如“保存”“提交”“清除填写区”。提交按钮可以放在表格外层,不必嵌在工具栏里。
new ToolbarPlugin({ toolItems: [ 'bold', 'italic', 'color', 'save', 'submit' ], });实际项目里,“保存”和“提交”是两个不同动作:保存是草稿,提交是最终锁定。提交后应在后端更新保护规则,把填写区也锁定,这样用户就彻底不能再改了,形成管理闭环。
7.3 面向多部门、多模板的可配置设计
最后一个扩展点是可配置化。不同部门的填报需求不同,与其每次开发一套新表,不如做模板管理:模板定义包括表头结构、固定区域、填写区域、校验规则、保护规则。管理员在后台可以使用 Univer 的可编辑区域配置工具生成本地配置 JSON,然后前端渲染表格时加载对应配置。
这样一来,Univer 就成了一个“填表引擎”,而不是一张写死的表。我做出这一层后,几个项目复用成本变得很低:换行、换列、换校验仅靠配置更新即可。
我个人在体验这套方案的过程中,最大的感受是——不要试图一开始就解决所有问题。Univer 的能力纵深相当大,但只需抓住“保护 + 校验 + 快照 + 事件监听”这四个核心,就能覆盖绝大多数模板填报业务。先把一条路走通,再往协同、移动端、自定义渲染这些方向扩展,你会发展得更扎实。