最近在给团队做一个数据收集表,需求听起来简单:管理员先画好表格结构、固定表头和公式,再分发给所有人填写;每个人只能编辑分配到的几个单元格,其他单元格一律不可修改。一开始我想用现成的在线表格工具解决,但数据回收、格式控制、权限粒度全都差点意思。
后来我把目光放到开源项目 Univer 上——一个基于 Canvas 渲染的现代开源电子表格框架,支持在线编辑、插件化扩展,而且恰好具备工作表保护和单元格锁定能力。这篇文章就是我把它做成一个“多人填报系统”的完整记录,从选型、初始化、单元格权限控制,到踩坑经验和数据回收,都尽量讲透。如果你也在做类似的在线收集表、报销单、调研问卷,这篇应该能帮你省不少弯路。
1. 从“Excel 发来发去”到“在线填表”:为什么选 Univer
1.1 填表场景的真实痛点
先说说我遇到的实际情况。团队每个月要收集一次项目进度,以前的做法是行政在 Excel 里做好模板,发到群里,大家下载下来填,再发回来。这个流程的问题很大:
- 格式横飞。有人改了表头,有人删了公式,有人把别人负责的列也顺手改了。
- 汇总麻烦。收回来几十个文件,要用脚本合并、清洗、对账,每次至少折腾半天。
- 权限控制完全没有。只要把文件发出去,内容就像泼出去的水,想收回也来不及。
后来换成腾讯文档、金山文档这类在线协作表格,协作是好了一点,但又冒出新的问题:模板的开放性太强了,填表的人能看到所有数据,还能随意修改整个工作表的结构。我想要的其实是“每个人拿到一张定制的表,只能填自己的格子”,这跟在线文档那种“广场式协作”完全是两种需求。
1.2 开源方案的对比
既然现成工具不合适,我就开始找开源方案,前后对比了几个:
| 方案 | 能做什么 | 短板 |
|---|---|---|
| SheetJS (xlsx) | 解析和生成 Excel 文件 | 只做文件读写,没有在线编辑 UI |
| Luckysheet | 在线表格编辑 | 社区活跃度下降,功能扩展和协同不太成熟 |
| x-data-spreadsheet | 轻量表格组件 | 功能太少,复杂公式、保护、校验都没有 |
| OnlyOffice | 完整办公套件 | 体积大、部署重,嵌入定制成本高 |
| Univer | 现代开源电子表格 | 文档偏少,版本迭代快,需要自己填坑 |
Univer 吸引我的点很具体:第一,Canvas 渲染加虚拟滚动,大数据量下交互依然流畅;第二,插件化架构,公式、数据校验、协同、导入导出都能按需加载;第三,它的权限模型沿用了 Excel 的“工作表保护 + 单元格锁定”语义,团队里大多数人都有 Excel 使用经验,认知成本几乎为零;第四,MIT 协议,内网部署没有授权包袱。
1.3 我把需求拆成了三层
选型之前,我先把自己的需求拆成三层,避免中途被技术细节带偏:
- 管理员层:定义表格模板、指定哪些单元格可填写、锁定其余区域、回收提交数据。
- 填写者层:打开链接就能输入,只碰到自己该填的格子,最好还有提示和校验。
- 系统层:能导出原始数据、能落库,最好还能监听变更。
这个拆分帮我决定了技术路线:底层用 Univer 做表格渲染和编辑能力,中间用它的命令系统控制“谁可以改哪里”,上层我自己写提交和回收逻辑。这个结构清晰,后面所有开发都围绕这三层展开。
2. 初始化工程:跑通一个能用的在线表格
2.1 环境准备与依赖安装
Univer 的工程接入不复杂,但有几个细节需要注意。首先,Node 版本建议 18 以上,包管理器我用的是 pnpm,主要是为了锁依赖、避免多版本冲突。
安装核心依赖时要注意,Univer 拆包拆得很细,常见的是下面这几个:
pnpm add @univerjs/core @univerjs/sheets @univerjs/sheets-ui @univerjs/ui如果需要公式、数据校验、导入导出,再追加对应的插件包:
pnpm add @univerjs/sheets-formula @univerjs/sheets-data-validation @univerjs/sheets-export一个容易踩的坑是:所有@univerjs/*包的版本号必须一致,尽量用同一个发布版本。Univer 的命令系统、插件注册机制在不同版本之间改得比较频繁,版本错位常常表现为“初始化不报错,但功能静默失效”,排查起来非常痛苦。
2.2 创建一个最小可运行的表格
Univer 的初始化代码整体是这样的:
import { Univer, UniverInstanceType, LogLevel } from '@univerjs/core'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui'; import { UniverUIPlugin } from '@univerjs/ui'; import { LocaleType } from '@univerjs/core'; const univer = new Univer({ locale: LocaleType.ZH_CN, logLevel: LogLevel.ERROR, }); // 注册表格插件和 UI 插件 univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsUIPlugin); univer.registerPlugin(UniverUIPlugin, { container: 'univer-container', // 页面上的容器 div id });接下来创建一个工作簿:
const workbook = univer.createUniver(UniverInstanceType.UNIVER_SHEET, { id: 'demo-workbook', locale: LocaleType.ZH_CN, });然后通过 id 拿到这个工作簿对象,就能操作工作表了:
const activeSheet = workbook.getActiveSheet(); activeSheet.getRange(0, 0, 1, 1).setValue('Hello Univer');getRange(0, 0, 1, 1)表示从第 1 行第 1 列开始,取 1 行 1 列的区域。Univer 的行列索引是从 0 开始的,这一点跟 Excel 里习惯的 A1 表达不太一样,写代码时容易绕晕。
2.3 理解 Univer 的数据驱动模型
在这个阶段我强烈建议先弄懂 Univer 的底层模型,不然后面做权限控制和数据回收会迷茫。
Univer 的结构大致是:工作簿(Workbook)包含多个工作表(Worksheet),每个工作表包含区域(Range),区域的数据和样式都存成 JSON 结构。它的核心思想是“数据驱动”——表格渲染成什么样,完全由数据快照(Snapshot)决定,而不是像传统 DOM 表格那样有一堆节点。
这一点非常关键。你可以把 Univer 理解成一个“状态容器”:单元格的值、样式、合并、保护状态,全都在数据对象里,通过命令系统来修改。我们要实现的“锁定单元格”,操作的本质就是修改数据层里某个范围的保护状态,而不是把输入框 disable 掉。
3. 核心机制拆解:用工作表保护 + 单元格锁定实现“只能填指定格子”
3.1 先理解 Univer 的保护语义
我在刚开始实现时犯过一个思维定式错误:以为“锁定单元格”就是把某个 range 的属性设成 locked,然后它就不能编辑了。实际上 Excel 和 Univer 的逻辑是:单元格的 locked 属性只是一个“待命状态”,真正让锁定生效的动作是开启“保护工作表”。
你可以类比一栋楼的门锁:每扇门都装着锁芯(locked 属性),但只有物业把楼门统一锁上(开启保护)后,所有装锁芯的门才真正锁住。反过来,如果楼门没锁,那每扇门上的锁芯形同虚设。
所以在 Univer 里,我们的策略是:
- 先给所有“允许用户填写”的单元格设置
locked: false,相当于告诉系统“这几扇门不锁”。 - 保持其他单元格默认的
locked: true。 - 最后开启工作表保护,让锁定规则真正生效。
3.2 编写解锁区域的样式
我直接贴一段可以跑通核心逻辑的代码。假设表格第一行是表头,从第二行到第二十行、第三列到第五列(对应 Excel 里的 C2:E20)是允许填写的区域:
import { SetWorksheetProtectionMutation } from '@univerjs/sheets'; // 拿到当前活跃工作表 const sheet = workbook.getActiveSheet(); // 第一步:把 C2:E20 设置为“不锁定” // getRange 的参数依次为:起始行、起始列、行数、列数(均为 0 起始索引) const editableRange = sheet.getRange(2, 2, 18, 3); editableRange.setStyle({ protection: { locked: false, }, });这里要注意,setStyle传入的是样式对象。如果你之前已经给这个区域设置了字体、背景色,建议先getStyle()再合并修改,而不是直接传一个只包含protection的新对象。否则可能把原有样式覆盖掉。
3.3 启用工作表保护
设置好可编辑区域之后,接下来就是“锁门”:
const commandService = univer.getCommandService(); commandService.executeCommand(SetWorksheetProtectionMutation.id, { unitId: workbook.getId(), subUnitId: sheet.getSheetId(), protection: { sheetId: sheet.getSheetId(), hasPassword: false, selectLockedCells: false, selectUnlockedCells: true, formatCells: false, formatColumns: false, formatRows: false, insertColumns: false, insertRows: false, deleteColumns: false, deleteRows: false, sort: false, filter: false, usePivotTable: false, editObjects: false, }, });SelectLockedCells设为false的意思是:填表人连“点击锁定单元格”都不行,光标根本进不去,更别说编辑了。这一点体验上非常重要,如果你只锁编辑、但允许点击锁定单元格,用户会看到一个半灰不灰的格子,容易误以为是自己电脑卡了。
SelectUnlockedCells设为true的含义则相反:未锁定的可填写区域可以正常点选、输入。填表人打开页面后,Tab 和鼠标操作都能顺畅落在可编辑区域。
执行完这段代码后,你可以立刻在页面上测试:点击 A 列表头或其他锁定区域,不会弹出编辑光标;点 C2:E20 区域,可以正常输入内容,效果基本就是热搜里描述的那种“用户只能填指定的格子”。
3.4 用“允许编辑范围”做更细的权限控制
如果你要做的不是“所有人都能填同一批格子”,而是“每个人只能填属于自己的几行”,那就需要更细的权限模型。
Univer 在保护机制里有“允许编辑范围”(Allow Edit Range)的概念。它的核心思想是:开启工作表保护后,你可以在保护规则里声明若干个例外区域,每个区域还可以关联对应的编辑者。比如“张三只能改第 2 行的 C:E 列,李四只能改第 3 行的 C:E 列”。
我在实际项目中用的是一个变通方案:在服务端根据每个用户生成独立的工作簿副本,然后把各自的区域解锁,再下发给对应的人。这样其实比单人单权限更省事,因为 Univer 的快照本身就是 JSON,服务端动态生成起来非常快。如果你确实需要动态判断“谁来填”,再深入研究 Allow Edit Range 相关的类型定义和 mutation。
3.5 别忘了公式保护
还有一个容易被忽视的细节:如果你在模板里写了公式(比如“总价 = 单价 × 数量”),填写者虽然不能改公式所在单元格,但公式本身依赖的输入单元格可能被填了非法值。
保护工作表后,公式单元格是锁定的,用户点不了也改不了。但你还是要提前把公式单元格也设置为locked: true(默认即锁定),并且确认hidden属性符合你的需求。protection.hidden设为true可以在公式被锁定的同时隐藏公式内容,避免填表人偷看计算逻辑。这个在“报价单”“成本核算表”这类业务场景里很常用。
4. 交互细节优化:提示、校验、联动,让填表人少犯错
4.1 视觉上区分“可填区”与“只读区”
权限做对了,但用户不知道哪里能填、哪里不能填,还是会有客服或者行政被反复追问。我的做法是在模板初始化后,给可填写区域加上明显的底色和边框:
const editableRange = sheet.getRange(2, 2, 18, 3); editableRange.setStyle({ background: '#FFF9E6', // 浅黄色背景 border: { top: { color: '#D9B300', style: 'medium' }, bottom: { color: '#D9B300', style: 'medium' }, left: { color: '#D9B300', style: 'medium' }, right: { color: '#D9B300', style: 'medium' }, }, });这样用户一眼就能看出“黄色格子是我要填的”,大大降低咨询量。
4.2 用数据校验插件限制输入格式
填表过程中常见的错误是格式混入:日期写成字符串、数量写成负数、手机号少一位。Univer 有数据校验插件,可以给指定区域设置校验规则。
安装@univerjs/sheets-data-validation插件后,你可以在初始化时注册插件,并使用校验 API。比如限制“数量”列只能填写 0 到 1000 的正整数,超过就弹出红色提示。这个功能对收集表来说是刚需,实际使用起来体验和 Excel 的“数据验证”非常接近。
4.3 公式联动:让汇总自动算好
填表系统最有价值的一点,是可以在模板中预设公式,让计算结果自动生成在只读单元格里。例如:
- 报销单:填金额、填数量,总金额自动算出。
- 报名表:填报名人数,剩余名额自动减少。
- 月度复盘:填几个分项得分,总分和排名自动展示。
这些公式默认就是锁定的,填表人不会误删。只要确保模板里的公式在保护工作表之前已经写好,用户填写时就能实时看到结果。这个体验远胜“发 Excel 文件过来再离线算”。
4.4 冻结窗格与首行固定
模板表头一般都很长,用户滚动几屏就忘了当前看的是哪一行。Univer 支持冻结窗格,我在项目里会把前两行固定住。
这个操作需要找到 freeze 相关的 API,通过registerSheetCommand或命令机制来设置。不同版本的调用位置不一样,但套路是统一的:先把要冻结的行列设置好,再刷新视图。设置之后填表人滚动数据时,表头始终可见,效率提升非常明显。
4.5 锁定区域的点击反馈
用户点击锁定时,最好有个明确的提示,而不是“无声拒绝”。Univer UI 收到编辑尝试后,通常会阻止操作并给出统一提示。如果你要自定义提示文案,可以在初始化 UI 插件时传入配置,或者监听相关的保护命令事件。
我实际体验下来,提示的颗粒度不用太细,一个统一的“该区域为只读/受保护,不可编辑”就好,用户看到黄色格子就知道去哪里填,看到灰色格子点一下弹出提示,就再也不会乱点了。
5. 实战踩坑清单:那些文档里不会明说的坑
5.1 版本不一致导致的“静默失灵”
这是我遇到的第一个大坑。当时我把@univerjs/core装成了最新版,但@univerjs/sheets还是旧版。初始化时不报错,表格也能显示,但执行getRange().setStyle()后,样式没有反应,单元格该锁定还是锁定。
排查了很久,最后在 package.json 里发现版本号对不上。改完全部统一到同一个版本后问题消失。经验:Univer 的插件版本必须严格一致,升级时最好一次性更新全部@univerjs/*包,不要漏掉任何一个。
5.2 setStyle 小心覆盖整片样式
Univer 的setStyle支持部分样式传入,但某些版本在处理时并不是“合并”,而是“整体替换”。我之前给填写区域先设置了黄色背景,后来又用setStyle({ protection: { locked: false } })去解锁,结果背景色、边框全没了,表格成了白板。
稳妥的做法是先取旧样式,再合并:
const oldStyle = editableRange.getStyle(); editableRange.setStyle({ ...oldStyle, protection: { locked: false }, });5.3 保护参数里的开关项要逐个确认
SetWorksheetProtectionMutation的protection对象里有十几个布尔字段,包括selectLockedCells、selectUnlockedCells、insertRows、deleteColumns等等。我第一次只填了sheetId和hasPassword,结果用户虽然不能改值,却可以在工作表里插入行、删除列,把整个表单结构搞坏。
所以在开启保护时,把所有操作项都检查一遍,不要偷懒:
formatCells、formatColumns、formatRows控制格式编辑;insertRows、insertColumns、deleteRows、deleteColumns控制行删改;sort、filter、usePivotTable控制数据操作;editObjects控制嵌入对象。
我的建议是全部设为false,除非你明确需要开放。
5.4 填表人撤销操作可能绕过视觉锁
保护机制主要拦截的是“用户在 UI 里发起编辑”这条通道,但撤销(undo)和重做(redo)走的是另一个路径。我遇到的情况是:填表人先在可编辑区输入了一些值,然后又用 Ctrl+Z 连续撤销,结果某个原本锁定的单元格也跟着变了。
这是因为撤销栈保留了之前的批量修改命令,如果修改命令本身没有做权限校验,就有可能在恢复旧状态时覆盖锁定区域。我的解决方案是:在提交后端时做最终校验,直接读取每个格子的值,比对模板的锁定规则,发现异常就拒绝入库并提示用户刷新重填。
5.5 中文文档少,善用类型定义和官方仓库
Univer 的中文文档目前还不算全,很多 API 只能靠猜,或者靠搜索引擎翻旧 issue。我的经验是三个地方最有用:
node_modules/@univerjs/sheets/lib里的.d.ts类型定义文件,能告诉你所有命令参数的结构;- 官方仓库里的 Storybook 示例和测试用例,几乎覆盖了所有常见场景;
- 源码里的 Playground,搜索你需要的关键词,比如
protect、lock、>const values = [ [1, 2, 3], [4, 5, 6], ]; sheet.getRange(0, 0, 2, 3).setValues(values);一次调用搞定,性能差距很大。你要是用循环填 500 行,页面色块会闪烁,严重时甚至假死。
6. 数据回收与服务端对接:填完之后怎么办
6.1 保存整个工作簿快照
填表数据最终要收集回来。Univer 有一个非常便利的特征:整个工作簿本身就是一个可序列化的 JSON 对象。你可以直接取出完整快照,存到数据库里,以后重新打开时原样渲染:
const snapshot = workbook.getSnapshot(); // 将 snapshot 字符串化后传给后端保存 const json = JSON.stringify(snapshot);这个方案的好处是连样式、合并单元格、公式、保护状态都一起保存了。用户填到一半退出,下次打开还能恢复到原来的状态,对长表单特别友好。
6.2 读取指定范围的值
如果不需要保存整个工作簿,而是只想取关键数据,那就用
getRange().getValues():const data = sheet.getRange(2, 2, 18, 3).getValues(); // 返回一个二维数组,按行、列排列 console.log(data);这种方式适合直接把数据对接业务系统。比如把 C 列当成“姓名”,D 列当成“部门”,读取后直接插入公司内部的数据库表。我这里建议在读取之前先做数据校验:循环检查必填项是否为空、格式是否符合预期。宁可多写几个 if,也不要让脏数据流到服务端。
6.3 导出 Excel 备份
有些业务方还是要一份 Excel 文件存档,不可能完全抛弃。Univer 社区有
@univerjs/sheets-export插件,可以在前端直接导出.xlsx。如果你的项目是服务端处理,也可以把工作簿快照发给后端,再用 ExcelJS 或 PHPExcel 把值遍历出来生成 Excel 文件。导出时注意一点:导出的表格文件只是原始数据的副本,不包含 Univer 的保护状态和校验规则,所以不要指望用导出的 Excel 再回填。导出文件夹主要用于归档和人工审计。
6.4 监听变更,做实时入库
如果想要实时感知用户改了哪个单元格,可以监听 Univer 的命令事件。Univer 中所有对数据的修改都会经过 command service,你可以监听
SetRangeValuesMutation,在回调里拿到修改的范围和新值。不过我在项目里没有做太复杂的实时入库,原因是:多人填报场景中,填表通常是一次性动作,不需要像在线文档那样做实时倒灌。我选择在用户点击“提交”后一次性读取全部区域的值,再传给后端。这样逻辑最简单,也不容易被撤销栈干扰。
6.5 给填表人一个清晰的提交入口
不要在表格角落让用户自己找提交按钮。我是在页面上方固定一个自定义工具栏,里面只放两个按钮:“保存草稿”和“提交”。提交后锁定全部区域,禁止二次修改;如果填错了,由管理员在后台解锁后再退回修改。
这个模式比开放实时编辑更容易管理。Univer 的 UI 插件支持自定义工具栏和按钮,写起来也不复杂。
最后再分享一点实际感受
整个项目从选型到上线,我前后用了一周多时间。Univer 的底子确实扎实,Canvas 渲染流畅,插件体系清晰,权限模型熟悉,数据快照也方便。但如果你只想快速实现“用户填写指定单元格”,不要被它表面丰富的功能迷惑——先理清自己的三层需求,再按“模板设计 → 解锁填写区 → 开启保护 → 校验回收 → 服务端归档”这条主线来做,就不会绕路。
另外,如果你要做的填表场景是“1000 人同时在线填写”,我的建议是给每人分发独立的工作簿副本,而不是让所有人挤在同一张表上。前者对服务端压力小,数据隔离也干净,还能自然支持“不同人看到不同内容”。这也是我在后续版本里准备用上的方案。