“想用在线表格做一个报名表或者工单登记表,业务同学打开网页就能填,但只能改我指定的那几格,标题、说明,还有那些公式列,一概不能碰”——这是我最近被问到次数最多的一个需求。字面听上去不复杂,真正落到代码里才发现弯弯绕绕不少。如果你正在用Univer搞这类“用户定义表格 + 指定单元格填写、其他单元格锁定”的在线表格场景,或者刚接触 Univer 在评估它能不能扛起这个业务,这篇就是给你准备的实操笔记。我会从项目选型讲到权限模型,再给可直接抄的配置和代码,最后整理几个我实际踩过的坑。
1. Univer 是什么:项目定位与核心技术拆解
1.1 开箱即用的在线表格内核
Univer 是一套基于 TypeScript 构建的开源办公套件方案,主打在线创建、编辑和预览电子表格、文档与幻灯片。如果你听过 Luckysheet,那可以把它理解为 Luckysheet 团队在架构上推翻重做后的下一代产物:渲染层换成了 Canvas 绘制,核心与 UI 彻底解耦,插件化程度更高,还内置了一套公式引擎和命令系统。
从业务接入角度,Univer 最让人舒服的一点是:它不是给你一个“静态表格组件”,而是给你一个完整的、可编程的表格运行时。你可以在里面定义工作簿、工作表、单元格数据、公式、样式、数据校验,也可以通过命令服务去执行“设置单元格值”“合并单元格”“开启工作表保护”这些操作。换句话说,你不需要像过去那样在一堆 div 和 table 标签上模拟表格行为,而是直接操作一个真实存在的电子表格实例。
Univer 的模块划分大概是这样:
- @univerjs/core:核心数据模型,工作簿、工作表、单元格、区域、样式、命令框架都在这层。
- @univerjs/sheets:表格业务逻辑,包括单元格编辑、选区、公式计算、筛选、排序等基础能力。
- @univerjs/sheets-ui:表格 UI 层,负责渲染工具栏、编辑栏、右键菜单、弹窗。
- @univerjs/ui:通用 UI 基础设施,让 Univer 可以嵌入 React、Vue 或原生 JS 项目。
- @univerjs/engine-formula:公式引擎,用来处理跨表、跨工作簿的公式计算。
- @univerjs/engine-render:Canvas 渲染引擎,负责把表格画到页面上。
这个分层直接决定了它的扩展性。比如你只想用表格编辑能力,不想显示官方那一整条工具栏,那完全可以不注册 UI 插件,自己写一套编辑入口;反过来,如果你需要标准 Excel 体验,把 sheets-ui 注册上就基本齐了。正因为这种灵活度,Univer 特别适合做“半定制化”的业务表格,而不是只能全盘照搬的标准 Excel。
1.2 为什么选 Univer 做“用户填表”场景
做“用户填写指定单元格”这件事,传统方案一般有三条路:直接发 Excel 模板让人填了再回收、用传统前端表格组件仿一个填表页、或者直接在自己系统里做表单引擎。这三条路各有各的别扭:
- Excel 模板回收,版本混乱、格式被改、收集汇总全靠人肉,体验很差。
- 前端表格组件仿填表页,表格行为很难做到位。用户想要拖动填充、下拉选择、公式联动时,基本都要自己造轮子。
- 表单引擎虽然能限制输入项,但表达不了表格布局。比如一行一个项目的工时填报,或者横竖轴交叉的排班表,用表单控件排出来非常痛苦。
Univer 刚好卡在中间:它有原生表格的交互能力,又允许你用编程方式控制哪些单元格可编辑、哪些被锁定。管理员先在页面上把模板搭好,锁定不需要用户碰的区域,再把链接发给用户,用户在网页里只能按预定位置填写。整个流程在线上闭环,数据直接回传后端,既避免了 Excel 文件满天飞,又保留了表格天然的布局表达能力。
更关键的是,Univer 的保护机制不是“只能设置整表只读”这种一刀切,它支持把工作表的“保护”和“单元格的锁定属性”拆开组合,配合非常细的权限范围,能够准确实现“某些区域可以编辑、其他区域不能改”的需求。这就是这篇实操里最核心的切入点。
2. 需求拆解:让用户填写指定单元格,其余锁定
2.1 “用户定义表格”的业务本质
先把“用户定义表格”这个说法拆开。用户这个词在不同场景里指代不一样。在多数业务系统里,设计表格模板的是管理员或财务,最终填写数据的是普通员工或外部客户。所以“用户定义表格”实际上包含两层含义:
- 模板定义权:谁来创建表格结构、设置标题、公式、校验规则、锁定规则。
- 数据填写权:谁能在特定区域内填入内容。
这篇文章要解决的核心是第二层,但实现第二层之前,必须先想清楚第一层。因为模板的定义过程往往也需要在 Univer 里面完成,如果管理员自己都分不清哪些单元格是锁定用的、哪些是放开用的,后面所有规则都是空中楼阁。
一个典型的业务例子是培训报名表:
- A1:D1 是合并标题“2025年第三期安全培训报名表”。
- 第二行是列名:姓名、部门、邮箱、是否住宿。
- 管理员不希望用户改标题和列名,甚至不希望用户能选中这些单元格;
- 用户只需要从第三行往下填写自己的信息;
- 如果邮箱格式错了,表格应给出提示。
这个例子里的“可编辑区域”就是一个从第三行到表格末尾的数据区域。用户在这个区域里输入内容,其他区域要么锁定、要么只读。管理员创建模板时,也应该把这个规则体现在配置里,而不是等表格上线后再去临时设置。
2.2 权限模型与可编辑范围控制的关键点
Univer 控制可编辑性的机制,本质上沿用了 Excel 那套经典的“工作表保护 + 单元格锁定”模型。
先记住一个关键结论:单元格默认的锁定状态,并不等于用户不可编辑。只有当工作表开启了保护(protection)之后,锁定属性才会生效。这个关系可以类比成小区门禁:每个房间有门锁(单元格 locked 状态),但只有保安启动门禁系统(工作表保护),这些门锁才真正起作用。如果你只给每个房间换了锁,却让保安放假,那谁都能推门进去。
在 Univer 的配置模型里,工作表保护对象至少包含这几个关键字段:
- sheet:布尔值,表示这张工作表是否启用保护。
- lockCells:布尔值,表示当前工作表是否锁定所有单元格。
- ranges:数组,用来声明保护范围内的例外区域,每个区域可以单独设置是否允许锁定。
当lockCells: true且protection.sheet: true时,整张表默认不可编辑,只有ranges里明确列为 unlock 的区域可以编辑。反过来,如果lockCells: false,整张表默认可编辑,ranges里的区域可以被单独锁死。大多数“用户填表”场景用的是前者:先锁全表,再把填写区域放出来。
还有两个容易被忽略的配置项:allowSelectingLockedCells和allowSelectingUnlockedCells。前者控制用户能不能点选锁定区域,后者控制用户能不能点选可编辑区域。如果业务上要求“用户连标题都选不中”,就把allowSelectingLockedCells设为 false;如果允许用户点选已填写的内容,只是不能修改,那就保持为 true。我这边的经验是,填表业务里通常把两个都放开,因为用户选中有助于看清他填过什么,只要不能编辑就可以了。
另外要注意,Univer 的保护模型是工作表级别的,不是工作簿级别。如果你想整个工作簿都进入“填表模式”,需要遍历里面每一张工作表,分别设置保护。如果有多个 Sheet,且用户应该只能看到其中一张填报表,那更实用的做法是直接隐藏其他工作表,只保留目标表。
3. 实操:在 Univer 中实现“可指定区域填写”
3.1 环境准备与最小示例
先搭一个最小可运行的 Univer 项目。我用的是 Vite + TypeScript 的 React 工程,其实框架不限,Univer 官方封装好了 React 组件和非 React 接入两种方式,核心逻辑一样。
安装依赖:
npm install @univerjs/core @univerjs/sheets @univerjs/sheets-ui @univerjs/ui @univerjs/engine-formula @univerjs/engine-render初始化代码大概长这样:
import { Univer } from '@univerjs/core'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui'; import { UniverUIPlugin } from '@univerjs/ui'; import { UniverFormulaEnginePlugin } from '@univerjs/engine-formula'; const univer = new Univer({ locale: 'zhCN', }); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverFormulaEnginePlugin); univer.registerPlugin(UniverUIPlugin, { container: 'app', }); univer.registerPlugin(UniverSheetsUIPlugin);这一步跑起来以后,页面右上角会出现 Univer 自己的工具栏。我建议开发阶段不要急着隐藏工具栏,因为“保护工作表”这个功能在工具栏里可以直接点,方便你验证效果。生产环境再根据业务隐藏不必要按钮。
然后在项目里创建一张工作表数据。Univer 创建表格时可以传一个类似“工作表配置快照”的对象,里面包含单元格数据、样式、合并信息、行高列宽,还有我们关心的保护配置。下面的示例是完整模板结构:
const formSheet = { id: 'training-signup', name: '报名填写', rowCount: 20, columnCount: 6, cellData: { 0: { 0: { v: '2025年第三期安全培训报名表', s: { bl: 1, bg: '#f2f2f2', locked: true, merge: 3 } }, }, 1: { 0: { v: '姓名', s: { locked: true, bg: '#e8e8e8' } }, 1: { v: '部门', s: { locked: true, bg: '#e8e8e8' } }, 2: { v: '邮箱', s: { locked: true, bg: '#e8e8e8' } }, 3: { v: '是否住宿', s: { locked: true, bg: '#e8e8e8' } }, }, }, protection: { sheet: true, lockCells: true, allowSelectingLockedCells: true, allowSelectingUnlockedCells: true, ranges: [ { range: { startRow: 2, endRow: 19, startColumn: 0, endColumn: 3 }, lock: false, }, ], }, }; univer.createSheet(formSheet);这段配置的作用很直白:把整张表锁定,然后在第 3 行到第 20 行的前四列放出一个可编辑区域。用户在网页上打开后,表头区域内容灰色、选中但改不了;第 3 行及以下的白色区域内,用户可以像操作 Excel 一样输入姓名、部门、邮箱和住宿信息。
3.2 定义可填写区域:模板配置方式
上一步的 protection 配置里,ranges就是指“放给用户编辑的区域”。这个数组可以包含多个不连续的区域,比如一张表上既有“基本信息区”又有“家庭成员区”,那就写两个 range 对象。注意startRow和endRow、startColumn、endColumn都是从 0 开始计数的,表格第 1 行对应 startRow 0,第 1 列对应 startColumn 0。这个计数方式特别容易踩坑,我第一次写就把第三行写成了 startRow 2 还是 3 纠结了半天。
单元格样式里的locked是给单元格本身打标用的。如果你在创建模板时想明确某个单元格“永远不能被编辑”,除了在 protection 的 ranges 里不放这个区域,还可以同时在s.locked上做标记。但需要说明:locked 只是标签,保护范围才是最终执行依据。开启保护后,Univer 执行编辑命令时会去检查“当前选中区域是否在 protection 的允许编辑范围内”。所以模板正确性最终看 protection.ranges,单元格的 locked 属性用来配合 UI 展示,比如给锁定区域加浅灰背景,让用户一眼看出哪里不能填。
3.3 运行时切换编辑权限:命令方式
模板配置是“静态初始化”的做法,适合表格结构在代码里写死。但真实业务里,管理员很可能要在界面上临时修改可编辑范围,这就需要用命令动态调整保护配置。
Univer 的命令服务(CommandService)是运行时改变表格状态的唯一正规入口。示例代码如下:
import { ICommandService } from '@univerjs/core'; import { SetWorksheetProtectionCommand } from '@univerjs/sheets'; const commandService = univer.getCommandService(); await commandService.executeCommand(SetWorksheetProtectionCommand.id, { unitId: 'training-signup', subUnitId: 'training-signup', protection: { sheet: true, lockCells: true, ranges: [ { range: { startRow: 2, endRow: 19, startColumn: 0, endColumn: 3 }, lock: false }, { range: { startRow: 2, endRow: 19, startColumn: 4, endColumn: 5 }, lock: true }, ], }, });这里有两个 id:unitId是工作簿的 id,subUnitId是工作表的 id。在我这个例子里两者都用了同一个字符串。如果你的业务里有两张 sheet,那 subUnitId 就分别指向对应 sheet。很多初学时困惑的“为什么执行命令没反应”,八成是这两个 id 没对上。调试时可以先打印工作簿和工作表的 id:
const workbook = univer.getActiveWorkbook(); const worksheet = workbook.getActiveSheet(); console.log(workbook.getId(), worksheet.getId());执行完这个命令后,表格的编辑权限会立即变化,不需要刷新页面。这种动态控制很适合做“审批流”:管理员编辑模板时保护未开启,审核通过后调用命令开启保护,业务用户拿到链接就进入了填写模式。
3.4 前端纯拦截兜底方案
保护机制原理上是拦截了 Univer 内部的编辑命令,但并不是所有交互都能被 protection 覆盖。比如某些版本里,你仍然可以通过填充柄向下拖拽一个锁定区域的值,或者复制锁定区域粘贴到可编辑区域。遇到这种情况,光靠 protection 不够,还得在前端事件层做兜底。
Univer 提供命令执行监听,我们可以拦下不必要的操作:
univer.getCommandService().onCommandExecuted((command) => { if (command.id === 'sheet.command.set-range-values') { // 检查 command.params 里的 range 是否落在可编辑区域内 // 如果不在保护范围内,就拦截或者回滚 } });更简单的做法是在编辑器外层加一层业务校验:拿到用户提交数据后,在后端再次校验“提交的字段是否都位于允许范围内”。前端保护是为了体验,后端校验才是底线。这个原则放在任何表格权限场景都适用。
4. 进阶:协同填写与后端保存的完整方案
4.1 多用户同时填写时保护区域怎么保证
如果你只是把一张带保护的工作表发给用户,每个用户独立打开、独立填写,那权限控制很清晰。但现实里经常出现几十个人同时打开同一张表,各自往自己那一行填数据。Univer 本身支持协同编辑(底层可以用 WebSocket、Yjs 等同步),但协同模式下保护逻辑的复杂度会上升。
首要原则是:不要把后端权限校验寄托在前端保护上。前端保护只是 UI 层面的限制,协同服务收到操作指令后,必须自己校验这个 range 是否允许写入。否则一个懂点前端的人,可以绕过界面直接调协同同步接口,把锁定区域的数据改掉。
具体操作层面,我建议把“保护配置”提升为后端的一张配置表:这张表里存了工作簿的 unitId、sheet 的 subUnitId、允许编辑的 ranges 列表。用户发起编辑时,协同服务先查配置表,校验操作范围再决定是否放行。如果业务里用到了 Univer 官方协同方案,可以基于其命令广播机制,在命令进入同步管道之前加一个鉴权中间层。
4.2 把填写结果持久化到后端
填表业务最终要落库。Univer 里读取用户填写内容有几种做法:
- 用户填完后,前端统一从表格实例中取出整个数据区。
- 监听单元格变更事件,实时增量提交。
第一种做法适合“填完点提交”的流程。代码大概这样:
const worksheet = univer.getActiveWorkbook().getActiveSheet(); // 读固定区域的数据 const rangeData = worksheet.getRange({ startRow: 2, endRow: 19, startColumn: 0, endColumn: 3, }); const rows = rangeData.map(row => ({ name: row.cells?.[0]?.v, department: row.cells?.[1]?.v, email: row.cells?.[2]?.v, accommodation: row.cells?.[3]?.v, }));然后把这组对象 POST 到后端接口。这里的重点是:读取数据时不要读全表,只读你允许填写的区域,既减少不必要的传输,也天然规避了越权数据被带上来的风险。
第二种做法适合表格长期打开、自动保存的场景。Univer 的命令服务有对应事件:
univer.getCommandService().onCommandExecuted((command) => { if (command.id === 'sheet.command.set-cell-value') { // 把 command.params 里的 values 增量提交 } });增量提交要做防抖,不然用户连续输入十几个字符会打出十几条请求。我习惯把变更先缓存到一个 Map 里,用 500ms 的定时器统一上报。
4.3 配合表单校验与数据联动
“能编辑”和“能填对”是两回事。用户虽然只能写指定区域,但写出来的内容可能格式完全不对。Univer 在填表场景下最好开启数据校验能力。
比如邮箱列,可以在模板配置里给单元格加上校验规则,或者使用公式做判断。Univer 支持在初始化时给单元格指定 validator;在填表业务中,更实用的做法是监听值变更,在 UI 上实时提醒。这里有一个经验:校验规则不要写在保护配置里,也不要散落在模板各处,最好集中在一个数据字典结构里带进模板,这样后端校验和前端提示共用同一份规则,避免两边不一致。
举个例子,邮箱列的校验规则可以定义为:
{ type: 'regex', pattern: '^[\\w.+-]+@[\\w-]+(\\.[\\w-]+)+$', message: '邮箱格式不正确', }用户填完不合法,提交按钮置灰;只有全部合法才能提交。这提升了表格的可用性,也让后台少收很多脏数据。
5. 常见问题与避坑指南
5.1 保护开启后锁定区域仍能编辑
这是我在社区里看到最多的问题,自己第一次也遇到。排查顺序如下:
- 确认 protection 的
sheet是否真的为 true。很多人只设置了lockCells,没有把sheet打开,保护等于没启用。 - 确认
lockCells是否为 true。如果 lockCells 为 false,ranges 之外的区域默认可编辑,保护范围的含义反过来了。 - 确认 ranges 的
lock字段。可编辑区域要用lock: false显式标记。有些版本字段名是locked,拿到的示例代码里写的是lock就照抄,结果毫无反应。 - 确认执行的是重新设置整套 protection,而不是增量 patch。Univer 命令执行时通常会整体替换 protection 对象,所以每次更新都要把完整的 ranges 放进去。
5.2 公式计算、填充柄绕过保护
保护只能拦编辑命令,拦不住用户把可编辑区域的公式向下填充到锁定区域。如果想彻底避免这种问题,有两个思路:
- 在锁定区域不上公式,改由后端统一计算。
- 在事件层拦截填充操作,只允许在非锁定区域范围内执行。
第二种思路实现起来要监听比较底层的命令,比较麻烦。我的建议是:填表场景能不用公式就不用公式。表格的计算能力让管理员在后台设计模板时用,最终提交给用户的填表视图,尽量只展示普通文本和数字,把公式计算挪到保存后的结果页。
5.3 Excel 导入与保护兼容性问题
Univer 支持导入 xlsx,但导入文件的保护配置不一定能完整还原。Excel 里“允许用户编辑区域”是通过范围安全性设置的,Univer 从 xlsx 里解析时可能会丢失或者转换偏差。如果业务要求管理员先上传 Excel 模板,再由系统启用填表模式,我的建议是:上传后不要依赖原文件的保护属性,而是按业务规则重新生成 protection ranges。
怎么做呢?上传后先解析 Excel 的单元格结构,然后通过规则匹配出可编辑区域。比如约定“所有带黄色背景的单元格为可编辑区域”,解析时读单元格背景色,把这些坐标转成 ranges。这样做的好处是模板设计者不需要懂 Univer API,只要在 Excel 里涂色就行。
5.4 大表格初始化性能
填表模板一般不会太大,但如果管理员从 Excel 导入了几千行数据,Univer 初始化时全量渲染会卡顿。优化手段有几个:
- 减少初始 cellData 里所有单元格都赋空对象的情况,Univer 对稀疏数据渲染更友好。
- 隐藏不必要的行列,不要让用户看到空白区域。
- 条件格式和校验规则不要铺满整张表,只设置在真正的数据区域。
- 如果可编辑区域很单调,优先用 range 规则代替逐格样式,尽量减少单元格级对象数量。
6. 一套可直接使用的“可填写表格”配置参考
6.1 模板配置速查
最后给一份完整的、可以改改就用的配置。我用“工时统计”举例,管理员每月发一张表给组员填写,组员只能填“项目名称、工时、说明”三列,其他列锁定。
工作表规划:
- 第 1 行:合并标题。
- 第 2 行:列名:项目编号、项目名称、工时、说明。
- 第 3 行到第 20 行:填写区。
- 项目编号列锁定,内容由系统写入;项目名称、工时、说明列可编辑。
配置如下:
const timesheet = { id: 'timesheet-2025-06', name: '6月工时', rowCount: 20, columnCount: 4, cellData: { 0: { 0: { v: '2025年6月工时登记表', s: { bl: 1, bg: '#f2f2f2', locked: true } }, 1: { v: '', s: { locked: true } }, }, 1: { 0: { v: '项目编号', s: { locked: true, bg: '#e8e8e8' } }, 1: { v: '项目名称', s: { locked: true, bg: '#e8e8e8' } }, 2: { v: '工时', s: { locked: true, bg: '#e8e8e8' } }, 3: { v: '说明', s: { locked: true, bg: '#e8e8e8' } }, }, }, protection: { sheet: true, lockCells: true, ranges: [ { range: { startRow: 2, endRow: 19, startColumn: 1, endColumn: 3 }, lock: false, }, ], }, };用户打开后,项目编号列由系统预先填好,用户只能填项目名称、工时、说明三列。这样收集上来的数据非常规整,后端解析也方便。
6.2 事件联动:填写完成后的处理
一个比较好用的小技巧是,监听单元格变更之后,把变更单元格标成其他背景色,这样用户一眼就能看出自己填了哪些格子,管理员也能快速判断哪些数据是新增的。核心代码就几行:
univer.getCommandService().onCommandExecuted((command) => { if (command.id === 'sheet.command.set-cell-value') { const { unitId, subUnitId, values } = command.params; // 遍历 values,把对应单元格背景色置为浅绿或浅黄 } });要提醒一句:这个监听事件非常频繁,做样式更新时最好合并批处理,避免每输入一个字符就重绘一次。我一般把待更新格子攒到一个数组里,等事件循环空闲时统一应用。
这个“可填写区域控制”的功能,真正的关键不在 API 调用,而是把权限模型想清楚。前端保护做得再好,也只是给用户一个顺畅的操作边界,后端必须持有同一份规则做最终校验。我在实际项目里被坑最惨的一次,就是前端保护全做好了,协同接口漏了校验,结果用户直接绕过界面改掉了锁定的公式列。后来我把 ranges 配置抽成公共模块,前后端共用,问题才彻底消失。你动手做的时候,建议第一步就先设计好这份公共配置,再碰 Univer 的代码,后面会省掉非常多的返工。