1. Univer是什么,为什么值得关注
1.1 从Excel到Web表格的技术路线变化
在线电子表格这个领域,前几年最热闹的还要数Luckysheet,一时间铺天盖地的国产开源Excel方案都围着它转。但Luckysheet后来基本停止维护了,它的核心思想和技术积累并没有消失,而是以一个新项目的形态继续演进,这个项目就是Univer。
Univer定位是面向Web的办公套件,前身继承了不少Luckysheet的设计思路,但底层完全是用TypeScript重写过的。它最鲜明的特点是:整个表格区域用Canvas分层渲染,而不是传统的DOM节点渲染。你滚动表格、拖动选区、输入内容时看到的所有画面,本质上都是绘制在Canvas上的图形,这样做的好处是单元格数量大时性能不会像DOM方案那样迅速崩塌。
对我这种做了多年B端项目的人来说,Univer真正吸引我的地方在于它的插件化和命令化架构。整个编辑器不是一堆代码拧在一起的大杂烩,而是拆成核心引擎、渲染引擎、UI插件、公式引擎、数据校验插件等模块。你要公式就挂公式插件,要数据校验就挂数据校验插件,不用的功能可以不加载,项目体积和复杂度都可以控制。
1.2 Univer的核心能力一览
如果只用一句话概括,Univer能让你在浏览器里跑一个接近Excel体验的表格编辑器。拆开来看,它目前比较成熟的能力包括:
- 电子表格核心:单元格编辑、选区操作、行列调整、合并单元格、冻结窗格,这些基础操作是养家糊口的本事,做得比较扎实。
- 公式系统:内置了几百个函数,支持跨工作表引用,公式引擎是独立的一个模块,计算和渲染分离。
- 数据校验:下拉列表、数字范围、文本长度、自定义公式校验,这个能力在填单场景里特别重要。
- 条件格式、图表、透视表:复杂数据分析场景能撑起来。
- 单元格与工作表保护:这是这篇文章的重点,也是实现“用户只能填指定单元格”的关键机制。
- 协同编辑:Univer有协同设计的底子,虽然生产环境中多数团队还是用“写数据再刷新”的方式,但如果你要上多人实时协作,它有对应的能力基础。
还有一个很实际的优势:Univer官方提供了一个在线Playground,你打开官网就能直接在一个Demo页面里试玩表格的全部交互。很多团队评估方案时,第一步不是看文档而是先上手玩一玩,这步它做得很好。
1.3 为什么选Univer而不是其他方案
市面上Web表格方案一大堆,我简单拉个对比,你就明白我的取舍逻辑了:
| 方案 | 许可证 | 技术路线 | 维护状态 | 适合场景 |
|---|---|---|---|---|
| Univer | Apache 2.0 | TypeScript + Canvas + 插件化 | 活跃,迭代快 | 需要深度定制、私有化部署的B端产品 |
| Luckysheet | MIT | 早期Canvas方案 | 基本停更 | 老项目维护、少量新项目 |
| Handsontable | 商业授权(部分免费) | DOM渲染 | 稳定 | 数据录入型表格,功能相对轻 |
| x-spreadsheet | MIT | Canvas | 功能简单 | 轻量需求,快速演示 |
Handsontable的功能边界比较轻,对复杂公式和图表支持有限,而且商用要买授权。x-spreadsheet更像是一个“能看的表格”,真要拿去做复杂业务你会发现处处受限。Luckysheet虽然是MIT协议,但代码已经停滞,遇到浏览器兼容问题或者想新增功能,只能自己硬啃。
Univer出身于Luckysheet团队,Apache 2.0协议改起来压力小,而且项目本身还在高速迭代。我做技术选型时有一个朴素标准:如果一个开源项目半年没有新提交、Issues堆积成山,那不管它现在多好用,未来都是风险。Univer目前显然不在这个名单里。
2. 核心需求拆解:用户只能编辑指定单元格
2.1 这个需求背后的真实场景
我做过的项目里,这个需求出现得非常频繁,而且形态高度一致。背景通常是:某个部门提供一份Excel模板,希望外部协作方填几个字段,但绝对不能让他们破坏模板的格式和结构。典型场景包括:
- 人事部门发员工信息收集表,员工只需填姓名、部门、联系方式,其他列是系统自动计算或管理员维护的。
- 财务部门做费用报销填报,用户填金额和事由,审批状态、流水号、税额计算列是锁死的。
- 供应链场景中供应商填报价,一旦提交就不能改动模板里的产品清单和报价公式。
这种需求在Excel里是老传统了:先保护工作表,再把允许编辑的单元格设置为不锁定。Univer继承了这个交互模型,所以你在Univer里做同样的事情,思路完全一致——先解锁白名单区域,再打开工作表保护开关。这两个动作缺一不可,很多人踩坑就是只做了其中一个。
2.2 工作表保护的工作机制
Univer里的保护机制分两级,理解清楚这两级,后面写代码就不会糊涂:
第一级是工作表保护(Worksheet Protection)。它像一个总闸,开启后整张表进入受保护状态,普通用户不能对锁定的单元格做任何修改。同时它还管着一堆细粒度权限:能不能选择锁定单元格、能不能插入行列、能不能删除行列、能不能排序、能不能用筛选,这些都可以单独开关。
第二级是单元格锁定状态(Cell Locked)。每个单元格样式里都有一个保护属性,默认是锁定状态。当工作表保护开启时,“锁定”的单元格不可编辑;反过来,如果某个单元格样式里把锁定状态解除了,即使工作表保护开着,它依然可以编辑。
这两个机制配合起来就是经典的白名单模式:默认全部锁定,然后把需要用户填写的区域逐个解锁。你的业务逻辑就是在生成模板时,循环你要放开的几个范围,把对应单元格的保护属性改掉,最后调用保护命令开启总闸。
2.3 数据校验是兜底输入质量的关键
单元格锁定解决的是“能不能改”的问题,数据校验解决的是“改成什么”的问题。填单场景里这两件事必须同时做,因为你可以让用户填写“部门”这一格,但用户如果是自由文本输入,他填“研发”、“研发部”、“研发一部”都可能,数据一多统计时就是一场灾难。
Univer的数据校验插件支持常见的校验规则:整数、小数、日期范围、文本长度,还有一个特别实用的列表校验——也就是下拉选择。下拉校验的公式格式和Excel一致,比如:
"研发部,产品部,市场部"这样用户点开单元格只能从这三个值里选,既保证了数据整洁,又减少了输入成本。数据校验还可以配置是否允许为空、错误时是否弹提示,这些细节在真实场景里都很重要。
2.4 容易被忽略的配套细节
在实际做模板时,有几个配套细节特别容易被忽略。第一个是隐藏列和隐藏行。模板里经常需要放一些辅助计算列,这些列用户不应该看到,但公式可能引用它们。正确做法是把辅助列隐藏起来,同时保持工作表保护状态,这样用户既看不到也改不了。
第二个是公式的保护语义。你可能会把“税额=金额*税率”这种计算逻辑直接写在单元格里。如果只是锁定这个单元格,用户虽然不能改结果,但双击单元格还是可能看到公式内容。在Excel里这叫“隐藏公式”功能,Univer对公式单元格也有类似的隐藏控制,属于单元格保护属性的一部分,做财务类模板时这个一定要开。
第三个是从安全角度说的:前端锁定不是安全边界。任何前端限制都只防君子不防小人,因为请求还是可以伪造。后端在接收提交数据时,必须对每个字段做白名单校验,不能信任前端传过来的任何字段是否“允许修改”。我见过不止一个项目只做了前端锁定、后端直接把整张表数据落库,结果被懂行的用户绕过界面改了锁定列,这种事情一旦发生就是事故。
3. 实操:搭建Univer填单应用
3.1 工程初始化与依赖安装
我这边以Vite + Vue 3工程为例,React版本思路完全一样,因为Univer本身就是框架无关的,官方对Vue和React都有示例。
先创建一个基础工程:
pnpm create vite univer-form-demo --template vue-ts cd univer-form-demo pnpm install然后安装Univer相关依赖。这里我按目前主流的包结构给你列一份,如果你安装的版本更新,包名可能会有调整,这一点后面细说:
pnpm add @univerjs/core @univerjs/design @univerjs/engine-render @univerjs/engine-formula pnpm add @univerjs/ui @univerjs/sheets @univerjs/sheets-ui @univerjs/sheets-formula pnpm add @univerjs/sheets-data-validation如果你是赶项目、不想纠结插件拆分,Univer官方还提供了预设包(preset),一行注册就能把常用插件挂全,适合先跑通流程再逐步拆。
3.2 初始化Univer实例
初始化代码放在一个独立模块里,避免在组件里反复创建实例。最基础的初始化长这样:
import { Univer } from '@univerjs/core'; import { RenderEngine } from '@univerjs/engine-render'; import { UniverUI } from '@univerjs/ui'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsUI } from '@univerjs/sheets-ui'; import { UniverSheetsFormulaPlugin } from '@univerjs/sheets-formula'; import { UniverFormulaEnginePlugin } from '@univerjs/engine-formula'; import { UniverSheetsDataValidationPlugin } from '@univerjs/sheets-data-validation'; import { defaultTheme } from '@univerjs/design'; import zhCN from '@univerjs/locale/zh-CN.json'; const univer = new Univer({ theme: defaultTheme, locale: zhCN, locales: { zhCN }, }); univer.registerPlugin(RenderEngine); univer.registerPlugin(UniverUI, { container: document.getElementById('app')!, }); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsUI); univer.registerPlugin(UniverFormulaEnginePlugin); univer.registerPlugin(UniverSheetsFormulaPlugin); univer.registerPlugin(UniverSheetsDataValidationPlugin);注意一点:容器元素的尺寸必须有确定值。很多人初始化后表格不显示,八成是挂载的div高度是0,Univer不会帮你撑高度。我习惯给容器写死一个高度,或者做自适应时先测好尺寸再初始化。
3.3 创建带模板数据的表格
Univer创建表格数据用的是一套JSON结构,和Luckysheet的数据格式一脉相承。我直接贴一个最简单的员工信息收集模板,三列十几个行,表头给出来,内容区域留白:
univer.createUniverSheet({ id: 'employee-form-001', name: '员工信息收集表', sheetOrder: ['sheet-01'], sheets: { 'sheet-01': { id: 'sheet-01', name: '信息填写', rowCount: 200, columnCount: 26, cellData: { 0: { 0: { v: '姓名' }, 1: { v: '部门' }, 2: { v: '联系方式' }, }, 1: { 0: { v: '' }, 1: { v: '' }, 2: { v: '' }, }, }, }, }, });这里不理解的读者也不用慌:cellData是一个二维映射,外层键是行号,内层键是列号,v是单元格的值。实际项目中,模板数据通常是从后端接口拿的,你拿到JSON后直接塞给createUniverSheet就行。这也意味着模板本身可以去后台维护,后端改了一版,前端渲染出来就是新模板,无需发版。
3.4 实现单元格锁定与工作表保护
现在到重头戏:让用户只能填写指定区域。上面分析过,核心就是两步,先把允许填写的区域解锁,再开启工作表保护。
第一步,把需要填写的区域(比如第二行到第十行的前三列)全部解锁。这里我用的是命令服务的方式,这样的好处是改动会正确进入Undo/Redo链路,不会把内部状态搞乱:
import { ICommandService } from '@univerjs/core'; import { SetRangeValuesMutation } from '@univerjs/sheets'; const commandService = univer.getCommandService(); async function unlockRange(sheetId: string, startRow: number, startColumn: number, endRow: number, endColumn: number) { await commandService.executeCommand(SetRangeValuesMutation, { sheetId, range: { startRow, startColumn, endRow, endColumn, }, values: { // 这里需要按行按列构造单元格对象,设置保护属性为“不锁定” }, }); }第二步,开启工作表保护。这一步相当于Excel里“审阅-保护工作表”,命令大致如下:
await commandService.executeCommand(SetWorksheetProtectionMutation, { sheetId: 'sheet-01', protection: { lock: true, allowSelectLockedCells: false, allowSelectUnlockedCells: true, }, });这里我要特别提醒:Univer的版本迭代非常快,我上面写的命令名和参数结构是当前版本的写法,你实际安装的版本可能有出入。遇到类型报错或者命令找不到,不要死磕,直接去查你锁定的版本对应的文档和类型定义。代码结构和思路是稳的,但API名字这种东西,版本一升级就可能变。
表格初始化后,你直接在界面上点那些解锁的区域,是可以正常输入的;点表头或者其他锁定区域,会提示这格不能编辑。整个交互和Excel保护工作表之后的体验非常像。
3.5 给可编辑区域加下拉校验
单元格解锁只是让用户能填,数据规范性要靠数据校验来管。我给“部门”这一列(B列)设置一个下拉列表,让用户只能从预设部门里选:
await commandService.executeCommand(SetDataValidationCommand, { ranges: [ { startRow: 1, startColumn: 1, endRow: 199, endColumn: 1, }, ], rule: { type: 'list', formula1: '"研发部,产品部,市场部,财务部,人力资源部"', allowBlank: true, showErrorMessage: true, }, });这样用户在B列点击单元格时,会看到下拉箭头,只能从给定的五个部门里选择,想乱填就填不进去。
如果你不想写代码配置校验,Univer的UI界面上也提供了数据校验入口,鼠标点一点就能配置。生产环境我更推荐代码配置,因为模板是动态生成的,每个客户部门列表都不一样,写死在界面上没法维护。
3.6 获取填写结果与导出
用户填完数据,前端要做两件事:一是把数据回传给后端,二是如果业务需要,导出成Excel文件。
读取用户填写的区域,用Univer的Range API:
const workbook = univer.getUniverInstance(UniverInstanceType.UNIVER_SHEET); const sheet = workbook?.getActiveSheet(); const range = sheet?.getRange(1, 0, 10, 2); // A2:C11 const values = range?.getValues();拿到的是一个二维数组,你遍历之后组装成提交数据,发给后端接口就行。
导出Excel文件在Univer里也有对应的命令,注册了导出插件后,调用导出命令就能让用户下载.xlsx文件。我做的项目还有一个变体需求:提交前先让用户确认一遍,我通常不导出文件,而是把填好的数据渲染到一个解析页面让用户核对,确认后再落库。这个做法多一步开发,但能省掉很多“我填错了你帮我改回来”的扯皮。
4. 常见问题与排查技巧
4.1 锁定和保护不生效
这是这个需求下遇到最多的问题,排查路径基本是固定的。先确认你是不是两个步骤都做了:单独解锁单元格没开保护,等于没锁;单独开保护但没解锁区域,用户连该填的格子也点不动。
第二个常见原因是版本API变更导致保护命令根本没执行成功。你可以打开控制台看命令返回结果,是成功还是报错。因为Univer的命令系统所有操作都有返回状态,排查时不要只看界面,先看日志。
第三个原因藏得比较深:如果你的模板是通过导入xlsx文件得到的工作簿,那么导入进来的单元格样式可能被文件里的格式覆盖。也就是说,你在前端代码里“解锁”了某个区域,但导入的文件里恰好那个区域被设置了锁定样式,两边打架,最终表现不可预期。我的建议是模板尽量用Univer的JSON结构定义,不要依赖导入,或者导入后统一刷一遍目标区域的样式。
4.2 公式显示#NAME?或者不计算
看到表格里的公式不计算,先检查公式引擎插件有没有注册。Univer的公式是独立引擎,不注册UniverFormulaEnginePlugin和UniverSheetsFormulaPlugin,公式就只是纯文本,不会出结果。
还有一个容易踩的坑是:公式计算是异步的。如果你在创建表之后立刻读单元格,公式结果可能还没算出来,拿到的是空值或#NAME?。正确做法是等公式计算完成事件之后再去取值,或者主动触发一次计算再读取。
4.3 表格渲染空白或错位
我遇到过一次非常典型的渲染问题:表格所在的页面是Tab切换结构,表格初始化时所在的Tab是隐藏状态,容器宽度为0。Univer在容器不可见时执行了布局计算,切回来之后Canvas没有重新测量尺寸,于是表格显示不全,或者渲染出来一块空白。
解决方案分两种:一种是等Tab切到前台、容器可见后再初始化Univer,这是最省心的方式;另一种是初始化后监听容器尺寸变化,调用Univer的刷新接口重绘。如果你做的是响应式布局,窗口缩放后表格错位,多半也是同一个问题——容器resize后没有通知渲染引擎重算。
4.4 大数据量下的性能取舍
Univer的Canvas渲染比DOM方案强很多,在常规数据量(几千行)下表现很流畅。但如果你硬塞进去几十万行、每行还带公式和样式,那任何前端表格方案都会吃力。
我在实际项目里的处理方式是分层:用户看到的是一个“待办视角”的表格,只展示和当前用户相关的行范围;真正的全量数据在后端。换句话说,表格当交互层用,数据存取走服务端接口,需要聚合统计时后端算好结果再回填表格。这种架构下,表格的渲染压力被控制住了,同时功能完整度没有打折扣。
4.5 权限模型要前后端配合设计
最后再强调一遍权限问题。我用Univer实现的单元格锁定,本质是前端交互层面的约束,它服务于用户体验,也拦截了大部分误操作,但它不是安全防线。
一个稳妥的设计是:后端下发模板时,同时下发一份“可编辑区域清单”的元数据;前端拿到清单后,动态把这些区域解锁并开启保护;用户提交时,后端根据同一份清单做字段级校验,不接受清单之外的字段修改。这样即使有人绕过前端直接POST脏数据,后端也会拒绝。审计方面,我建议给提交记录加上操作时间、提交人、设备信息,特别是财务、人事这类敏感场景,出现纠纷时至少能追溯。
个人体会
把Univer用在填单场景,前后做了快两个月,整体感受是它把Excel里最核心的交互模型搬到了Web上,而且做得足够灵活。我最开始走弯路的地方就是老想着“前端一把梭”,试图在表格初始化后硬改内部数据模型,结果要么样式对不上,要么保护状态混乱。后来想通了:Univer的命令系统就是设计来干这个事的,遵循它的规则走,比自己绕开接口改内部状态省心得多。
如果你要接新的填单项目,我建议先别急着写业务代码,把这篇文章里的锁定逻辑和数据校验思路在一个Demo页面上跑通,再往后端接口对接。另外一个小建议:Univer版本更新很频繁,项目里最好把依赖版本锁死,升级前先在分支上验证一遍核心功能,别让“顺手升级”变成“上线上火”。表格类功能的回归点又多又碎,版本升级的谨慎程度,值得参考银行系统改核心的那种心态。