Univer表格实践:自定义表格结构+指定区域可编辑+其余锁定 1. Univer 到底是个什么水平的开源表格项目做 Web 表格开发这些年我接手过不少类似的需求要一张在线表格用户能自己定义表头、自己设置哪些列需要填写但提交之后其他人只能往规定的格子里填数据连表头都不能碰。以前做这类功能通常是把 Excel 转成 HTML 表单或者用表单引擎一个字段一个字段去画页面每次业务改一个字段前端就要跟着改一轮结构维护成本高得离谱。最近在一个供应商信息采集项目里我用 Univer 重新做了这套流程感受完全不一样。Univer 是一个开源的在线电子表格项目核心能力是创建表格、编辑单元格、公式计算、数据校验、样式控制这些通通暴露在插件化架构里业务方可以自由组合和扩展。我在网上看到“univer 支持用户定义表格然后让用户去填写一些单元格其他的单元格用户无法修改”这种描述基本就是它最有代表性的应用场景。本文不打算复述官方文档而是围绕“用户定义表格结构 指定单元格开放填写 其余区域锁定”这条主线把思路、代码、边界和踩坑记录一次讲清楚。1.1 为什么会选 Univer而不是 SpreadJS 或 Luckysheet做在线表格组件选型的时候技术圈讨论最多的无非这几类商业表格控件如 SpreadJS功能确实强Excel 兼容度高但授权费用对很多中小团队并不友好而且一旦绑定商用组件后续扩展容易受制于人。Luckysheet早期开源界比较亮眼的 Canvas 表格项目社区热度一度很高但项目维护节奏不稳定公式能力、数据校验和插件生态都比较有限适合简单展示场景撑不起复杂业务。Handsontable内存模型和交互设计不错定位更偏数据网格而不是在线 Excel公式、下拉校验、样式这类能力需要再挂插件协同功能基本没有。Univer走的是模块化和插件化路线核心引擎用 TypeScript 编写渲染层基于 Canvas公式引擎内置UI、数据校验、导入导出、协同协议都是以插件形式提供团队目标很明确就是想做开源领域里接近 Google Sheets 体验的东西。Univer 并不是突然冒出来的它的文档和示例更新频率很高GitHub 上的 issues 也能看到团队在持续迭代。它把表格能力拆成了好几个独立包比如univerjs/core负责核心数据模型univerjs/sheets负责表格逻辑univerjs/sheets-ui负责渲染和交互univerjs/design负责主题皮肤。这种拆分带来的直接好处是我不需要为了一个填表功能把整个 Office 套件都引进来按需加载就行。1.2 它的工程底座为什么适合做权限化填表Univer 给我留下最深印象的不是画面渲染得多快而是它的命令系统。表格里的每一次操作不管是敲字、合并单元格、修改样式还是设置数据校验本质上都会变成一个 Command再经过管线执行。有了这层设计业务方就可以在命令执行前后插入自己的逻辑比如判断某个区域是否允许写入不允许就直接拦截。这一点对“其他单元格用户无法修改”的需求非常关键。如果只靠前端 CSS 把格子变灰用户打开控制台调一下接口照样能改如果只靠事件监听处理键盘输入用户通过复制粘贴、拖拽填充、脚本调用等方式仍然可能绕过。真正可靠的做法是在命令层拦截虽然用户界面和操作路径很多但最终要改动单元格数据一定会触发设置值的命令。只要这里把住关了锁定才真正锁得住。配合这套命令系统Univer 还有统一的操作入口FUniver写业务代码时不需要直接跟底层命令来回打交道用链式调用就能拿工作表、取单元格、设置值、加校验。我刚接触这个项目时花了一个下午把官方 examples 跑了一遍基本就摸清了它把“数据模型、操作命令、渲染 UI”三者分开的设计逻辑。理解了这一点后面所有功能做起来都比较顺。2. “只能填这几个格子”的需求拆开其实是四件事用户说“我要一张表格别人只能填一部分格子”听着很简单实际落到系统里至少包含四个子需求让用户自定义表格结构、锁定非填写区域、给填写区加输入约束、最后把填写结果安全地保存下来。任何一件没做好功能上线后都会被用户喷。2.1 用户自定义表格不等于把工具栏全部放开“用户自定义表格”可以有两种理解。一种是用户作为管理员像用 Excel 一样自由拖拽行列、改表头、设宽度然后系统把这份表保存成模板另一种是普通用户也能随便改表结构那就是管理失控了。我的建议是设计上一定要区分角色管理员编辑模板时工具栏可以放开普通用户打开的是实例化的模板只能往指定区域填内容。在 Univer 里“自定义表格”对应的底层动作包括创建 workbook、设置 sheet 的行列数、写入表头单元格、给表头加底色和边框、标注哪些区域是允许编辑区。这些动作都通过FUniver操作后端只需要保存一份表格快照之后用户打开表时从快照恢复即可。2.2 锁定其他单元格要分 UI 层和逻辑层两重很多从传统前端转过来的人第一反应是“锁定不就是把 input 变成 disabled 吗”。在线表格不是 HTML 表单所有单元格本质上都是画布上的图形没有天然的 input 节点所以“禁用”必须自己实现。UI 层的做法是把非编辑区域的单元格设置成灰底、浅色文字再监听选区变化当用户选中非编辑区域时弹一条提示逻辑层的做法是在命令执行前判断区域是否合法非法直接拒绝写入。这两层我建议都做但逻辑层优先级更高。UI 层只是给用户看的反馈逻辑层才是真正的防线。2.3 填写区的约束靠数据校验而不是靠自觉就算把非编辑区域锁死了用户往可编辑区域里填的内容也可能千奇百怪要求填数字他给你填个“12,000”要求从状态下拉里选他直接手输一个“正常 ”带空格。所以填写区必须挂数据校验规则。Univer 有数据校验插件支持列表、数值范围、日期区间、自定义公式校验还能配置错误提示行为。这块做好之后录入质量会明显提升。2.4 保存结果与回填是容易被忽略的最后一公里编辑完之后数据去哪、下次打开怎么恢复很多人做 Demo 时不考虑上线前一晚才发现抓瞎。我的做法是双层保存业务字段单独提取成结构化数据存业务表方便列表查询和统计整张工作表的快照单独存一份 JSON用于还原完整表格。这样既保留了表格的样式和结构又能做业务数据分析。把需求拆完就可以直接对照功能了需求Excel 里的对应做法Univer 里的实现思路表头和其他区域不可改保护工作表并锁定单元格命令层拦截写入 UI 置灰只允许填写指定区域取消锁定部分单元格对填写区放行其余拒绝表格结构由用户自己定义做成模板或宏用 FUniver 动态创建 workbook 和 sheet填写内容必须符合规范数据验证数据校验插件或自定义校验防止有人绕过界面改数据服务端校验 工作簿保护提交时后端重新校验整表数据3. 最小可跑通的实现可编辑区锁定的完整步骤纸上谈兵没用我直接给出一个能跑的代码骨架。需要说明的是Univer 的 API 还在快速迭代下面这些写法基于我写项目时的版本具体包名、方法名可能会有变化跑工程前先以官方仓库的 examples 为准。3.1 安装依赖和初始化先用 npm 装上核心包npm install univerjs/core univerjs/sheets univerjs/sheets-ui univerjs/ui univerjs/design univerjs/locale装包的时候一定要锁版本。这几个包之间是强依赖关系版本不一致很容易出现注册插件时报错或者白屏的问题。初始化示例import { Univer, LocaleType } 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 { zhCN, enUS } from univerjs/locale; const univer new Univer({ locale: LocaleType.ZH_CN, locales: { [LocaleType.ZH_CN]: zhCN, [LocaleType.EN_US]: enUS, }, theme: defaultTheme, }); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsUIPlugin); univer.registerPlugin(UniverUIPlugin, { container: app }); const workbook univer.createUniverSheet({ name: 供应商信息采集, sheets: [ { name: 填报页, rowCount: 200, columnCount: 10, cellData: { 0:0: { v: 供应商编码 }, 0:1: { v: 供应商名称 }, 0:2: { v: 联系人 }, 0:3: { v: 联系电话 }, 0:4: { v: 主营品类 }, }, }, ], });创建完之后表格会直接渲染到app容器里。表头这一行算是固定区域后续要锁就是从这个 sheet 的行列关系入手。3.2 定义“允许编辑区域”我的习惯是创建一个可编辑区域的清单用 A1Notation 表示比如B2:C200是指从第 2 行第 2 列到第 200 行第 3 列这个矩形区域。这个清单最好由管理员在模板设计页面勾选生成存到后端模板配置里而不是硬编码在前端。const editableRanges [B2:C200];接下来写一个判断函数给定某个单元格的行列索引检查它是否落在任一允许编辑区域里。关键是要把 A1Notation 转成行列范围再做区间比较。行列索引在 Univer 里是从 0 开始的这一点容易踩坑算边界的时候多检查一次。3.3 命令层拦截真正把非编辑区域锁住这是整个方案的核心。我的思路是拦截所有修改单元格值的命令检查命令涉及的矩形区域是否完全落在允许编辑范围内只要有任何一个格子越界整条命令直接取消。import { FUniver } from univerjs/core; const api FUniver.newAPI(univer); const sheet api.getActiveWorkbook()?.getActiveSheet();然后注册命令拦截器。以我使用的版本为例写法大致是const interceptor { beforeCommandExecute: (command) { const type command?.type; if (type ! SetRangeValueCommand type ! SpliceRangeValueCommand) { return; } const ranges command?.params?.ranges; if (!ranges || ranges.length 0) { return; } const allowed editableRanges.some((notation) { const range sheet?.getRange(notation); if (!range) return false; return true; }); for (const range of ranges) { const startRow range.startRow; const endRow range.endRow; const startColumn range.startColumn; const endColumn range.endColumn; for (let row startRow; row endRow; row 1) { for (let col startColumn; col endColumn; col 1) { if (!isInEditableRanges(row, col)) { alert(该单元格不可编辑); return { cancel: true }; } } } } }, }; // univer.registerCommandInterceptor(interceptor) // 注意不同版本注册方式有差异请以官方文档为准虽然不同版本的注册方式不太一样但拦截思路是通用的找到修改值的命令检查区域确认越界就取消。只要拦截住这一层用户不管是用键盘输入、拖拽填充、复制粘贴还是调 API 写数据都会被挡住。3.4 UI 层把锁定区域变成“一眼就能看出来”的样式命令层能拦住修改但用户体验上还差一步用户得知道哪些格子能填、哪些不能填。我一般初始化完表格之后遍历非编辑区域把背景色统一改成浅灰字体颜色调浅同时加一条提示栏说明填写规则。const lockedStyle { backgroundColor: #f5f5f5, color: #999999, }; for (let row 0; row 200; row 1) { for (let col 0; col 10; col 1) { if (!isInEditableRanges(row, col)) { const cell sheet?.getRange(row, col, 1, 1); cell?.setStyle(lockedStyle); } } }这里要注意给锁定区域上灰底只是视觉提示千万别把这一步当成安全措施。用户改不了靠的是命令拦截不是样式。3.5 提取填写结果并保存填写完成提交时遍历允许编辑区域取数组成结构化对象const result []; for (let row 1; row 200; row 1) { const values sheet?.getRange(row, 1, 1, 2).getValues(); // B列和C列 if (values values[0] (values[0][0] ! null || values[0][1] ! null)) { result.push({ row, contact: values[0][0] ?? , phone: values[0][1] ?? , }); } }同时把整张表的快照保存一份到后端const snapshot workbook?.getSnapshot(); // 把 snapshot 上传到服务端存档下次需要恢复这张表时把快照 JSON 回传Univer 可以基于快照重建。快照的好处是结构、样式、校验规则全部保留缺点是比较大所以业务字段要单独存一份用于查询统计。4. 光锁定还不够给填写区加校验、下拉和公式联动“能填”和“填得规范”是两码事。前面我们把其他单元格锁定了现在还要保证用户往允许区域里填的内容是可用的数据。4.1 为什么必须先做数据校验我见过太多因为录入不规范导致的返工金额列里出现字母、日期字段格式五花八门、状态列各人写法不统一。这些数据进了系统统计报表就全乱了。Univer 的数据校验插件正好解决这类问题配置起来也不复杂。4.2 下拉选择状态列只能选固定选项比如“主营品类”这一列我不想让用户随便输入而是从预设分类里选。用数据校验配一个列表规则import { DataValidationType } from univerjs/data-validation; const range sheet?.getRange(D2:D200); range?.setDataValidation({ type: DataValidationType.LIST, allowBlank: false, formula1: 电子元器件,仪器仪表,包装材料,其他, showErrorMessage: true, errorMessage: 请从下拉框中选择主营品类, });这样设置后用户在这一列只能从四个选项里选手输一个“电子元件”都会被拦下来。对于业务系统来说统一口径比什么都重要。4.3 数值和日期校验防止脏数据进系统电话列虽然我给的是文本但也可以做正则校验数量列可以限定只能填数字。Univer 支持数值范围校验和日期校验业务上按需加。这里我想强调一点数据校验规则本身要跟模板快照一起保存否则用户重新打开表时校验就丢了。4.4 公式联动合计列自动算不用人填填表场景里最经典的组合是录入项开放、计算项锁定。比如“库存盘点表”账面数量是系统生成的用户只能填实盘数量差异数量由公式自动计算。在 Univer 里可以把差异列设置为公式const formulaRange sheet?.getRange(F2:F200); formulaRange?.setFormula(D2-C2);公式列属于非编辑区域命令拦截会保证用户改不了。用户填完实盘数差异列会自动更新。这种设计最大限度减少了人为计算错误。4.5 一个完整的模板示例跑通把上面的东西串起来做一个简单的“库存盘点模板”列字段是否可编辑校验规则A商品编码锁定无B商品名称锁定无C账面数量锁定无D实盘数量可编辑数值0 到 99999E差异说明可编辑文本可选填F差异数量锁定公式D2-C2这套模板做出来后库存人员只需要关注 D 列和 E 列其他格子点都点不动。同样的思路可以套到报名表、排班表、设备巡检表等场景核心变化只在表头字段和校验规则。5. 在线表格的落地细节模板保存、加载与多人填报填表功能要真正上线还绕不开三个工程问题模板怎么存、多人怎么填、Excel 怎么进出。5.1 模板的保存与加载管理员在后台设计完表格模板后把 workbook 快照存到后端记一个模板 ID。普通用户打开页面时前端拿着模板 ID 请求快照 JSON然后基于快照重建一张新表。这里有个容易犯错的地方快照必须区分模板和实例。模板是结构用户填报后保存的是实例实例不能反过来污染模板否则下一个人打开时表头被别人改掉就麻烦了。我习惯在后端加一个status字段区分模板与实例模板只允许管理员修改实例在创建时会从模板深拷贝一份数据。这样就算某个用户在自己实例里瞎折腾也不影响其他用户。5.2 多人共同填报同一张表社区版该怎么处理Univer 官方对协同编辑有更高阶的支持但普通业务系统接入时不建议一上来就上完整的协同协议。更务实的做法是“分区填报”每个用户负责固定的行或固定的工作表提交时给数据加版本号后端比对版本一致才允许覆盖。这比实时协同简单得多也能避免覆盖风险。如果同一张表确实需要多个人同时改不同区域社区版条件下我建议按行拆分每人一个 Sheet 页最后后端统一汇总。实时协同确实爽但架构成本和调试成本相当高没有强需求别硬上。5.3 导入导出 XLSX 的边界需求方总喜欢说“能不能支持导入导出 Excel”。Univer 有对应的 xlsx 插件能处理常规数据和样式但下拉校验、区域锁定这类高级特性在不同格式之间转换时很容易丢失。我的做法是模板的创建、保存、再编辑全部走 Univer 原生快照保证功能不丢XLSX 只做展示用导出给客户下载查看即可不作为二次编辑的载体。这样既满足了“能导出 Excel”的需求又避免踩格式兼容的坑。6. 我实际接入 Univer 之后踩过的坑代码写起来只是第一步真正接到业务系统里我碰到的问题比预想多挑几个影响最大的记录一下。6.1 多个包版本不一致直接白屏Univer 的包拆得很细如果univerjs/sheets和univerjs/sheets-ui版本不一致UI 插件注册时会直接报错页面白屏。解决方案很简单但容易被忽略所有univerjs/*包用同一个版本号不要各自升级。我一开始没锁版本npm 自动装了一堆小版本差异排查了半天。6.2 命令拦截把撤销操作也拦掉了第一次写拦截器时我把所有修改类命令都拦了一遍结果用户撤销操作也被拦住了。原因很简单Unreal 的“撤销”本质上也会向命令系统发送命令如果不区分来源就会把合法的撤销当成非法写入给取消掉。后来我在拦截器里加了一层判断只拦截用户直接编辑单元格的命令遇到 undo、redo 或内部重置流程时直接放行。6.3 Canvas 渲染下别想着靠 DOM 改样式用惯了 DOM 的前端很容易想去document.querySelector找表格节点改样式。Univer 的表格内容渲染在 Canvas 上大部分单元格样式必须通过配置或 API 修改。比如给锁定区域置灰就要遍历单元格设置样式而不是直接改 CSS。这一点不改变思维的话会浪费很多时间。6.4 公式不自动重算先检查插件是否注册我在项目里遇到过填了实盘数量差异列却不变的情况。排查后发现问题不是公式写错了而是公式插件没有被注册到 Univer 实例里。Univer 把公式引擎做成了独立插件不注册就不会计算。注册完插件还要确认数据写入时机在公式依赖单元格刷新之后。6.5 大范围 setValues 卡顿一次性给几百行设置单元格样式或数据界面会有明显卡顿。我的做法是把操作分批每批只处理几十行用requestAnimationFrame或setTimeout把批次分散开避免主线程长期阻塞。如果数据量上千行建议放到 Web Worker 里做数据组装最后一次性提交结果。6.6 XLSX 导入后下拉校验丢失之前想让用户直接上传 Excel 回填数据试了几轮发现下拉列表、锁定区域这些特性在 xlsx 转换后基本保不住。从此我定了一条规则所有涉及校验和锁定的模板一律用 Univer 原生快照保存Excel 文件只出不进除非只是简单导入纯数据。6.7 移动端填表体验一言难尽在线表格毕竟是桌面端产品手机上缩小看表格很痛苦右键菜单、悬浮提示都不友好。如果业务里有人要拿手机填表我建议单独做一个移动端的表单页把填写字段渲染成普通表单控件不要试图在手机浏览器里用表格组件硬扛。7. 现在做在线填表我对 Univer 的判断用了几个项目之后我对 Univer 的定位越来越清晰它最适合预算有限、又需要接近 Excel 交互体验的团队。比起自己从零写一个表格引擎Univer 省掉的开发时间是几个数量级的比起商业控件它没有授权压力插件化设计也允许我们随时改源码和扩展新能力。它的短板也摆在明面上API 迭代速度快网上资料和官方文档有时候跟不上代码更新遇到问题需要直接翻源码Excel 高级特性的兼容程度还不能跟商业产品比复杂图表、透视表这类功能不必抱太高期待。所以选它之前团队里最好有人愿意啃源码否则调试成本会偏高。我个人的做法是先跑最小闭环再铺开用FUniver创建一个 sheet写一个命令拦截逻辑加一个数据校验最后保存一份快照。这四步大概一个下午就能验证核心链路是否跑得通。如果这套闭环没问题后面的功能都可以在这个基础上慢慢加。最后分享一个很实际的体会不要把“锁定”寄希望于前端任何组件。Univer 命令拦截确实挡住了绝大多数用户的越界操作但你的后端接口在接收提交数据时依然要再校验一遍字段范围、字段类型和必填规则。前端锁是体验后端锁才是底线。这两件事都做好这个功能才算真正落地。