提到开源在线表格,很多人第一反应是Luckysheet,再往前数就是Handsontable和x-spreadsheet。但如果你需要在业务系统里嵌入一张“表格模样的表单”——用户只能填写自己该填的单元格,其他区域动都不能动——Univer是值得认真考虑的选择。Univer是一个用TypeScript开发的开源办公套件,覆盖在线表格、文档、幻灯片三大块,其中最核心的表格引擎已经相当成熟,支持公式计算、条件格式、数据校验、协作编辑,还能通过插件机制按需扩展。这篇文章围绕“univer在线 + 用户自定义表格 + 单元格只读控制”这条主线,把从引入依赖到实现单元格保护、再到底层原理和常见坑,完整地拆开讲一遍。
1. Univer是什么:为什么我会从Luckysheet切过来
1.1 一个真实的需求场景
先交代背景。我这边是一个企业内部的项目管理系统,里面有个模块叫“季度考核登记表”,业务部门提的需求非常明确:员工打开表格后,只能填写“自我介绍”“工作成果”“自评分”这三列;“主管评分”“审核意见”“最终等级”这三列,只有主管角色登录才能编辑;季度、部门、员工编号这些列是系统自动生成,谁都不许动;还要做必填校验和下拉选择,比如“自评分”只能填0到100的数字,不能粘贴乱七八糟的文本。
这个需求放在Excel里很简单:锁定区域加保护工作表。但在网页里要做成一张真正的在线表格,还得跟系统现有的登录角色体系打通,就不那么简单了。我之前用过Luckysheet,表格渲染和基础公式都够用,但有三个痛点一直绕不过去:一是官方插件生态越来越不活跃,有问题只能自己啃源码;二是复杂的单元格权限控制实现起来很绕,需要自己维护一堆交互逻辑;三是和React项目的状态管理整合得很别扭,数据同步总差半拍。
后来调研了一圈,发现Univer的定位和功能模块几乎就是冲着这些问题去的。它不是简单的一层表格组件,而是把“渲染引擎”“交互层”“公式引擎”“权限模型”拆成了可独立控制的模块,业务方只需要通过统一API操作Range对象,就能实现大部分Excel级别的控制能力。
1.2 Univer的技术底子
Univer是Apache 2.0协议的开源项目,GitHub上叫dream-num/univer,背后团队其实就是Luckysheet的原创团队DreamNum。所以它天然继承了Luckysheet对“浏览器端表格渲染”这一块的深度积累,但又在架构上重新设计了一遍:全部用TypeScript编写,类型体系完整,写代码时编辑器提示非常友好;渲染层基于Canvas,表格刷新性能远好于纯DOM实现;核心层、UI层、插件层严格分离,你可以只引入表格引擎不引入UI,也可以自定义UI和交互;还提供了Facade API,面向业务开发者的高级封装,大部分场景不用深入内部源码。
这里有个关键点,Univer的插件机制是它的灵魂。它不像Luckysheet那样把所有功能都堆在一个包里,而是像积木一样按需加载。比如你做表单场景,可以不加载图表和幻灯片相关模块,包体小、启动快。这一点在后面做单元格保护时尤其明显——保护能力也是作为一个模块挂在表格引擎上的,用API就能控制。
1.3 适合谁用,不适合谁用
| 方案 | 渲染方式 | 单元格权限控制 | 协作编辑 | 维护活跃度 | 上手成本 |
|---|---|---|---|---|---|
| Univer | Canvas | 原生支持,API可控 | 服务端方案成熟 | 活跃,更新快 | 较高 |
| Luckysheet | DOM+Canvas | 需要自己实现 | 基础版功能有限 | 趋于停滞 | 中等 |
| Handsontable | DOM | 商业版支持 | 商业版支持 | 商业维护 | 较低 |
| x-spreadsheet | Canvas | 基本没有 | 不支持 | 低 | 低 |
我个人的使用判断是:如果你的需求是“嵌入业务系统的在线表格”,并且要求单元格级别权限、数据校验、协作、二次开发,那Univer是当前开源方案里的第一梯队;如果你的需求只是“在网页上简单展示一份Excel”,那Univer有点杀鸡用牛刀,直接SheetJS解析后渲染成HTML更快;如果你的产品需要完整的“在线Office体验”,不仅仅是表格,还有文档和PPT,那Univer的Docs和Slides模块还处于早期阶段,成熟度需要评估风险;如果你团队里没有前端工程师,所有人都是后端出身,那建议先花一周时间搭个原型验证,Univer的上手成本比Luckysheet高,因为概念多、模块多,但一旦跑通,开发效率是值的。
2. 从零集成Univer:环境、依赖与首屏渲染
2.1 项目初始化与依赖安装
我用的技术栈是React 18 + Vite + TypeScript,Univer官方推荐的就是这套组合。不过Univer并不绑定React,你也可以用Vue、Svelte或者原生JS直接挂载,只是React下的示例和社区资源最多。
安装命令很简单:
npm install @univerjs/presets @univerjs/core @univerjs/sheets @univerjs/sheets-ui @univerjs/ui如果你的项目里还有公式需求,再补上:
npm install @univerjs/docs @univerjs/sheets-formula这里要提醒一下,Univer的包版本更新很快,各个包之间是相互依赖的,安装时务必保证版本一致。我曾经因为某个包单独升级到最新版,结果其他包还是旧版,直接报了一堆类型错误。保险做法是统一用npm install @univerjs/presets@latest,让npm帮你把版本对齐,或者直接锁一个经过验证的版本号组合写进package.json。
2.2 核心初始化代码
用Presets的方式初始化是最简单的,几行代码就能渲染出一张带工具栏的表格:
import { useEffect, useRef } from 'react'; import { Univer } from '@univerjs/presets'; import '@univerjs/presets/lib/styles/index.css'; function Spreadsheet() { const containerRef = useRef<HTMLDivElement>(null); const univerRef = useRef<Univer | null>(null); useEffect(() => { if (!containerRef.current || univerRef.current) return; const univer = new Univer({ plugins: ['sheets', 'sheets-ui', 'sheets-formula'], locale: 'zhCN', }); const instance = univer.createUniverSheet({ container: containerRef.current, }); univerRef.current = instance; }, []); return <div ref={containerRef} style={{ height: '600px' }} />; }注意几个细节:容器一定要有明确的高度,否则Canvas会按0高度渲染,页面上一片空白。我最早踩的就是这个坑,div样式忘了加height,调试了半小时。locale: 'zhCN'能直接把工具栏和右键菜单切成中文。初始化之后,Univer渲染的是一个带默认工具栏和右键菜单的完整表格,如果你做的是纯展示场景,可以进一步关闭工具栏、隐藏行号列号,只保留表格区域。
2.3 单例化与按需加载
React里容易犯的一个错误是:组件被重新挂载时,又new了一个Univer实例,导致页面上出现两张表格或重复绑定事件。解决思路是把Univer实例存在一个全局单例里,或者干脆在应用启动时就初始化,后续通过getInstance获取。我实际的做法是封装一个单例类:
class UniverSingleton { private static instance: Univer | null = null; static getInstance() { if (!this.instance) { this.instance = new Univer({ plugins: ['sheets', 'sheets-ui'], locale: 'zhCN', }); } return this.instance; } static destroy() { this.instance?.dispose(); this.instance = null; } }另外,如果你的应用有多个页面,建议用动态import方式加载Univer相关代码,只把“会打开表格”的那个路由做懒加载。Univer的JS体积不算小,首屏不相关的模块没必要提前下载。实测下来,懒加载之后,包含表格的页面首次打开在3G网络环境下大概多等500ms到800ms,但其余所有页面几乎没感知,这笔账是划算的。
3. 用户自定义表格与单元格只读控制
3.1 保护机制的三个层次
回到最初的需求,业务方希望“让用户填写指定单元格,其他格子不能改”。这背后实际上有三个层面的控制,理解它们之间的关系是整个功能实现的地基。
第一层是单元格本身的属性。每个单元格有一个locked属性,类似于Excel里的“锁定”标识。关键在于,锁定与否只是标记,如果整个工作表没有启用保护,这个标记并不生效。
第二层是工作表级别的保护开关。当工作表保护开启时,所有locked=true的单元格都禁止编辑,同时还可以控制“是否允许选中”“是否允许插入行列”“是否允许删除行列”等操作。
第三层是权限校验。Univer的保护可以设置密码,也可以配置不同用户的操作许可。在实际业务里,我们通常不会把密码下发到浏览器端,而是自己实现权限控制逻辑:从后端接口取当前登录用户的角色和可编辑范围,再动态地对Univer下发保护指令。
我见过不少人直接在单元格上找“只读”配置,找不到就说Univer不支持看起来很高端的能力,其实只是没理解它遵循的是Excel那套“锁定加保护”模型。三层弄清楚之后,剩下的就是按顺序操作:先锁全部,再解锁指定区域,最后开保护。
3.2 方案选型:前端控制还是后端强校验
在这里我必须诚实说一句:任何纯前端的单元格保护,本质上都是“防君子不防小人”。Univer在UI层面拦截了用户输入,但如果用户直接通过控制台调用Univer的API修改单元格,或者直接抓包提交数据到后端,保护就形同虚设。
所以正确的姿态是分两层:前端用Univer的保护机制做体验层,让普通用户“感觉”到哪些格子不能改,减少误操作;后端在接收数据时,根据同样的权限规则做服务端校验,发现越权字段直接拒绝写入。我在项目里是把权限规则抽成一份JSON配置,同时供给前端和后端使用:
{ "role": "employee", "editableRanges": [ { "sheet": "考核表", "startRow": 1, "endRow": 30, "startCol": 0, "endCol": 2 } ], "requiredFields": ["B2", "B3", "B4"] }前端拿着这份配置去设置Univer的保护区域,后端拿着同一份配置去校验提交的数据,两边永远一致,不会出现前端放开了但后端校验不过,或者后端收了但前端输入框压根不可编辑的错位。
3.3 核心实现:工作表保护 + 区域解锁
下面是一段基于Univer Facade API的示例代码,实现“A2:C20可编辑,其余全部只读”:
import { FUniver } from '@univerjs/core'; const univerAPI = FUniver.newAPI(univerInstance); function applyPermission(editableRange) { const sheet = univerAPI.getActiveWorkbook()?.getActiveSheet(); if (!sheet) return; // 1. 先把整张表所有单元格的locked属性设为true sheet.getRange(0, 0, sheet.getRowCount(), sheet.getColumnCount())?.setRangeEditLocked(true); // 2. 把允许编辑的区域解锁 sheet.getRange( editableRange.startRow, editableRange.startCol, editableRange.endRow - editableRange.startRow + 1, editableRange.endCol - editableRange.startCol + 1 )?.setRangeEditLocked(false); // 3. 开启工作表保护 sheet.protect({ password: '', // 密码为空代表仅拦截交互 permissions: { selectLockedCells: true, selectUnlockedCells: true, formatCells: false, insertRows: false, deleteRows: false, editObjects: false, }, }); }这段代码里我用的是setRangeEditLocked这个语义化命名,不同版本的Univer API名可能略有差异(有的版本叫setLocked,有的版本叫protectRange),但思路是一致的:先锁定全部,再解锁指定区域,最后开保护。如果你用的版本API对不上,去GitHub的examples目录搜“protection”关键词,准能找到对应版本的demo。
还要强调一个小细节:保护开启时,我特意设置了selectLockedCells: true。为什么?因为如果设为false,用户连点击那些锁定单元格都不行,焦点无法落在那里,这会导致两个问题:一是用户没法通过选中区域查看内容,只能干瞪眼;二是在键盘操作时,焦点会莫名其妙跳到表格外面,体验很怪。保留“可选中但不可编辑”,才是Excel老用户熟悉的行为。
3.4 叠加数据校验:只让用户填合法值
只读控制解决的是“能不能填”,数据校验解决的是“填得对不对”。Univer的数据校验支持列表、整数、小数、日期、自定义公式等类型,用法和Excel的数据验证几乎一致。
对员工的“自评分”列,我做了这样两个校验:
// 下拉列表校验:用于“考核等级”列 sheet.getRange(1, 5, 30, 1)?.setDataValidation({ type: 'list', value: ['优秀', '良好', '合格', '不合格'], allowBlank: false, errorMessage: '请从下拉框中选择考核等级', }); // 数字范围校验:用于“自评分”列 sheet.getRange(1, 2, 30, 1)?.setDataValidation({ type: 'decimalBetween', value: [0, 100], allowBlank: true, errorMessage: '自评分必须在0到100之间', });有个经验:数据校验和单元格锁定要配合使用。如果你对一个锁定单元格做校验,用户根本没机会触发校验,等于白写。反过来,如果你只做校验不做锁定,用户虽然能输入非法值但会被拦截,体验上总觉得“迟了一步”。最佳实践是:锁定决定能不能改,校验决定改的值合不合法,两个维度互补,缺一不可。
3.5 动态切换角色:主管登录后解锁审核列
同一个表格,员工看到3列可编辑,主管看到6列可编辑。这个不能靠刷新页面时一次性下发权限解决,因为系统里可能同时存在“管理员正在看表格,普通员工也在看同一个表格”的场景,所以在切用户角色的瞬间就要重新设置保护。
实现思路是在登录态变化后调用一个refreshProtection方法:
function refreshProtection(ranges) { const sheet = univerAPI.getActiveWorkbook()?.getActiveSheet(); // 先解除保护 sheet?.unprotect(); // 重置全部为锁定 sheet?.getRange(0, 0, sheet.getRowCount(), sheet.getColumnCount())?.setRangeEditLocked(true); // 解锁当前角色可编辑的区域 ranges.forEach((r) => { sheet?.getRange( r.startRow, r.startCol, r.endRow - r.startRow + 1, r.endCol - r.startCol + 1 )?.setRangeEditLocked(false); }); // 重新开启保护 sheet?.protect(); }注意:切换保护之前,必须先保存当前编辑数据。我在这里踩过一个坑——用户在可编辑区域还没填完,主管登录了,保护被重置,那些还没保存的输入直接被清空。后来我在refreshProtection之前强制调了一次getRange().getValue()把所有值快照下来,重置保护后再写回,这才解决了问题。
4. 在线场景的进阶玩法:公式、导入导出与协作
4.1 让公式跟着权限走
有了保护机制之后,公式的应用也变得更安全。常见需求是:允许用户填写原始数据,但汇总、求和、排名这些计算列不让用户碰,由系统公式自动生成。Univer的公式引擎和Excel公式基本兼容,比如在F列写一个自动求和:
sheet.getRange(9, 5)?.setFormula('=SUM(F2:F8)');配合保护机制,把公式所在的列设为锁定,用户就既看不到修改入口,也无法覆盖公式结果。这里要注意,如果你把公式隐藏(Univer支持隐藏公式功能),那用户在选中单元格时只能看到结果,看不到公式本身,适合某些需要“黑盒”计算的业务场景。
公式模块在使用时有个潜在坑:如果表格里的数据量上到几千行,公式重算会非常吃CPU,尤其是有大量VLOOKUP或SUMIFS的时候。我建议把公式尽量限制在汇总区域,不要每行一个重型公式,必要时把计算结果缓存到后端,避免前端反复触发全表重算。
4.2 数据导出与回显的完整性
在线表格最终要跟业务系统对接,最常见的就是导出成Excel文件。Univer社区里推荐的方案是用核心API读取表格数据,再配合SheetJS(xlsx库)生成文件,或者直接用官方提供的导出插件。
我在项目里的导出函数大致长这样:
function exportToXlsx() { const workbook = univerAPI.getActiveWorkbook(); const sheet = workbook?.getActiveSheet(); const data = sheet?.getSheetData(); const ws = XLSX.utils.json_to_sheet(data); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, '考核表'); XLSX.writeFile(wb, '考核表.xlsx'); }要注意的是,导出不能只导单元格的显示值,还要把公式、数据校验、单元格样式一起带走,否则用户拿到的Excel和在线表格看到的完全不是一回事。这一点在Univer上需要额外花功夫,因为getSheetData拿到的可能是纯值数组,单元格样式和校验规则要通过Detail API逐项读取。如果你的导出目标是“给客户看”,纯值就够了;如果目标是“给同事继续编辑”,那建议直接用Univer官方的导出插件,虽然体积大一倍,但格式保真度高得多。
导入方向也一样,用xlsx解析文件后,把每行数据映射到指定单元格即可。我这边由于有权限控制,导入时还会做一次“导入前校验”:解析出来的每条记录先跑一遍后端校验接口,发现非法值就直接报错并给出行号,绝不允许绕过前端的保护区域直接污染数据。
4.3 协作编辑:多人同时填一张表
Univer的协作编辑是基于OT或类似协同算法实现的服务端能力,需要部署Univer的协作服务端。如果你只想在本地方案里简单体验,Univer也提供了演示模式。但我要泼一盆冷水:协作编辑的服务器搭建和维护成本都不低,如果业务场景只是几个人同时填表填完保存,用“后写覆盖”的业务锁其实更简单可靠。
我在系统中是用Redis做了一份分布式锁,Key是表格ID,持有锁的用户才能提交数据,提交后立即释放。因为考核表的单次填写数据量很小,根本用不上OT级别的实时协同,锁就够用了。等哪天业务规模大到需要多人实时编辑同一片区域,再上Univer的协作服务不迟。这个取舍其实值得很多团队参考:技术选型不是越高级越好,而是要考虑你的真实并发模型。Univer的协作是它最亮眼的卖点之一,但也是最重的部分,很多业务场景其实用不到。
5. 常见问题与排查技巧实录
5.1 为什么保护设置后仍然能编辑
这是问得最多的问题。排查顺序是:先确认工作表保护是否真的开启了。在Univer UI里,如果工作表被保护,右键菜单的“保护工作表”选项前应该有个勾选状态,或者调用sheet.isProtected()看一眼返回。再确认要禁用的单元格是否真的有locked=true。如果你先开了保护,再调用setRangeEditLocked(true),有些版本会认为这是对保护中工作表的修改,操作会被拒绝。正确顺序是:先设置锁定标识,再开启保护。最后确认操作的是不是当前活跃Sheet。如果你开了多个Sheet,而保护作用在了当前sheet上,用户切换到别的Sheet就绕开了,需要逐个Sheet设置。
5.2 单元格样式丢了
有几次我发现用户填完保存回来后,之前加的黄底、红字全消失了。排查下来是保存时用了前端的getSheetData(),只拿了值,没有拿样式。这里建议:要么用Univer官方导入导出插件保真,要么在保存时把样式也快照一份。后者虽然啰嗦,但在百行以内的表单场景完全够用。
5.3 大数据量下的性能优化
表格超过5000行时,带公式和数据校验的表格会明显卡顿。我的优化经验有三条:只渲染可见区域。Univer本身是Canvas渲染,理论上不关心DOM节点数,但公式引擎和校验逻辑还是会全量扫描,所以尽量控制Sheet的行数,不要用表格代替数据库。关闭不必要的单元格监听。如果每个单元格上都有onChange监听做实时计算,数据一多就崩,改成保存时才统一读取会好很多。升级到支持Worker的版本。Univer官方在新版本里把公式计算放到Web Worker里做,实测对长耗时公式改善明显,建议跟进版本。
5.4 包版本不一致导致的常见报错
Univer的多包架构是优点,也是坑点。常见报错有Plugin not found、Cannot read properties of undefined (reading 'getActiveWorkbook'),大概率都是版本不匹配导致的。解决方案就是开头说的:所有包统一升到同一版本,用npm ls检查依赖树,必要时把node_modules删掉重装。
5.5 数据校验不生效的检查顺序
校验不生效通常有三种原因:校验范围写错了行列索引,比如从1开始数还是从0开始数没对齐;校验类型名跟版本不一致,比如list在新版本里可能改叫listMultiple;或者校验所在的单元格同时被锁定,用户根本没法触发编辑。逐个排查,基本两分钟能定位。
6. 落地的两周里,我积累的几条判断
6.1 Univer的学习曲线其实是“陡峭但短”
这套方案从选型到落地,我前后大概花了两周时间,其中第一周全在啃Univer的文档和示例代码,真正写业务逻辑其实只用了两三天。Univer的概念非常多——Facade API、插件、Command、Worksheet、Range——一开始容易被吓住。但只要理解了“所有操作都在操作Range对象”这层,后面就顺了。建议新手把官方demo仓库里的examples从头点一遍,胜过看十篇文档。
6.2 真正的边界在后端
在线表格的权限控制,本质上是把Excel的“保护工作表”和业务系统的角色权限模型做了一次映射。Univer只是给了你操作旋钮,规则设计还是要自己来。任何纯前端保护都有被绕过的可能,后端校验才是真正的边界。
如果你正在评估要不要用Univer,我的建议是先下载官方demo仓库,照着“Protection”和“DataValidation”这两个例子跑起来,然后试着把你要的业务表单做进去。跑通之后,你会对它到底适不适合你的场景有一个非常明确的判断——比我在这里写一万字都管用。