1. Univer 到底是什么:不只是又一个在线表格
1.1 拆开代码层看,Univer 更像一个表格引擎
说实话,Univer 第一次进入我的视野,是在一份开源项目周报里。当时只觉得是一个“能在线显示 Excel 的 JavaScript 库”,没有特别上心。直到后来做一个内部填报系统,被表格控件折腾得够呛,我才认真把它从仓库里拉下来跑了一遍,才意识到它和市面上那些“表格组件”根本不是一回事。
Univer 是一套基于 TypeScript 的跨端开源办公套件,核心形态是可嵌入的在线表格引擎,官方把它定位为“下一代跨端协同办公解决方案”。简单说,它不是一个给终端用户直接使用的成品应用,而是一个能把“类 Excel 的表格能力”嵌入到你自己业务系统里的基础设施。它支持工作表、文档、幻灯片三种文档类型,但在国内被讨论最多的,还是它做在线表格的部分——也就是大家常说的“univer在线表格能跑通什么样的业务场景”。
把它看成“表格引擎”而不是“表格组件”,是因为它的架构和普通 UI 组件有本质区别。Univer 的核心层不依赖浏览器 DOM,可以在纯 Node 环境运行,UI 层和核心逻辑层是分离的。所有针对表格的操作,比如改单元格、合并区域、调整行高列宽,都会被封装成一条一条的“命令”,再交给核心层执行。正因为有这一层命令封装,后面实现撤销重做、协同同步、权限拦截才成为可能——这一点在后续做单元格锁定时会非常重要。
上手之后你会发现,Univer 能做的远不止“展示一张表格”。公式计算、条件格式、数据校验、筛选排序、冻结窗格、合并单元格、跨表引用等能力都有,而且工作簿、工作表、单元格这套结构基本对齐 Excel 心智模型。对于要自建“在线填报系统”“管理后台列表编辑器”或者“类 Excel 业务表”的团队来说,它的可定制空间非常大。
1.2 热词里的刚需场景:一张“能填但锁不住”的表
这次搜索里有一句很典型的话:Univer 支持用户定义表格,让用户填写一些单元格,其他的单元格用户无法修改。这句话看起来很简单,实际上是一个特别普遍、也特别容易做砸的需求。
我见过太多团队在做这种“填写表”时,第一反应就是遍历单元格,在用户输入之前做一遍判断:哪些行哪些列是可编辑的,哪些必须只读。理想状态是“非白名单区域全部灰色锁定”,实际做出来却经常是:表头能被拖拽修改、合并单元格被误删、公式区域被用户一条粘贴覆盖掉,甚至用户复制粘贴还会绕过校验。说白了,一个在线表格如果只是 UI 上做只读限制,那它从头到尾都是漏的。
Univer 解决这个问题的思路,是支持把“编辑内容”“编辑结构”“选择区域”这类权限拆开控制。你可以定义某些单元格区域受保护,禁止别人改内容、改格式、改结构;也可以反过来,只开放一小块白名单区域给用户填写。再配合命令拦截层和服务端提交校验,基本能做到“该锁的锁死,该填的放开”。
这也是 Univer 相比纯“表格展示组件”的核心优势:它把权限控制能力做在了引擎这一层,而不是靠前端一个个事件去补。
1.3 选型对比:为什么我最终没选 SpreadJS 和 Luckysheet
在做在线表格选型时,绕不开几个同行:SpreadJS、Luckysheet、Handsontable、x-spreadsheet。我用自己的真实需求对照过一圈,列在下面供参考。
| 对比项 | Univer | SpreadJS | Luckysheet | Handsontable | x-spreadsheet |
|---|---|---|---|---|---|
| 开源协议 | 开源 | 商业授权 | 开源 | 开源/商业双轨 | 开源 |
| Excel 还原度 | 优秀 | 极强 | 中上 | 一般偏数据表格 | 简单 |
| 协同能力 | 底层支持 CRDT 协同 | 商业方案 | 社区协同不完整 | 无内置协同 | 无 |
| 二次开发自由度 | 高,核心与 UI 分离 | 受限 | 低 | 中 | 低 |
| 更新活跃度 | 高 | 商业维护 | 基本停滞 | 较高 | 低 |
| 集成成本 | 中等 | 中高 | 低 | 低 | 低 |
| 典型适合场景 | 在线协同办公、嵌入式编辑、自主可控业务 | 强 Excel 兼容、离线桌面端 | 简单在线表格展示 | 数据录入型页面 | 原型验证 |
你可能会问:那直接用 SpreadJS 不是更省心吗?如果预算充足、不希望自己深入改造,商业方案确实值得考虑。但如果你要的是“能融入自己技术体系、能改权限逻辑、能自己控制协同方案”的底层引擎,Univer 的开源属性和命令架构会更合适。Luckysheet 最大的问题就是社区更新慢,很多当年的 bug 到现在还挂着,不太敢用在长期维护的业务里。
2. 最小落地:把 Univer 塞进你的 Web 项目
2.1 初始化工程与安装 Univer 相关依赖
这里用一个 Vite + TypeScript 项目做最小验证。Univer 对现代浏览器要求不高,TypeScript 支持很友好,做集成时类型提示能省掉很多猜字段的时间。
npm create vite@latest univer-demo -- --template vanilla-ts cd univer-demo npm install接下来安装 Univer 相关依赖。Univer 现在采用多包架构,核心包、工作表插件、UI 插件、渲染引擎是分开的,官方把它们叫做“Univer 插件体系”。至少需要下面几个包才能跑起来一张在线表格。
npm install @univerjs/core @univerjs/sheets @univerjs/sheets-ui @univerjs/ui @univerjs/design @univerjs/locale @univerjs/engine-render这里有个值得注意的点:Univer 的版本更新很快,API 在不同大版本之间不保证完全兼容。我在实际项目中碰到过 0.x 时代的createUniver函数签名,到了 2.x 变成了new Univer,老文章里的代码直接拿过来大概率跑不通。所以安装完依赖后,最好先确认一下package.json里锁住的版本号,再到官方 GitHub 的 demo 目录里对照示例代码。
2.2 创建 Univer 实例并渲染在线表格
在页面里放一个容器,Univer 会把整个表格渲染到这个 DOM 节点里。容器的高度必须显式设置,否则表格会出现“高度为 0”的诡异现象,这是新手最容易踩的坑。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Univer 在线表格示例</title> <style> html, body, #univer-container { margin: 0; height: 100vh; } </style> </head> <body> <div id="univer-container"></div> <script type="module" src="/src/main.ts"></script> </body> </html>然后在main.ts里初始化 Univer 实例:
import { LocaleType, Univer, defaultTheme } from '@univerjs/core'; import { UniverRenderEnginePlugin } from '@univerjs/engine-render'; import { UniverSheetPlugin } from '@univerjs/sheets'; import { UniverSheetUIPlugin } from '@univerjs/sheets-ui'; import { UniverUIPlugin } from '@univerjs/ui'; import { zhCN } from '@univerjs/locale'; // 创建 Univer 实例 const univer = new Univer({ theme: defaultTheme, locale: LocaleType.ZH_CN, locales: [[zhCN]], }); // 注册渲染引擎插件 univer.registerPlugin(UniverRenderEnginePlugin); // 注册工作表插件 univer.registerPlugin(UniverSheetPlugin); // 注册 UI 插件,指定容器 univer.registerPlugin(UniverUIPlugin, { container: 'univer-container', header: true, toolbar: true, footer: false, }); // 注册工作表 UI 插件 univer.registerPlugin(UniverSheetUIPlugin); // 获取 Univer API 入口 const univerAPI = univer.__getAPI();这一步跑起来,页面上应该就会出现一张带工具栏的空白在线表格。Univer 渲染出来的交互是完整的:单元格点击、编辑、拖选、右键菜单这些基础能力都默认可用。
有两点实践经验可以分享:第一,footer这个配置项在部分版本里控制的是底部 Sheet Tab 栏,业务系统内嵌时通常建议开header关闭footer,让页面更干净;第二,如果你发现工具栏和单元格都是英文,检查locales参数是否传对了中文语言包,常见原因是没有把zhCN放进locales数组。
2.3 用 createUnit 定义可填写的表格初始数据
Univer 中“创建工作簿”的概念,在老版本里叫createUniverSheet,新版本统一成createUnit。我们可以在初始化之后,直接创建一份带有表头和数据格式的表格。
univer.createUnit(UniverSheetPlugin, { name: '部门经费预算填报单', sheetData: { default: { rowCount: 60, columnCount: 10, cellData: { '0': { '0': { v: '项目名称', s: { bg: '#f5f5f5', bl: 1 } }, '1': { v: '预算科目', s: { bg: '#f5f5f5', bl: 1 } }, '2': { v: '预算金额', s: { bg: '#f5f5f5', bl: 1 } }, '3': { v: '实际支出', s: { bg: '#f5f5f5', bl: 1 } }, '4': { v: '备注', s: { bg: '#f5f5f5', bl: 1 } }, }, '1': { '0': { v: '' }, '1': { v: '' }, '2': { v: '' }, '3': { v: '' }, '4': { v: '' }, }, }, }, }, });sheetData的结构和 Fax 引擎内部数据结构一致,row和column用数字索引,单元格对象{ v: 值, s: 样式 }表示内容与样式。你可以调用univerAPI.getActiveWorkbook()拿到当前工作簿,再通过getActiveSheet()操作活动工作表。
这一阶段不用急着做权限,先把一张表格跑起来、数据能渲染、单元格能编辑,就已经完成 80% 的集成工作了。
3. 核心实现:可填写区域 + 其余单元格锁定
3.1 需求拆解:权限控制只有“编辑内容”还不够
我看到很多项目在实现“其他单元格用户无法修改”时,只是把输入框里的内容disabled了,这远远不够。用户依然可以复制粘贴、拖拽填充、插入行、删除列、改样式。所以做单元格锁定前,要明确表格层级里有哪些操作会破坏数据:
- 修改单元格内容:输入、粘贴、公式计算产生的新值
- 修改单元格样式:字体、颜色、背景
- 修改区域结构:插入行、删除列、合并单元格
- 移动单元格内容:拖拽填充、剪切粘贴
Univer 的权限模型把这些操作拆成了不同的“权限点”,可以针对某个工作簿、某张工作表、某个单元格区域分别设置。最常用的是对 Range 设置保护,把“编辑内容”和“编辑结构”同时关闭,让目标区域变成纯只读。再加上命令拦截层做兜底,基本能覆盖上面所有破坏路径。
3.2 用保护区 API 锁定禁止编辑的区域
我按当前主推的 2.x API 风格写一个锁定示例。具体方法名和字段在不同版本可能有出入,跑之前先到官方示例里对一下。
const workbook = univerAPI.getActiveWorkbook(); const sheet = workbook?.getActiveSheet(); // 锁定第一行表头区域:从第 0 行第 0 列开始,共 1 行 5 列 if (sheet) { const headerRange = sheet.getRange(0, 0, 1, 5); headerRange.setProtection({ editContent: false, editStructure: false, hintText: '该表头区域已锁定,不可修改', }); }这个操作的意思是:把第一行 A 到 E 列的表头区域设成受保护状态,用户点击这个区域时无法进入编辑,会显示提示文本。如果后续要放开某一块区域,可以重新调用setProtection传入新的权限配置,或者直接移除保护。
要注意的是,setProtection在某些版本里会要求传一个完整的保护配置对象,缺字段可能直接抛错;有些老版本则需要通过univerAPI.getCommandService().executeCommand()发送“添加保护区”命令来实现。不同版本差距较大,使用时优先看官方示例里 “Protection” 的 demo。
3.3 用命令拦截做最终兜底
保护区 API 是在引擎层对用户操作做了拦截,但在我看来,真正要保证“绝对锁死”,单靠保护区还不够。因为某些批量操作、粘贴行为、程序化调用会走不同路径,可能在保护区判断之外执行。更稳妥的方式,是在命令执行前统一拦截一遍,判断当前命令是否涉及“写入操作”,再结合业务白名单决定放行还是阻止。
Univer 的onBeforeCommandExecute可以注册一个全局前置拦截器,返回false表示阻止该命令执行。可以参考下面的代码:
const editableRanges: Array<{ row: number; col: number }> = [ // 这里存一张“允许填写”的格子清单 // 例如:第 2 行第 0 列、第 2 行第 1 列... ]; univerAPI.onBeforeCommandExecute((command: any) => { const commandId = command?.id || ''; // 只有涉及到修改单元格值的命令才需要拦截 if (commandId.includes('set-range-values') || commandId.includes('paste')) { const params = command.params || {}; const row = params.range?.startRow; const col = params.range?.startColumn; // 判断目标区域是否在可编辑白名单里 const isEditable = editableRanges.some( (range) => range.row === row && range.col === col ); if (!isEditable) { return false; // 阻止命令执行 } } return true; });这个方案的好处是:所有针对单元格值的修改操作都必须经过命令层,即使某个具体编辑器组件忘记做 UI 判断,这里也会兜住。但要注意一点——onBeforeCommandExecute拦截覆盖范围比较广,调试时容易影响正常的工具栏操作,建议只在“锁定场景”下挂载,页面退出时及时移除监听。
3.4 读取用户填写数据并回传业务后端
锁定只解决了“不能改哪里”的问题,还有另一半是“用户填了什么”。实际业务里通常两件事会一起做:监听单元格变化,以及提供“保存/提交”按钮主动收集数据。
监听单元格变化,官方提供事件订阅 API:
import { UniverInstanceType } from '@univerjs/core'; univerAPI.getEventManager().on( UniverInstanceType.SHEET, 'univer:sheet:on-cell-change', (change: any, state: any) => { console.log('用户修改了单元格:', change.row, change.column, change.value); // 这里可以把变化实时同步到后端草稿接口 } );如果是批量提交,我更推荐在“提交按钮”触发后,直接读取整个工作表的有效数据,一次性交给后端。这样后端拿到的是一份完整快照,而不是一堆离散的变更事件。
function collectFormData() { const workbook = univerAPI.getActiveWorkbook(); const sheet = workbook?.getActiveSheet(); if (!sheet) return; // 读取从第 0 行第 0 列开始到第 59 行第 4 列结束的数据 const values = sheet.getRange(0, 0, 60, 5).getValues(); const payload = values .filter((row) => row.some((cell) => cell?.v !== '')) // 去掉空行 .map((row) => row.map((cell) => cell?.v ?? '')); // 提交到业务后端 fetch('/api/fill/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); }这里有个小陷阱:getValues()返回的单元格值类型取决于单元格设置,可能是字符串、数字、对象甚至公式对象。提交前最好先做类型清洗,避免把{ v: 123 }这种对象结构直接扔给后端。
4. 在线协同与动态权限:一张表多人同时填
4.1 Univer 在线协同的基本思路
热词里强调“univer在线”,其实就是指它天然适合做成多人协同填写的在线表格。Univer 底层通过 CRDT 数据结构做协同,常用的是 Yjs 这套方案。多个用户在各自客户端操作同一份文档,操作产生的是增量命令,通过协同协议分发给房间里的其他客户端,再合并到本地状态。
这和“Ctrl+C / Ctrl+V 到服务端再广播”的老式方案完全不一样。CRDT 的好处是不存在传统“最后写入覆盖”问题,哪怕两个人同时编辑同一列相邻单元格,最终也能收敛到一致状态。Univer 的命令系统本身很适合这种协同模型:所有编辑都是命令,命令可以在网络中传输,不需要一帧一帧同步屏幕截图。
如果你打算自建在线协同,需要准备三个部分:一是 Univer 客户端协同插件,二是协同房间服务,三是数据持久化。官方仓库里有协作示例,可以参照collaboration相关 demo。
4.2 与业务权限联动:不同人打开同一张表看到的锁区不同
单元格锁定和“在线协同”遇到一起时,真正的难点不是技术,而是业务策略。同一个填报单,部门经理打开应该能改预算科目,普通员工打开只能填实际支出,这是很典型的动态权限需求。
我的建议是:前端权限只负责展示层和输入体验,服务端必须再做一层数据提交校验。换句话说,锁区的控制要让用户“根本进不去编辑”,但这并不能替代后端的最终审核。因为用户可以直接打开浏览器调试工具,绕过前端限制发送修改请求。一个成熟的填报表业务,至少要保证后端有能力验证“提交的数据是否越过了允许范围”。
动态权限在客户端可以做成一查表配置:当前用户角色 → 可编辑单元格列表 → 页面加载时批量设置保护区。比如普通员工的可编辑范围只有 C、D 两列,管理员的可编辑范围是全部区域,这种配置放在数据库里最好,页面初始化时再由前端拉取。
4.3 协同填报表:实时同步与提交校验的几个注意点
多人同时填写一张表,还有一个很容易被忽略的问题:用户 A 填写的单元格,可能和用户 B 提交的数据发生冲突。虽然 CRDT 能保证最终一致,但业务上的“覆盖错误”并不会因为底层一致就自动消失。
我在实际项目中加了这样几层防护:
- 单元格级监听:只在可编辑区内监听变化,把用户输入实时同步到草稿状态;
- 提交前校验:遍历提交数据,检查必填项、数字格式、金额上限;
- 服务端版本校验:提交时带上表格版本号,如果服务端发现版本已经变化,要求用户刷新后重新确认。
协同不是“把表格变成聊天室”,而是把编辑体验平滑地交给多人。如果用户同时只有三五个人填报,其实也不需要上全量实时协同,定期拉数据、提交时合并也够用。技术选型上,先想清楚业务规模,再决定要不要上 Yjs 那套重量级方案。
5. 实操中踩过的坑与排查经验
5.1 版本差异是集成时最大的坑
Univer 版本迭代速度非常快,很多 API 在 2024 年内就发生了调整。我最初参考的博客代码还是createUniver()老写法,当时直接用报错:找不到函数。后来对照官方 example 目录才发现,新版已经全面改成new Univer()+registerPlugin()的写法。
如果你发现复制过来的代码报错,优先做两件事:
- 打开
node_modules/@univerjs/core/package.json确认实际安装版本; - 到官方 GitHub 仓库的
examples目录里,找当前版本对应的初始化代码。
还有一个常见问题:Univer 不同插件的版本必须一致,如果@univerjs/core是 0.5.x,而@univerjs/sheets-ui是别的版本,运行时会报各种奇怪的模块错误。建议安装时统一用npm install @univerjs/xxx@latest,并且一次性安装同一批,避免新旧混用。
5.2 容器、样式与高度问题:表格渲染成“一条线”
症状是页面里明明调用了插件,但表格区域几乎看不见,只有很窄的一条线或者完全空白。排查思路很简单:Univer 需要容器具备确定的高度。如果父元素是height: auto,子元素高度计算为 0,渲染引擎会导致表格无法撑开。
解决办法是在 CSS 里给容器设置固定高度,比如height: 600px或height: calc(100vh - 50px)。在后台管理系统里,如果容器在 Tab 页内,还要注意 Tab 页刚渲染时容器是否对引擎可见,不可见时可能需要手动触发一次 resize。
样式污染是另一个隐性坑。Univer 内部样式会直接注入页面,如果项目里同时使用 Tailwind 的全局 reset 或者其他 UI 库的全局样式,可能会出现按钮错位、菜单样式冲突。稳妥做法是把 Univer 放在一个相对隔离的区域内,必要时使用 iframe 承载。虽然 iframe 不优雅,但在“先保证不出问题”的前提下,它是最可靠的隔离方式。
5.3 性能与内存:页面卸载后表格还在运行
在 Vue 或 React 项目里使用 Univer,最容易出现的问题是路由切换后,Univer 实例仍然被引用,监听器、命令拦截器、渲染引擎都没有销毁。结果就是用户来回切换页面后,浏览器内存一路飙升,甚至出现多个表格实例互相干扰。
正确的做法是在组件卸载时主动销毁 Univer 实例。
onBeforeUnmount(() => { univer.dispose(); });如果你的业务是“后台常驻一张主子表”,也可以不销毁,但要保证全局只有这一个实例。我早期犯过的错是把 Univer 实例挂到某个局部变量里,页面切走又切回时重新初始化了一份,结果两张表格叠加在一起,单元格错位。后来改成了“先销毁再重建”或者“全局单例复用”的策略,这个问题就彻底消失了。
5.4 常见问题速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 表格不显示或高度异常 | 容器高度为 0 | 给容器设置显式高度;Tab 切换后触发 resize |
| 中文界面变成英文 | locale 配置缺失 | 在构造参数里注册zhCN语言包 |
| 单元格无法编辑 | 保护区配置误设或权限拦截器生效 | 检查setProtection参数,确认可编辑白名单 |
| 工具栏按钮报错 | 插件版本不一致 | 统一升级到相同版本,重新安装依赖 |
| 复制粘贴后数据异常 | 粘贴命令绕过 UI 校验 | 在命令拦截层额外校验目标区域权限 |
| 路由切换后内存上涨 | 实例未销毁 | 组件卸载时调用univer.dispose() |
createUniver函数找不到 | 版本变化导致 API 更名 | 改用new Univer()+ 插件注册方式 |
最后说一点个人体会。Univer 确实解决了我很长时间以来的痛点:以前做在线填报,总是不得不用“一堆 div 拼表格”或者“花钱买商业控件”,前一种体验差,后一种改不动。Univer 把完整表格能力还给了开发者,但它不是一个开箱即用的成品,业务逻辑、权限边界、提交策略,这些都要自己一层层设计和实现。如果你只是为了一个三五行的表单,没必要为它引入整套引擎;但如果你想做的是真正长期迭代的在线表格类业务,在它身上投入的时间,大概率是值得的。