☰
基于Univer构建在线填报表单:锁定单元格与数据校验实战
2026/10/1 19:36:59 网站建设 项目流程

做数据收集这件事,我一开始用的都是传统表单工具,后来项目里必须让用户在一张“长得像 Excel”的表格里直接填数,而且还要把表头、公式、说明区域全部锁死,用户只能动我指定的那几个单元格。折腾一圈之后,我从 Luckysheet 迁移到了 univer,算是在这个项目里把整套权限控制、数据校验、结果导出的链路都摸了一遍。如果你也在做在线表格、需要“用户定义表格后只允许填写部分单元格”这种需求,这篇应该能帮你少走不少弯路。

我会从为什么选 univer 讲起,然后给一个可以跑通的最小项目,再到单元格锁定、数据校验、结果收集这三大块,最后补一些上线前容易踩的坑。整个内容以“运动会报名表”这个真实场景贯穿,代码可以直接抄。

1. 为什么我看好 univer:从“填表收集数据”这个刚需场景说起

如果你只在内部系统里做“填表”,传统表单确实够用。但一旦遇到下面这类需求,传统表单就很别扭:

  • 字段不是固定的几个输入框,而是几十行、十几列的结构化表格;
  • 用户需要在一个界面里同时看到说明、历史数据、汇总公式,然后自己在指定区域补录;
  • 后端拿到的数据,最好直接按“行列坐标”落库,而不是解析一堆 JSON 表单字段;
  • 管理员希望表格是自己定义的,定义好之后用户只能改其中一部分单元格,其他区域动都不能动。

这种场景下,最自然的产品形态就是在线表格。用户打开一个网页,看到一个已经定义好的表格,该填的格子高亮显示,不该填的格子点了也没反应,填完直接提交,数据自动回到后端。univer 正好就是干这个的。

1.1 univer 是什么,它和 Excel、Luckysheet 的区别

univer 是一套开源的表格/文档/幻灯片基础设施库,底层用 TypeScript 写,核心是“数据与 UI 分离”的架构。它有这几个特点非常吸引我:

  • 真正的前端组件化,可以像引入一个 npm 包一样嵌进 React / Vue / 原生项目;
  • 支持自定义插件,后期加公式、数据校验、协同编辑都有对应的扩展点;
  • 默认的视觉交互和 Excel 非常接近,单元格样式、合并、边框、冻结行列都有;
  • 免除了我早期用 Excel 宏程序收集数据的困局——以前每个用户电脑上装的东西不一样,宏运行环境千奇百怪,换成网页表格之后,所有逻辑都在服务端控制,用户只需要一个浏览器。

和 Luckysheet 相比,univer 的代码更新更快、社区更活跃,而且它不只是一个表格,后续还能扩展文档和幻灯片。对于一个要长期维护的项目来说,选一个还在快速迭代的框架,比选一个“已经很稳但基本停更”的框架更安心。

1.2 热门需求背后:管理员定义表格,用户只填指定单元格

我看到这段时间 univer 相关的讨论集中在“支持用户定义表格,然后让用户去填写一些单元格,其他单元格用户无法修改”这一点上。这其实是两个能力叠加:

  1. 管理员侧:能通过页面交互设计表格、合并单元格、写公式、给表头加样式;
  2. 用户侧:表格打开后处于保护状态,只有明确标注过的单元格可编辑。

在 Excel 里,对应的就是“工作表保护 + 单元格锁定”。管理员先设计好整张表,然后默认所有单元格都是 locked 状态,再把允许用户编辑的区域取消锁定,最后开启工作表保护。univer 也沿用了这套思路,所以如果你之前理解过 Excel 的保护机制,在 univer 里做同样的事会非常顺。

2. 三步跑通 univer:从零搭建在线表格的最小可用项目

在动权限控制之前,先要把 univer 本体跑起来。我用的版本是当前 npm 上的最新稳定版,这里我给一个最简示例,前端构建工具用的 Vite,框架用的原生 TypeScript,不嵌套任何前端框架,这样最容易讲清楚原理。

2.1 环境准备与安装依赖

创建一个空项目:

mkdir univer-demo cd univer-demo npm init -y npm install @univerjs/core @univerjs/sheets @univerjs/sheets-ui @univerjs/ui @univerjs/design @univerjs/sheets-data-validation

注意这里的数据校验包@univerjs/sheets-data-validation,如果你只做表格展示不需要,但做用户填报表单一定用得上。后续版本号建议锁定同一个大版本,因为 univer 目前还在比较活跃的迭代期,小版本之间 API 偶尔会动,混装不同版本容易出现类型不匹配。

然后配一个 Vite 环境:

npm install -D vite typescript npx tsc --init

tsconfig.json里记得把moduleResolution设为"bundler",target设为"ES2020"以上,避免遇到 univer 源码里的新语法报错。

2.2 渲染一个最小表格

在一个index.html里放容器:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <title>Univer 报名表格</title> <style> html, body, #app { height: 100%; margin: 0; } </style> </head> <body> <div id="app"></div> <script type="module" src="/src/main.ts"></script> </body> </html>

src/main.ts里做初始化:

import { Univer } from '@univerjs/core'; import { defaultTheme } from '@univerjs/design'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui'; import { UniverUIPlugin } from '@univerjs/ui'; import { UniverSheetsDataValidationPlugin } from '@univerjs/sheets-data-validation'; const univer = new Univer({ theme: defaultTheme, }); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsUIPlugin, { container: 'app', }); univer.registerPlugin(UniverUIPlugin, { container: 'app', }); univer.registerPlugin(UniverSheetsDataValidationPlugin);

这里有同学会困惑:UniverSheetsUIPlugin和UniverUIPlugin都要传container,到底谁在渲染?我的理解是UniverUIPlugin负责最外层的工作台 UI 框架,UniverSheetsUIPlugin负责表格编辑区的交互与渲染。两个都注册、且都指向同一个容器,最终表格才会完整显示出来。

2.3 创建一个工作簿并用代码填充表头

初始化完之后,创建带一个 sheet 的工作簿:

const workbook = univer.createUniverSheet({ id: 'workbook-1', sheets: [ { id: 'sheet-1', name: '报名表', rowCount: 100, columnCount: 10, cellData: { 1: { 0: { v: '姓名', s: { bold: 1, fill: { rgb: '#E8F0FE' } } }, 1: { v: '部门', s: { bold: 1, fill: { rgb: '#E8F0FE' } } }, 2: { v: '手机号码', s: { bold: 1, fill: { rgb: '#E8F0FE' } } }, 3: { v: '参赛项目', s: { bold: 1, fill: { rgb: '#E8F0FE' } } }, 4: { v: '备注', s: { bold: 1, fill: { rgb: '#E8F0FE' } } }, }, }, }, ], });

这里的cellData是二维坐标映射,外层 key 是行号,内层 key 是列号,都从 0 开始。s是样式对象,bold代表加粗,fill是背景填充。这样创建出来的表格,第一眼就已经有可读性了。

如果你需要“管理员在界面上自己定义表头”,后期可以把这份cellData改成由后端配置驱动,表格结构发生变化时前端重新createUniverSheet或者用 API 动态更新即可。我项目里的做法是:管理员在一个管理页用同样的 univer 实例编辑模板,保存后把整个快照存到后端,用户端加载时直接渲染这份快照,这样就实现了“管理员定义表格”。

3. 单元格锁定的真正实现:让用户只能改允许填写的区域

这是整个项目里最核心的一块。用户侧打开表格之后,默认情况下表格的所有单元格都是可以编辑的,这肯定不行。我们需要做两件事:第一,把整张表保护起来;第二,在保护状态下,单独放行我们指定的可编辑区域。

3.1 理解 univer 的保护与锁定关系

在 Excel 里,保护工作表只是开启一个开关,真正决定单元格能否被修改的,是单元格自己的 locked 样式。默认所有单元格 locked 属性是真,开启工作表保护后全都不能改;如果你想让某个区域可编辑,就先选中它,取消锁定,再启用保护。

univer 虽然 API 形式不完全一样,但设计思维是一致的:保护作用于工作表维度,锁定作用于单元格样式维度。你在创建表格时可以通过style里设置locked: 1表示锁定,locked: 0表示不锁定;然后在用户侧渲染页面里开启工作表保护。

3.2 我的做法:明确“锁定默认值”和“放行区域”

因为 univer 的默认样式通常是不锁定或跟随主题,为了安全,我建议你在创建表格时就显式给单元格样式加上 locked 标志,不要依赖默认值。否则一旦某个版本改了默认行为,你辛辛苦苦做的权限控制可能直接失效。

具体到运动会报名表,我的定义是:

  • 第 1 行放标题,合并单元格,锁定;
  • 第 2 行放填写说明,比如“请只填写白色区域,灰色区域由管理员维护”,锁定;
  • 第 3 行是表头,锁定;
  • 第 4 行到第 50 行是用户填写区,取消锁定;
  • 最后一列或最后一行放统计公式,锁定。

创建表格时,我直接用循环给数据区域加样式:

const editableRegionStartRow = 3; // 第4行 const editableRegionEndRow = 49; // 第50行 const editableColumns = [0, 1, 2, 3, 4]; const cellData: Record<number, Record<number, unknown>> = {}; function setStyle(row: number, col: number, cell: unknown) { if (!cellData[row]) cellData[row] = {}; cellData[row][col] = cell; } // 第1行:标题 setStyle(0, 0, { v: '年度运动会报名表', s: { bold: 1, fontColor: '#1F4E79', fontSize: 16, locked: 1 }, }); // 第2行:说明 setStyle(1, 0, { v: '请填写白色可编辑区域,其他区域不可修改。', s: { fontColor: '#888888', locked: 1 }, }); // 第3行:表头 ['姓名', '部门', '手机号码', '参赛项目', '备注'].forEach((name, col) => { setStyle(2, col, { v: name, s: { bold: 1, fill: { rgb: '#E8F0FE' }, locked: 1 }, }); }); // 可填写区域:显式锁定为 0 for (let row = editableRegionStartRow; row <= editableRegionEndRow; row++) { for (const col of editableColumns) { const style = { locked: 0, fill: { rgb: '#FFFFFF' }, }; setStyle(row, col, { s: style }); } }

这里我专门把所有可编辑区域都设成了白色的填充色,锁定的表头和数据保留灰色底色,用户在视觉上就能一眼看出哪里能填。

3.3 在用户侧开启工作表保护

表格快照创建完之后,需要在渲染时对工作表启用保护。univer 提供了命令式 API,不同版本名称可能略微不同,但思路一致:拿到当前工作表对象,调用保护相关命令。

我记得在@univerjs/sheets中有SetWorksheetProtectionCommand,还可以设置一个密码字符串。用起来大概是:

import { Worksheet } from '@univerjs/core'; import { ICommandService, LocaleService } from '@univerjs/core'; const sheet = workbook.getSheetBySheetId('sheet-1'); // 命令方式 const commandService = univer.__getInjector().get(ICommandService); commandService.executeCommand('sheet.command.set-worksheet-protection', { sheetId: sheet.getSheetId(), protection: { lock: true, password: '', // 不需要用户解除密码,空即可 }, });

如果你们的 univer 版本已经把这套封装成了更友好的 API,比如worksheet.protect(),直接调用即可。重点不是 API 长什么样,而是你要意识到:保护必须发生在所有单元格样式设置完毕之后。如果你先把保护开了,再调整样式,可能会被权限拦截掉。

补充一个容易忽略的点:工作表保护默认会一并禁止用户插入行列、删除行列、调整行高列宽、合并单元格等操作。这对于填表场景其实是好事,不会有人乱拉列宽把布局搞坏。但如果你希望用户能调整行高以便看清楚内容,需要在保护配置里单独开放resizeRow、resizeColumn权限。这一点在 Excel 保护设置里也有对应选项,univer 做得比较接近,找一下protection对象下的permissions字段即可。

3.4 被锁定单元格的交互反馈

从产品体验角度,光“不能编辑”还不够,最好让用户知道为什么不能编辑。我自己测试时发现,锁定单元格点击后通常没有任何反应,第一次用的人会以为是系统卡了。

我后来处理的方式是:在页面里通过监听单元格选中事件来判断当前选中的区域是否可编辑,如果用户选中的是锁定单元格,就弹一个轻提示:“该区域已锁定,仅管理员可修改”。虽然不能让 univer 原生弹出气泡,但完全可以在上层监听onCellClick或onSelectionChange来做。

这个交互细节属于“做了用户没感觉,不做用户会困惑”的类型。你产品里如果是给内部员工填表,建议无论如何都加上,能减少很多客服咨询。

4. 表格权限之外,还需要配套的设计:数据校验与表单提交

锁定单元格只是第一步。真正的填表场景,光靠锁定远远不够:用户可能漏填、填错格式、填了不存在的项目、甚至绕过前端直接提交伪造数据。这一节我讲两块:在 univer 里做校验,以及如何把用户填写的内容完整、可靠地收集上来。

4.1 数据校验:手机号正则、必填标记、下拉枚举

univer 的数据校验插件提供了类似 Excel 数据验证的能力。我可以给指定区域设置规则,比如:

  • 姓名列:非空;
  • 手机号码列:匹配 11 位数字;
  • 参赛项目列:只能是“100米短跑 / 跳远 / 铅球 / 4x100接力”四选一;
  • 备注列:允许为空,但最多 50 字。

数据校验的规则写法在不同版本里略有差异。我用的版本是通过 data validation 插件注册规则,大致形式是:

const dataValidationPlugin = univer.getPlugin(UniverSheetsDataValidationPlugin); dataValidationPlugin.addRule({ uid: 'rule-name', ranges: ['sheet-1!A4:A50'], validator: { type: 'required', message: '姓名不能为空', }, }); dataValidationPlugin.addRule({ uid: 'rule-phone', ranges: ['sheet-1!C4:C50'], validator: { type: 'regex', pattern: '^1[0-9]{10}$', message: '请输入11位手机号码', }, });

下拉枚举我使用的是列表校验,把四个项目写进 options:

dataValidationPlugin.addRule({ uid: 'rule-sport', ranges: ['sheet-1!D4:D50'], validator: { type: 'list', options: ['100米短跑', '跳远', '铅球', '4x100接力'], message: '请从列表中选择参赛项目', }, });

设置完之后,用户填错时 univer 会在单元格上给出提示,这是原生能力,比自己写正则监听要稳得多。

需要注意一点:**数据校验只解决了“用户在 UI 上填写时的体验”,不能保证后端收到的数据一定合法。**因为用户完全可以拿到页面源代码直接构造 HTTP 请求提交假数据。所以后端接口里必须把手机号正则、项目枚举再校验一遍,前端校验只是减少无效提交和提升体验,不是安全边界。

4.2 收集填写结果:监听单元格变更还是定时拉快照?

用户填完表格之后,数据怎么回到后端?有三种做法,我在实际项目里都试过,分别适用于不同情况。

第一种,实时监听单元格变更。在 univer 里可以通过事件监听拿到用户修改了哪个单元格、新值是什么,然后立刻发送到后端。这种方式适合做自动保存,用户不需要点任何按钮,关闭页面也不怕丢数据。

const workbook = univer.getCurrentWorkbook(); const sheet = workbook.getActiveSheet(); sheet.onCellChange((event) => { const { row, column, value } = event; saveToBackend(sheet.getSheetId(), row, column, value); });

第二种,用户手动点击提交按钮,前端遍历整个可编辑区域,把所有单元格的值打包成一个二维数组发给后端。这种方式最可靠,因为你能确保用户是“填完、检查过、再提交”的。运动会报名这种低频、小数据量的场景,我建议用这种方式。一次性拿到的是完整的填写结果,后端可以直接落库或覆盖更新。

第三种,定时自动保存快照。每隔几十秒把整个 sheet 的数据序列化成 JSON 传给后端。好处是简单,坏处是数据量大时网络开销和写入压力都高,适合协同编辑类产品,不适合普通填表。

我自己在报名表场景用的是第二种,配合第一种实时监听做“暂存”功能:每改一格先存到浏览器 localStorage,用户如果误关页面,下次打开还能从本地恢复。等到确认无误后再点提交,一次性写入后端。

4.3 提交时的数据打包与后端接口设计

提交按钮一般放在表格外部,也就是页面里 univer 容器下方。点击后我这样拿数据:

function collectEditableData(workbook: Univer, sheetId: string) { const sheet = workbook.getCurrentWorkbook().getSheetBySheetId(sheetId); const rows = []; for (let row = editableRegionStartRow; row <= editableRegionEndRow; row++) { const rowData: Record<string, string> = {}; for (const col of editableColumns) { const cell = sheet.getCell(row, col); rowData[col] = cell ? String(cell.v ?? '') : ''; } rows.push(rowData); } return rows; }

拿到rows之后,可以把一个“行对象数组” POST 给后端。后端只需要做三件事:

  1. 重新校验必填、正则、枚举;
  2. 给每行补一个唯一 ID,防止重复提交;
  3. 落库,并返回一个提交编号。

这样用户如果再点一次提交,也不会因为网络重试产生重复数据。

5. 上线前必须解决的几个问题:版本、性能与边界情况

前面三条路跑通之后,项目已经能用了。但如果你想把它放到正式环境,有四个问题必须提前想清楚。

5.1 版本迭代快,锁定版本很重要

univer 目前迭代很快,可能你几个月后回来升级依赖,发现某个插件 API 变了,导致整个初始化代码要重写。我的建议是:

  • 所有@univerjs/*包统一锁到同一个版本号,不要出现核心包是 1.x、UI 包是 2.x 这种混装;
  • 在 package.json 里不要用^或~,直接锁死精确版本,或者用 lockfile 统一管理;
  • 升级时先看官方 changelog,尤其注意破坏性变更说明。

如果你不是特别需要新功能,完全可以把依赖锁在一个稳定版本上跑很久。表格这种组件,稳定性比前沿性重要得多。

5.2 大数据量下的渲染性能与节流

我有一次拖了 5000 行数据进去,页面能跑,但明显感觉选中单元格和滚动有卡顿。univer 本身做了 canvas 渲染,性能在同类库里算好的,但还是要避免做一些“看起来很合理但实际很费”的操作。

比如上面说的“遍历所有可编辑区域打包数据”,如果可编辑区域是几百行,那没问题;如果是几千行,就要分页或者只在用户点击提交时打包一次,不要在每次单元格变更时全量扫描。

另外,如果表格里大量使用了整列样式或整行样式,渲染层会维护大量样式对象,内存占用会蹭蹭往上涨。能用“局部单元格样式”就别用“整行整列样式”,尤其在宽表场景下。

5.3 我踩过的几个坑

第一个坑是样式覆盖。我在创建表格时先给可编辑区域设置了白色背景,后来又在某个列上设置了黄色高亮,结果因为创建顺序问题,高亮被之前的白色背景盖住了。排查了半天才发现是单元格样式在cellData里的合并规则并不像 CSS 那样有层级,必须保证你最终想要的样式在创建时完整覆盖,而不是依赖后续“覆盖”操作。

第二个坑是保护命令的调用时机。我最初在创建 workbook 后立刻执行保护命令,但当时 sheet 还没有完全挂载到组件树上,命令执行后实际没有生效。后来改成在页面渲染完成后再调用,并且用requestAnimationFrame或setTimeout做了一次兜底,才稳定复现出“锁定”效果。如果你遇到“设置了保护但用户还是能改”,优先检查这个时机问题。

第三个坑和数据校验有关:校验规则里的ranges如果引用了不存在的 sheet 名,插件不会报错,而是静默失效。用户填任何值都不会触发提示,排查起来非常隐蔽。我后来专门写了一个自检函数,初始化完把所有规则的范围进行一遍断言,确保每个 sheetId 真实存在。

5.4 后续扩展方向

跑通这个报名表之后,你可以把同一套方案复制到几乎所有“数据收集 + 表格呈现”的项目里:

  • 后台管理端的“批量录入”页面;
  • 客户自助填写产品配置单;
  • 财务报销单的多行明细录入;
  • 考试报名和成绩确认表。

如果你以后要支持两人同时编辑,univer 也有协同编辑的接入方案,不过那需要你自建 websocket 服务和 CRDT/OT 同步逻辑,复杂度会上去一个量级。在普通填表场景下,其实并不需要多人同时编辑同一个格子,做好“一人填完后提交”就够了。

从我个人的实际体会来说,univer 这套组合最舒服的一点是它把 Excel 的保护、校验、样式这些成熟概念都搬到了 Web 端,后端不需要做太多额外设计,前端也只需要理解“单元格样式 + 工作表保护 + 数据校验”这三板斧。如果你正在为“用户只能在指定单元格里填数据”这件事发愁,完全可以照着上面的链路搭一个最小原型,先把锁定和校验跑通,再逐步加提交逻辑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询