“想用在线表格做一个报名表或者工单登记表业务同学打开网页就能填但只能改我指定的那几格标题、说明还有那些公式列一概不能碰”——这是我最近被问到次数最多的一个需求。字面听上去不复杂真正落到代码里才发现弯弯绕绕不少。如果你正在用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-renderCanvas 渲染引擎负责把表格画到页面上。这个分层直接决定了它的扩展性。比如你只想用表格编辑能力不想显示官方那一整条工具栏那完全可以不注册 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 }, ], }, });这里有两个 idunitId是工作簿的 idsubUnitId是工作表的 id。在我这个例子里两者都用了同一个字符串。如果你的业务里有两张 sheet那 subUnitId 就分别指向对应 sheet。很多初学时困惑的“为什么执行命令没反应”八成是这两个 id 没对上。调试时可以先打印工作簿和工作表的 idconst 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 为 falseranges 之外的区域默认可编辑保护范围的含义反过来了。确认 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 的代码后面会省掉非常多的返工。