如果你稍微关注过开源表格类项目,应该会注意到一个名字:univer。在我第一次看到它的演示视频时,第一反应是"这不就是本地Excel被搬进了浏览器?",但真正深入用下来之后,我发现它解决的问题远不止"像Excel"这么简单。尤其是热词里提到的场景——"支持用户定义表格,然后让用户去填写一些单元格,其他的单元格用户无法修改"——这其实就是在线填报、数据收集类系统最典型的痛点,而univer把这块做了非常自然的支持。
这篇文章我想从一个实际做项目的人的角度,聊聊univer能做什么、它的底层是怎么想的、以及怎么把它变成一套真正可用的"在线填写-锁定保护-数据回收"工具。我不会只贴官方文档,更多的是我自己在集成过程中踩过的坑和验证过的方案。
1. 开源在线表格里的"隐藏选手":Univer能做什么
先说结论:Univer是一套基于TypeScript开发的、开源免费的在线文档解决方案,除了电子表格之外,它还有文档和幻灯片能力,但最容易落地、社区讨论最多的还是它的Sheet(电子表格)模块。很多团队用它是为了替代"在代码里拼表格HTML"这种方案,直接给用户一个能渲染、能编辑、能联动公式的表格界面。
它的核心价值我总结成三个关键词:可控、可嵌入、可扩展。
可控指的是不用被特定的在线文档平台绑定。你可以把它集成到自己产品里,指定哪些单元格可编辑、哪些必须只读,甚至可以做到不同用户登录进来看到同一张表但编辑范围不同。热词里提到的"让用户去填写一些单元格,其他的单元格无法修改",本质上是表单系统的需求,传统做法是画一个表单页,但表单页远不如表格直观——尤其当你要让用户一口气填几十行、多列数据时,表格的体验优势很明显。
可嵌入指的是它不挑前端框架。项目用React、Vue还是原生JS,都能接。Univer的架构里,核心逻辑和UI渲染层是分离的,这让我可以在一个老旧的jQuery项目里也能嵌进去,而不需要为了一个表格功能做技术栈迁移。
可扩展指的是它有插件机制,而且不是摆设。我后面会详细讲,Univer允许你注册自定义命令、自定义函数、自定义菜单操作,甚至能通过命名绑定去替换它内部的模块实现。这意味着它不只是"现成的表格控件",而是一个可以长在产品里的基础设施。
如果只是在Gitee或GitHub上白嫖它的源码看热闹,你会低估它;真正把它当成一个可选组件去设计时,你会发现市面上几乎没有第二个开源项目能同时满足"界面接近Excel、可编辑范围可控、部署自由"这三件事。
2. 三个底层设计,决定Univer的实际体验
2.1 Canvas渲染:为什么拖2000行不卡
表格这种场景,最怕的就是DOM节点爆炸。你用普通HTML表格渲染2000行×20列的单元格,浏览器会开始卡顿,滚动起来帧率下降明显,一旦加上公式重算,那基本就是灾难现场。
Univer在这里走的是Canvas渲染路线。它的整体渲染层是基于Canvas的,单元格的绘制、选区高亮、行列标题、网格线,全部画在一块画布上。这个设计带来两个直接好处:一是大数据量下滚动性能质变,二是视觉表现极其统一,它可以像游戏引擎那样自己控制重绘时机。
实际体验下来,我对比过同一个3000行数据在普通table方案和Univer中的滚动流畅度,后者是明显平滑的。代价是——你没办法直接通过DOM去选中单元格里的文本,因为根本没有DOM。所有交互都得走Univer的Command和Selection体系。刚开始做集成时我会下意识想"能不能用document.querySelector去拿某个单元格的输入框",后来发现整个思路都要转变。
2.2 公式引擎:可以独立使用的计算核心
公式是表格的灵魂。Univer的公式能力不是简单调个eval,它内部有一套独立的公式引擎,支持跨工作表引用、命名范围、条件格式化里带公式、数组公式等。日常采购清单里的SUM、VLOOKUP、IF嵌套,基本都没问题。
这套公式引擎最让我意外的是"可以被单独拿出来用"。有段时间我想在服务端做一套"模板校验逻辑"——用户在客户端填写金额,服务端要判断填的值是否满足"单价×数量=总价"这种约束。我完全可以把Univer的公式计算逻辑编译到服务端执行,同一个公式定义在前后端得到一致结果,省掉了整整一套规则引擎的活。
当然这里要提醒一句:Univer公式引擎的兼容性和Excel比,仍然有边界。超复杂的交叉引用、透视表推导、某些数组公式的边界情况,可能处理得不如原版Excel那么精细。所以如果业务场景深度依赖Excel的高级公式,上线前一定要做一轮兼容性摸底。
2.3 协同机制:CRDT与命令重放
在线表格另一个绕不开的话题是多人协作。Univer的设计里,协同不是靠"谁后保存谁赢",而是基于一套变更操作流——每次编辑会成为一个命令,命令可以广播到其他端,每个端按相同顺序重放命令,最终保持一致的用户状态。
这套机制让我在一开始使用时踩过一个很有意思的坑:本地编辑其实也在走这套命令流。如果你直接修改某个内部数据结构而不派发Command,UI不会更新,协同也不会同步。换句话说,Univer里所有操作都必须"走正规流程",它不像一个DOM组件那样直接改属性就能解决问题。
理解这一点之后,你会发现它的架构相当统一:命令、撤销栈、协同、权限控制,全部建立在"操作流"基础上。这带来一个很好的工程特性——撤销是天然可靠的,因为撤销本身就只是派发一个反向命令。相比自己维护表格状态的快照来做撤销,要省心太多。
3. 最关键的填报权限:让部分单元格可编辑,其他都锁定
3.1 从工作表保护说起
回到热词里最核心的那个场景:用户填几个单元格,其他区域完全动不了。Univer实现这个需求的方式,一开始让我有点不习惯,因为它没有像普通表单那样"设置某个字段为只读"的直观API,而是通过工作表的保护机制来控制的。
工作表的属性里有一个protection配置,启用后整张表进入锁定状态,未解锁的单元格无法编辑。然后你再针对特定Range设置unlock放行,这些区域就可以被填写。这种"先全锁,再局部解锁"的思路,和Excel的"保护工作表"如出一辙,但它被完整带到了Web端。
我实际做的配置大体是这样:
- 创建一张空表,先开启整表保护;
- 把需要用户填写的连续区域(比如"B2:F100")标记为可编辑;
- 在工具栏层面禁掉insertRow、deleteRow、insertColumn等操作,防止用户通过插入行列的方式绕开锁定;
- 再通过命令拦截或权限配置,让普通用户即使调用了编辑命令也会在权限校验层被拒绝。
如果只在UI层面控制右键菜单禁用,实际上并不能彻底阻止调用命令。Univer的权限校验是可以被触发的,所以最稳妥的方案是在CommandManager派发之前,注册前置校验逻辑,如果当前用户对此Range没有编辑权限,就直接拦截命令。这样做之后,即便有人用控制台手动调用API也改不了,因为命令没有真正的执行权。
3.2 动态权限:后端决定谁能改哪一格
静态锁定简单,真正复杂的是"不同用户看到同一张表,可编辑范围不同"。比如一张预算表,部门经理可以编辑本部门的预算数字,但不能碰其他部门;财务总监全表可编辑。这种场景靠前端写死不可行,因为权限判断必须跟登录用户走。
Univer的方案是提供一系列PermissionPoint。每一个操作、每一类单元格改动,都可以对应一个权限点。你可以注册自定义权限点,或者通过拦截命令的方式在前端逻辑中动态判断"当前操作是否被允许"。
我最终落地的方式是:
- 页面加载时,根据当前登录用户调后端接口,获取该用户在当前表格上的可编辑范围(比如返回一个数组
[{sheetId, startRow, endRow, startCol, endCol}]); - 在Univer实例化之后,遍历这些范围,依次给Range设置
unlock; - 同时注册一个命令拦截器,数据变化操作执行前,判断目标区域是否包含在允许列表中,不在就拒绝。
这样一次性设置之后,每个用户看到的是同一套模板,填写的边界却很清晰。项目经理填进度,财务填金额,销售填预估,大家互不干扰,报表又汇总在一起。
3.3 配合表单化的几点细节
只有锁定还不够,填报场景里还有几个细节容易被忽略。
第一是提示文案。用户看到一片灰暗的锁定单元格,第一反应是"系统坏了"而不是"这不能填"。我会在填报说明里明确告诉他哪些列需要填,甚至用条件格式把可编辑区域标成浅黄色,让"哪里能填"一目了然。
第二是必填校验。Univer本身不做"这个单元格不能不填"的逻辑,但你可以在提交时遍历可编辑区域,逐格检查空值,再通过UI提醒用户。如果你需要的是更严格的"保存即校验",可以让校验逻辑在CommandManager完成后触发,但要做好性能控制,不要每次敲一个字符就跑全量校验。
第三是粘贴劫持。这是容易翻车的地方。用户可能在Excel里复制了一整块数据,然后往锁定表格里粘贴——默认情况下Univer会尝试把粘贴内容映射到当前选区,如果跨越了锁定区域,就会产生"一部分粘贴成功、一部分被拒"的混乱状态。我给普通填报用户关闭了粘贴命令,只保留手动输入,这确实损失了一些便利,但换来了数据安全。如果你是内部系统,可以做成粘贴前先检查目标区域是否完全可编辑,再做放行。
4. 跑通一个最小可用填报系统
4.1 前端工程与初始化
这里我按照自己常用的前端工程方式,给一个最小可跑的示例骨架。假设你已经有一个Vite项目,安装所需依赖:
npm install @univerjs/core @univerjs/sheets @univerjs/sheets-ui @univerjs/ui @univerjs/sheets-formula @univerjs/sheets-numfmt初始化入口通常在main.ts里,大致代码如下:
import { Univer } 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 { UniverFormulaEnginePlugin } from '@univerjs/sheets-formula'; import { UniverNumfmtPlugin } from '@univerjs/sheets-numfmt'; const univer = new Univer({ theme: defaultTheme, }); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverFormulaEnginePlugin); univer.registerPlugin(UniverNumfmtPlugin); univer.registerPlugin(UniverUIPlugin, { container: 'app', layout: { toolbar: true, footer: true, }, }); univer.registerPlugin(UniverSheetsUIPlugin, { container: 'app', });这段代码看起来平平无奇,但有一个容易被新手卡住的地方:插件的注册顺序。UniverSheetsPlugin必须比UniverSheetsUIPlugin先注册,因为UI插件依赖核心模块的已注册状态。我刚开始把顺序写反,界面就是渲染不出来,控制台报了一堆看不懂的依赖错误——其实本质是"插件先跑,核心模块还没有实例化"。
初始化之后,得到的是一个空的Univer实例。下一步是创建工作簿并填充模板数据。
4.2 锁定单元格、设置填报项
在我使用的版本里,创建工作簿并设置权限的路径大概是先在univer实例上获取univerAPI,然后创建sheet并写入表头数据。示例逻辑如下:
const univerAPI = univer.createAPI(); const workbook = univerAPI.createWorkbook(); const worksheet = workbook.getActiveSheet(); // 写表头 const headers = ['项目名称', '负责人', '开工日期', '计划产值', '实际产值']; headers.forEach((header, colIndex) => { worksheet.getRange(0, colIndex).setValue(header); }); // 设置A列到E列,第0行到第100行的范围为填报区 const fillRange = worksheet.getRange(1, 0, 100, 5); fillRange.setUnlock(); // 允许编辑 // 其他范围保持锁定(默认是锁定的)这里需要解释一下setUnlock和setLocked的区别。官方语义中,setUnlock是把某个Range的保护开关打开,允许在sheet被保护时继续编辑;setLocked则是重新锁定。默认情况下,新单元格的锁定状态是继承工作表保护的,所以你新建工作表后,先启用保护,再对指定Range执行setUnlock效果最清晰。
光有锁定还不够,我把工具栏里那些"插入行、删除行、删除列、排序、筛选"也一并从普通用户的UI上抹掉。如果只看锁定动作,用户仍可能通过"插入行"在填报区下方插出一行不受保护的空白行。虽然权限拦截能防住底层调用,但最好的策略是"底层拦截+UI简化"双管齐下。
4.3 从填表到收集数据
数据回收让我考虑了很久。Univer提供了一套onChange监听机制,但如果你每个单元格的变化都触发一次后端请求,网络开销会非常大。填报场景最优解是"本地积累、统一提交"。
我的做法是在提交按钮里遍历全部需要填报的Range,一次性读取所有值,然后组织成JSON或表格行数据打到后端。极端情况下,上万行数据一次性提交会触发HTTP请求体积上限,所以如果填写量真的很大,建议按批次提交,但普通填报表单控制在几百行内完全没问题。
这里要提一个非常有用的API:worksheet.getRange().getValues()。它能按行列一次性把区域数据打成二维数组。相比逐个单元格取数,性能提升是数量级的。而且Univer内部对此做了优化,一次API调用能返回整个Range的矩阵。
采集到数据后,后端可以按下发的模板ID进行落库。如果需要生成审批流或Excel导出,直接基于这份数据拼装即可,不再依赖前端渲染。
5. 服务端部署,别被前端迷惑
5.1 必需的基础组件
很多人以为Univer是个纯前端库,部署起来就是静态文件一放就行。这个认知对了一半——如果只是做单机Demo,确实只需要静态前端;但凡是多用户、要做协作、要能保存编辑历史,就必须有服务端支撑。
Univer的后端组件分为几种能力:
- Univer Server端核心:处理工作簿的创建、保存、读取操作,提供REST API;
- 协同服务:基于WebSocket实现多人实时同步,如果用不到多人同时编辑一张表,可以简化;
- 对象存储与数据库:保存文档快照和操作日志;
- Redis等中间件:缓存热数据与协同状态。
这不是一套轻量部署的组件。如果你只是要做填报场景,后端不需要协同服务,只需要"模板下发-数据回收"两条路径,那完全可以自己写一个轻服务,把Univer前端当成"渲染引擎",把最终数据落进自己的业务库。这样部署压力小很多。
5.2 普通业务场景的部署经验
如果确实需要服务端,官方提供了Docker镜像,我建议直接用编排工具走通官方示例,然后再替换自己的业务逻辑。需要准备的容器大致有:univer服务端容器一个,PostgreSQL或MySQL一个,Redis一个,对象存储可以先用MinIO模拟,后续再切换云厂商的S3服务。
这里我踩过一个非常实际的坑:前端通过WebSocket连不上服务端。排查一番后发现,Univer前端的协同接入地址走的是内置配置,如果你的服务端部署在/univer-server这种子路径下,前端需要同步修改API基础路径和WebSocket路径两处。只改一个会出"保存成功但同步无响应"的诡异故障。
实际上,对于大部分业务系统,我不会建议上完整的协同服务。原因是协同带来的复杂度远大于收益——普通填报/查询场景里,真正需要多人同时并编辑同一份数据的情况很少。Univer前端本身已经是很好的"表格体验层",服务端我按业务自己设计,反而更可控。
5.3 一个真实的坑:域名和跨域
如果你把Univer嵌到一个子域名,而这些接口又被部署在另一个子域名,CORS配置是绕不开的。我遇到过前端页面打开正常,但所有请求被浏览器拦截的问题——原因就是服务端没有正确配置Access-Control-Allow-Origin。
Univer服务端的配置里通常有两种模式:同域部署(最简单,推荐);跨域部署(需要配置白名单,且WebSocket连接也需要处理跨域)。
我最终的建议是:在公司的网关层直接把/univer-api这类路径反代到Univer服务端,让前端以为它是同域服务,所有跨域问题一次性解决。这比逐个接口配CORS省事也安全。
6. 扩展能力:当默认功能不够用的时候
6.1 自定义操作和插件路由
Univer的插件机制是它区别于普通Table组件的地方。你注册一个插件后,可以向CommandManager注册自定义命令,这些命令可以在撤销栈里生效。比如我给填报系统做了个"提交当前区块"按钮,点击后走自定义命令:
- 校验当前区块数据合法性;
- 快照当前样式和值(供撤销);
- 向后端提交数据;
- UI上显示提交状态。
这样做的直接好处是撤销还原也能覆盖这个动作,不会出现"填了数据,提交了,但Ctrl+Z只能撤销最后一次键盘输入"的割裂感。
6.2 给表格增加自定义函数
业务系统里经常需要"算一个业务专用指标",比如项目进度百分比。与其在外部JS里计算好再塞回表格,不如注册成Univer里一个函数,让用户在单元格里直接写=PROJECT_PROGRESS(B2, C2)。Univer支持自定义公式的注册,走一遍它们的生命周期即可。
这个能力在花名册、项目排期、成本核算之类的表格里很实用。它让表头的"语义"和单元格的"计算"都统一在表格内部,业务人员可以自主调整计算逻辑,而不需求固定的代码版本。当然,函数注册需要公式引擎加载完毕后再进行,不然会报"Function not defined"之类的问题。
6.3 命名绑定与注入机制
更深层的扩展,是用命名绑定替换Univer内部的默认实现。这相当于Spring里的依赖注入,你可以告诉Univer:"这个服务不用你的默认实现,用我的"。我在项目中用这个能力替换了默认的鉴权逻辑,把用户信息和菜单权限做了一次统一适配。
命名绑定是所有扩展机制里"最重"的一种,优点是极强的可定制性,缺点是要理解它的源码层级。如果你只是做表格填报,建议先通过命令拦截和自定义函数满足需求,命名绑定留到后期需要深度集成时再研究。
7. 落地Univer的持续困扰与兜底方案
7.1 渲染性能的极限
虽然Canvas渲染让大数据量表的表现大幅改善,但Univer并非无上限。当单元格数量到几十万、公式链很复杂时,输入响应仍然会变慢。我的经验是:真正到这种量级,应该考虑聚合层处理,让用户看到的是过滤后的结果,而不是把所有明细一股脑丢到前端。
滚动性能只是第一层,重算性能和内存占用才是深层压力。遇到复杂嵌套公式时要多做压测,尤其是那些"跨工作表引用+数组公式"的组合,计算成本可能在毫秒级和秒级之间剧烈波动。
7.2 权限之外,还需要兜底的行为控制
权限拦截解决的是"能不能改",但拦不住"改了之后提交脏数据"。填报场景里我会再加两道兜底:
- 后端的业务校验不能完全信任前端,Univer的前端模板规则要同步一份到后端;
- 操作日志必须有。一旦数据异常,能从操作流中定位是谁、什么时间、从哪一秒改成了什么样。
Univer保存操作流的能力在这方面是加分项,因为单元格每一次改变都是可复盘的。它天然具备审计需要的原始轨迹。
7.3 退出成本与控制成本
说实话,Univer目前的上手成本不算低。它的接口迭代速度快,文档更新没完全同步,社区里很多问题的答案还是旧版本的写法。集成时最好锁定一个稳定版本,别追着最新版跑,否则每次升级都可能有断崖式变更。
我的做法是把Univer封装成项目内的一个适配层,外部只暴露"创建填报表格、锁定区域、导出数据"等窄接口。这样即使底层升级,业务代码也不需要大面积改动,把控制成本控制在一个模块内部。
从我自己的实操体验来看,Univer最适合的场景始终是"产品中需要一个像Excel一样的交互层,但数据安全和权限边界你要自己掌控"。它不是开箱即用的业务系统,更接近一个高质量的通用组件。用好的关键,是理解它的命令流、权限机制和插件模型,并且从一开始就设计好"哪些数据给用户写、哪些只能由后端写入"的边界。找准这个边界,你能省下大量的表格与表单开发工作量,还不会失去系统自己的控制力。