ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Univer表格实践:自定义表格结构+指定区域可编辑+其余锁定

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 到 99999
E差异说明可编辑文本,可选填
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 命令拦截确实挡住了绝大多数用户的越界操作,但你的后端接口在接收提交数据时,依然要再校验一遍字段范围、字段类型和必填规则。前端锁是体验,后端锁才是底线。这两件事都做好,这个功能才算真正落地。

返回列表