Univer这个开源项目,我前后研究了挺长时间,最近终于在一个内部预算填报系统里真正落了地,踩了不少坑也攒了不少经验。简单说,Univer是一个基于Canvas渲染的在线表格基础设施,你可以在自己的前端项目里快速搭出接近Excel体验的电子表格应用,而且它从架构上就支持“给表格强加业务规则”。如果你正在做那种“让用户填一张表,但只能填某些格子,其他格子锁死”的功能,Univer大概是目前开源阵营里最适合深度二开的方案,这也是我今天想把整个实践过程完整聊透的原因。
这篇文章适合三类人:一是要在系统里嵌在线表格的前端工程师;二是做低代码平台、审批表单这类“表格即业务”产品的技术选型者;三是纯粹对国产开源表格方案感兴趣的开发者。我会从Univer的架构定位讲起,再重点拆解“用户只能填指定单元格”这个场景的几种实现思路,最后给你一份可以直接抄的集成代码和问题排查清单。
1. Univer是什么:新一代在线表格引擎的定位拆解
1.1 为什么需要表格引擎而不是表格控件
先明确一个概念。大多数前端方案比如Handsontable、x-data-spreadsheet,本质上是表格控件,它们擅长渲染二维数据、支持编辑、导出,但业务规则并不是它们的主场。而真实的业务系统里,表格并不只是数据容器,它往往是业务规则的载体:哪些人能改、改成什么格式才能提交、改完之后哪几列要自动联动计算、哪些单元格要下拉选择而不能手输。
Univer把这件事做成了“引擎”而不是“控件”。它把数据层、计算层、交互层、命令系统、插件系统都分层拆开,你在命令系统层面能拦截每一次编辑动作,在公式引擎层面能注册自定义函数,在渲染层能做自定义单元格,“受控表格”天然是该架构的优势场景,而不是靠后续打补丁硬做。
1.2 Univer的核心架构
我对Univer架构的印象是四个关键词:核心包、插件化、Canvas渲染、命令总线。
核心包是@univerjs/core,提供工作簿、工作表、单元格的数据模型,同时内置了命令服务、生命周期、插件注册机制。UI和交互分别拆在@univerjs/ui和@univerjs/sheets-ui里,公式引擎是独立的@univerjs/engine-formula,它采用类似Excel的计算模型,支持跨工作表引用。渲染引擎@univerjs/engine-render则用Canvas绘制整个表格区域,DOM节点极少,所以大数据量滚动依然很顺滑。
最影响二开体验的是插件化设计。你不需要的功能就不注册:注册一个公式插件就多公式能力,注册一个数据校验插件就多校验能力,注册一个协同插件就多多人协同能力。这种按需加载的模式,在打包体积和启动时间上都给了你控制权。
1.3 Univer和其他方案的对比
我在选型时对比过几套主流方案,直接放一张表:
| 方案 | 渲染方式 | 受控填报能力 | 公式引擎 | 协同能力 | 二开成本 |
|---|---|---|---|---|---|
| Handsontable | DOM/Canvas混合 | 弱,需业务层实现 | 无内置 | 无 | 中 |
| Luckysheet | Canvas | 一般,锁定能力有限 | 自带部分公式 | 社区版有限 | 中高 |
| SheetJS(纯导出库) | 无渲染 | 无 | 无 | 无 | 不适合在线编辑 |
| Univer | Canvas | 强,命令拦截+权限系统 | 独立公式引擎 | 有SDK方向 | 中高,但上限高 |
Univer打动我的点在于:它把“编辑动作”统一收拢到命令系统,这给了业务方一个非常优雅的干预窗口。你可以把一些特殊规则挂到命令执行前,随时阻止或者改写编辑行为,这是很多表格控件做不到的。缺点是它目前版本还在0.x阶段,API演进速度快,升级时要留意官方文档的变更记录。
2. 核心场景实战:让用户“只能填部分单元格”的实现思路
2.1 场景还原:一张真实的受控填报需求
先说一个我实际做过的需求:某生产车间的月度损耗申报表。表头固定、历史数据列固定、汇总行固定,只有“本月申报数量”和“备注”这两列的下方空白单元格允许填写,用户填完点提交,系统做汇总校验。
这种需求如果用Excel文件下发,用户很容易误改格式,回收后你还得解析校验;如果做成一个个输入框的普通表单,又丢失了表格上下文,用户找不到行和列对应的业务含义。最适合的形态就是“应用内嵌表格 + 单元格级编辑控制”。Univer刚好能同时提供表格浏览体验和表单级的输入约束。
需求可以拆成四层:第一层,只读区域锁死;第二层,可填写区域加上输入校验;第三层,部分列根据填报值自动计算(比如差额 = 计划数 – 申报数);第四层,提交时统一取数并做后端校验。下面我会把每层的实现思路都说清楚。
2.2 方案一:用工作表权限与单元格锁定
Univer支持在工作簿、工作表、单元格范围级别设置权限点,思路和Excel的“保护工作表”很像。先给不需要编辑的单元格在样式里写上locked: true,再启用工作表的保护状态,这样锁定区域就不可编辑了;而没有加锁的单元格,也就是你想让用户填写的格子,保持默认可编辑。
这里有一个关键点容易忽略:单元格样式里的locked属性本身不直接禁止编辑,它必须配合工作表的保护状态才生效。你可以理解为Excel里“锁定单元格”和“保护工作表”是两件事,模板做完了要点击“保护工作表”才算数。
在Univer中,初始化工作表时可以通过给工作表设置权限点来启用保护,也可以在执行层调用命令服务去改变权限状态。通常我会做一个“模板管理”的切换:模板编辑阶段放开所有单元格,模板发布后开启保护,这样管理员维护模板和用户填报就是两套状态,互不干扰。
2.3 方案二:在命令系统拦截编辑命令(通用兜底方案)
权限系统的API有时候覆盖不了太灵活的规则,比如“周一允许改、周二不允许改”这种时间窗规则,或者“超过某个阈值后整列禁止输入”这种动态规则。这种时候我推荐用命令拦截做兜底,这也是Univer架构里我最喜欢的一个设计。
Univer里一切交互最终都会派发命令,比如修改单元格值对应的是SetRangeValuesCommand。你可以在命令真正执行之前用ICommandService.beforeCommandExecute$订阅到它,检查目标范围是否落在你允许编辑的区域内,如果不合法就返回false阻止执行。
我这里给一段简化版的核心逻辑,直接看代码更直观:
import { ICommandService, SetRangeValuesCommand } from '@univerjs/sheets'; // 假设我们只允许第10行到39行、B列(第1列)的单元格被编辑 const editableRange = { startRow: 10, endRow: 39, startColumn: 1, endColumn: 1, }; function isRangeEditable(range) { // 这里可以扩展为多区域判断,也可以从后台动态拉取可编辑区域 return ( range.startRow >= editableRange.startRow && range.endRow <= editableRange.endRow && range.startColumn >= editableRange.startColumn && range.endColumn <= editableRange.endColumn ); } // 获取命令服务并订阅命令执行前事件 const commandService = univer.__getInjector().get(ICommandService); commandService.beforeCommandExecute$.subscribe((command) => { if (command.id === SetRangeValuesCommand.id) { const params = command.params; if (params && params.range && !isRangeEditable(params.range)) { // 返回 false 表示阻止该命令执行 return false; } } return true; });这段代码的好处是通用。不管是用户点击单元格输入,还是通过工具栏粘贴、拖拽填充,最终都会走SetRangeValuesCommand,拦截一次就能覆盖大多数入口。需要注意的坑是:部分批量操作命令不一定叫这个名字,比如粘贴可能走的是专门的多区域写值命令,实际开发时要把所有能写值的命令ID都枚举出来,缺一个就可能漏拦截。
2.4 方案三:数据校验 + 自定义公式联动
“只能填部分单元格”只是第一步,填得“规不规范”同样重要。Univer有数据校验插件,你可以给可填写区域配置下拉选项、数值范围、日期格式、长度限制等约束,效果类似Excel的数据验证。用户输入不合法,单元格会立刻标红,这比提交后再报错体验好很多。
联动计算则可以让公式插件来做。Univer的公式引擎支持自定义函数,也就是说如果你们业务里有一些特殊计算逻辑,比如“按设备类型加权汇总”“根据申报数量反推损耗率”,你可以注册一个自定义公式名称,然后像普通Excel函数一样写在单元格里,由Univer的公式引擎自动重算。
我用过一个自定义函数的例子,场景是计算“填报达成率”,其中分子在表格里填,分母来自外部接口,这个函数就负责取外部数据并完成百分比格式化。注册之后,公式列在每次填报数据变更后都会自动重算,不用前端额外监听单元格变化再手动更新DOM。这个能力很实用,省去了很多“把公式结果写回单元格”的脏活。
3. 实操过程:从零集成Univer并实现受控填报表格
3.1 环境准备与依赖安装
我以Vite + TypeScript + React为例,其他框架思路也差不多。先初始化项目:
npm create vite@latest my-univer-app -- --template react-ts cd my-univer-app npm install然后安装Univer相关的核心依赖。需要注意Univer版本迭代比较快,我建议直接使用当前npm上的最新版本,并且保证核心包的版本一致,避免混用大版本:
npm install @univerjs/core @univerjs/ui @univerjs/sheets @univerjs/sheets-ui @univerjs/engine-render @univerjs/engine-formula @univerjs/design如果你需要数据校验和协同相关能力,再额外安装对应的插件包:
npm install @univerjs/sheets-data-validation @univerjs/sheets-formula装完之后可能会遇到样式文件的问题,记得要把Univer的CSS样式引入到入口文件里,通常是@univerjs/design/lib/index.css和@univerjs/ui/lib/index.css,具体路径以包内结构为准。
3.2 初始化Univer实例并渲染基础表格
初始化Univer实例有几个固定步骤:创建Univer对象、注册基础插件、在DOM节点挂载、然后通过API创建一个包含工作表的工作簿。
import { Univer, UniverInstanceType } from '@univerjs/core'; import { defaultTheme } from '@univerjs/design'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui'; import { UniverFormulaEnginePlugin } from '@univerjs/engine-formula'; import { UniverRenderEnginePlugin } from '@univerjs/engine-render'; // 创建Univer实例 const univer = new Univer({ theme: defaultTheme, }); // 注册插件(按需) univer.registerPlugin(UniverRenderEnginePlugin); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsUIPlugin); univer.registerPlugin(UniverFormulaEnginePlugin); // 创建一个工作簿并获取Sheet实例 const workbook = univer.createUniverInstance(UniverInstanceType.UNIVER_SHEET, { id: 'budget-report', name: '预算填报系统', sheets: [ { id: 'sheet-1', name: '月度损耗申报', rowCount: 100, columnCount: 20, cellData: {}, }, ], });创建完成后,在React组件里挂载一个div容器,然后用univer.mountSheetElement()之类的方法把表格渲染到指定DOM节点。这块属于Univer的基础接入流程,按照官方Demo走基本一遍就能通。
3.3 实现“模板锁定 + 可填单元格”的完整代码
在上一节基础实例之上,我给一个比较完整的“模板锁定 + 可填单元格”实现。
思路分两步:第一步,给需要只读的区域设置样式锁;第二步,启用保护模式,让锁定单元格真正不可编辑。这里的代码我把重点放在Univer的命令和权限服务上,因为不同版本API名称可能略有差异,你结合当前版本的类型定义稍作调整即可。
import { ICommandService, SetWorksheetProtectionCommand, SetRangeStyleCommand } from '@univerjs/sheets'; // 给只读区域设置锁定样式 function lockRange(univer, rowStart, rowEnd, colStart, colEnd) { const commandService = univer.__getInjector().get(ICommandService); commandService.executeCommand(SetRangeStyleCommand.id, { range: { startRow: rowStart, endRow: rowEnd, startColumn: colStart, endColumn: colEnd, }, style: { lock: true, // 这里需要结合版本确认属性名,有的版本是 locked }, }); } // 启用工作表保护 function protectSheet(univer) { const commandService = univer.__getInjector().get(ICommandService); commandService.executeCommand(SetWorksheetProtectionCommand.id, { // 保护级别、密码等参数以当前版本为准 protection: { type: 'protection' }, }); } // 初始化时调用 lockRange(univer, 0, 9, 0, 6); // 锁表头和固定列 lockRange(univer, 0, 99, 2, 19); // 锁除B列外的其他列 protectSheet(univer);为了保险,我仍然会额外加一层命令拦截,防止某些场景下权限没生效导致数据被修改。两种机制叠加,等于上了双保险。
const protectedCommandIds = new Set([ 'sheet.command.set-range-values', // 具体ID以版本实际导出的常量为准 ]); commandService.beforeCommandExecute$.subscribe((command) => { if (protectedCommandIds.has(command.id)) { const params = command.params; if (params && params.range) { const allow = isRangeAllowedByBusinessRule(params.range); if (!allow) { // 还可以在这里插入业务提示,比如弹窗或状态提示 return false; } } } return true; });在实际项目里,我建议把可编辑区域定义为配置表,后端下发一组“区域+规则”的JSON,前端渲染时做映射。这样模板调整不需要发版,填报权限也能按部门、按周期灵活控制。
3.4 数据校验与填写提示
只读区域搞定后,再来给可填写区域加校验。Univer的数据校验插件用法和Excel很接近,你可以通过命令或者服务直接添加规则。
下面是一个给“本月申报数量”列添加数值范围校验的简化示例:
import { IDataValidationService, DataValidationType } from '@univerjs/sheets-data-validation'; const dataValidationService = univer.__getInjector().get(IDataValidationService); dataValidationService.addDataValidation({ type: DataValidationType.DECIMAL, formula1: 0, formula2: 10000, allowBlank: true, showErrorMessage: true, errorMessage: '申报数量必须在0到10000之间', ranges: [{ startRow: 10, endRow: 39, startColumn: 1, endColumn: 1 }], });同样是这个数据校验服务,还可以给“备注”列加上文本长度限制,给“填报日期”列加上日期格式限制。校验触发时机一般发生在单元格编辑完成时,如果用户输入非法内容,单元格会进入错误状态,鼠标悬停会看到错误提示。
除了数据校验,我还习惯给可填写区域加一点视觉提示。比如给可编辑单元格设置浅黄色背景,或者在列头增加一个“*”符号表示必填。Univer的自定义单元格能力允许你完全控制单元格渲染,但我建议先从样式入手,简单且稳。
3.5 数据回读与提交校验
用户填完表之后,前端需要把表格数据取出来提交给后端,同时做一遍最终校验。Univer的数据取读也很直白,通过工作表实例的getRange()可以拿到指定区域的矩阵数据。
import { UniverInstanceType, IUniverInstanceService } from '@univerjs/core'; function getSheetData(univer) { const instanceService = univer.__getInjector().get(IUniverInstanceService); const workbook = instanceService.getCurrentUniverInstance(UniverInstanceType.UNIVER_SHEET); const sheet = workbook.getActiveSheet(); const range = sheet.getRange(10, 1, 30, 2); // 第10行到39行,第1列到第2列 return range.getValue(); }拿到的数据是一个二维数组,提交前我还会再跑一遍业务校验:必填项是否为空、数量是否超过阈值、备注长度是否超标。校验规则和前端数据校验保持同一份配置源,避免前后端规则漂移。提交时我习惯一次性把整张表的变更区域数据发给后端,后端再根据模板定义和历史记录做二次校验,确保绕过前端直接调接口的人也改不了不该改的数据。
4. 常见问题与排查技巧实录
4.1 权限设置后单元格还是能编辑?
这是最容易遇到的问题。我排查过的案例里,九成原因是锁定样式和“工作表保护”没有同时生效。单独设置某个单元格的locked样式只表示“这个单元格声明为锁定”,但如果工作表没有处于保护状态,声明无效。
另一个隐蔽原因是命令ID没找对。如果你依赖命令拦截来做兜底,但用的命令ID是旧的或者插件的实际命令ID和官方示例不一致,拦截自然不触发。建议直接在控制台打印command.id,把你操作表格时触发的所有命令ID都过一遍,再决定拦截名单。
最后一种情况是某些插件绕过了你监听的命令。比如数据导入插件可能直接走内部API写值,不走常规编辑命令,这时候就要在导入插件的回调里再叠加一层校验。
4.2 大数据量表格卡顿/渲染异常
Univer用Canvas渲染本身性能已经不错,我拿一张5000行、30列的业务表实测过,滚动和选区都很流畅。真正卡的地方往往在公式计算和频繁写值。如果表里有大量易变公式,每次输入一个新值都会触发重算链条,几千行时会有明显延迟。
我的建议是:封闭场景下提前把能预计算的结果在数据层算好,不要在表格里塞太多联动公式;如果公式确实多,把公式计算插件独立初始化,并且尽量限制重算范围。渲染异常最常见的是容器尺寸变化后没有触发重绘,比如侧边栏折叠、弹窗打开。遇到这种情况,调用Univer实例的刷新方法即可。
4.3 多用户同时编辑/提交时的数据一致性
Univer本身是前端表格引擎,不带后端服务。多用户并发编辑时,最基础的做法是“提交时带修订号”:前端提交数据时附带当前表格数据的基准版本号,后端校验这个版本号和当前库里的版本是否一致,不一致就拒绝提交,让用户手动合并后再提交。
如果业务确实需要多人同时编辑同一个表格,那就要考虑协同方案。可以关注Univer官方协同方向的SDK进展,或者自研一套基于操作转换(OT)的协同服务,把每次写值命令同步到其他端并做冲突合并。这块工作量不低,中小型项目还是建议先做“一人填报,多人只读”的模式,把并发复杂度降到最低。
4.4 与React/Vue框架集成时遇到的坑
在React里集成Univer,最常见的问题就是生命周期管理。Univer实例应该绑定到业务模块的全局生命周期,而不是随组件卸载就销毁。如果组件频繁挂载卸载,每次重新创建实例不仅慢,还容易造成内存泄漏和事件监听冗余。
我给团队封装了一个简单的Hooks思路:用一个单例Map保存Univer实例,key是业务类型的标识;组件挂载时从Map里取实例并挂载到新的DOM容器,卸载时只执行卸载操作,不销毁整个Univer实例。容器尺寸变化时,配合ResizeObserver调用刷新方法。Vue里也同理,重点是让实例存活在组件之外。
还有一个容易踩的雷是SSR环境。Univer依赖浏览器API,比如操作Canvas、访问window对象,在没有document的环境下直接初始化会报错,SSR项目要在客户端渲染阶段动态加载并初始化Univer。
4.5 排错速查表
我整理了一份常见问题速查表,可以贴在项目Wiki里当参考:
| 症状 | 可能原因 | 解决方式 |
|---|---|---|
| 锁定单元格仍可编辑 | 工作表保护未启用 | 检查保护命令是否执行成功 |
| 部分入口绕过拦截 | 监听命令ID不全 | 打印实际触发的命令ID,补全拦截列表 |
| 输入非法值不报错 | 数据校验插件未注册 | 检查插件注册顺序和校验范围配置 |
| 公式列不自动重算 | 公式插件未加载 | 注册公式引擎插件并按需初始化 |
| 表格显示空白 | 容器尺寸为0或刷新不及时 | 调用刷新方法,检查容器CSS高度 |
| 组件多次挂载卡顿 | Univer实例重复创建 | 使用单例方案管理实例生命周期 |
| 提交数据丢失部分行 | 取数范围设置错误 | 核对行号列号索引是否从0开始 |
最后说点我的体会
表格类业务看似简单,但真正要做到“好用”很考验底层功底。Univer最打动我的地方是它的命令系统和插件化架构,把“让用户只填指定单元格”这种需求从“业务层硬造”变成了“引擎层支持”,二开时你能感觉到设计上留出的扩展空间,而不是像传统表格控件那样处处碰壁。
我建议你上手Univer时不要一上来就追求复杂功能,先按今天的实操流程把一张受控填报表完整跑通,把权限、校验、命令拦截、取数提交这几环都理解透,再去探索自定义函数、协同、导入导出那些进阶能力。踩过几次坑之后你会发现,它完全可以作为企业内各种表单类业务模块的低成本底座。后面我也打算把Univer接到低代码平台里,用配置化的方式生成可填报表格,到时候有新的实操心得再继续分享。