1. univer是什么:从一次“表格卡死”的崩溃说起
事情得从一次真实的崩溃讲起。去年我负责一个内部数据看板项目,前端用某传统电子表格开源库渲染两千多行带格式的报表,结果浏览器直接卡死,滚动都费劲。后来换成 univer,同样的数据量,初始化快了一个量级,操作跟手,这才算彻底解了围。
univer 是一个开源的、面向 Web 的电子表格解决方案,简单说就是“在浏览器里跑起来的 Excel”。它不是一个简单的表格展示组件,而是一整套可以嵌入现有系统的办公套件。核心包括电子表格(Spreadsheet)、文档(Document)和幻灯片(Slide),当前最成熟、社区讨论最多的就是电子表格部分,也是大多数项目接入它的第一站。
它能解决什么问题?最直接的是把“导入 Excel 文件、在线编辑、多人协作、导出结果”这一整条链路搬到前端,不用再依赖桌面 Office。适合谁?如果你正在做在线教育作业系统、企业内部数据平台、低代码表格配置、甚至 SaaS 产品里的报表编辑模块,univer 几乎是为这些场景量身定做的。它最吸引人的点有三个:性能好、架构干净、二次开发友好。这也是我在多个方案里最终选它的原因。
写这篇文章,是想把我从选型、接入到踩坑的完整过程整理出来。内容会覆盖核心架构、初始化配置、数据绑定、协同编辑、以及真实项目中常见的性能问题和调试思路。不管你是刚开始了解 univer,还是已经踩了几个坑,应该都能从中找到有用的东西。
2. 为什么选 univer:核心思路与技术拆解
2.1 从架构上看 univer 的设计理念
先说结论:univer 的设计核心是“插件化 + 领域模型驱动”。它不像很多开源表格库那样把渲染、数据、交互全部揉在一个大类里,而是拆成了多个独立模块,通过统一的分发机制协同工作。
第一层是核心层,也就是@univerjs/core,负责定义工作簿、工作表、单元格、样式这些底层数据模型,以及一套可扩展的命令系统。第二层是渲染引擎@univerjs/sheets-render,负责把数据模型绘制成画布上的真实界面。第三层是交互与控制层,包括@univerjs/sheets、@univerjs/ui等,处理用户操作、编辑、选区、快捷键这些功能。再往上就是各种可选插件,比如协同编辑@univerjs/sheets-collaboration、公式引擎、条件格式、筛选、数据透视等,按需加载。
这套架构带来的直接好处是按需加载。一套完整表格功能少说几十个模块,要是全部塞进首屏,体积必然失控。univer 的插件机制能够做到“用到哪个模块才加载哪个模块”。我实际测过,最小化集成的包体积比传统全功能表格组件有明显优势,首屏加载速度提升不是一点半点。
另一个关键设计是命令系统(Command)。在 univer 里,几乎所有对数据的修改,包括输入值、改样式、插入行、删除列,都会转化为一条命令。命令有两个好处:一是方便撤销与重做,二是天然适合协同场景,因为协同的本质就是“把命令广播给别人,别人再执行同样的命令”。这个设计在后面的协同章节会详细说。
2.2 渲染引擎为什么快:Canvas 与局部刷新的取舍
老实说我第一次打开 univer 的性能表现时很惊讶,两千行的表格拖动、输入,几乎没有迟滞感。这套渲染引擎的底层思路,是把 DOM 表格换成了 Canvas 绘制,加上局部刷新策略。
传统 DOM 表格每个单元格都是一个真实节点,两千行乘以二十列就是四万个 DOM 节点,浏览器光是维护这些节点就已经到极限了。Canvas 的做法是把整个表格当成一张“画”,每次只重绘可见区域。上下滚动时,引擎根据滚动偏移量快速计算哪些行和列需要画,只更新那些区域,而不是重新绘制整张画布。
这里有个值得注意的细节:univer 不是一上来就全用 Canvas,而是保留了一部分 DOM 层用于编辑器输入。也就是说,你在单元格里打字的时候,实际上是覆盖了一个真正的输入框,让原生键盘体验得到保留。确定输入后,输入框消失,值靠 Canvas 渲染。这种混合架构兼顾了性能和输入体验,是一个很聪明的折中。
2.3 数据模型与 DOM 分离:摆脱“改一个单元格卡一下”的魔咒
用传统表格库的时候经常遇到这个场景:每改一个单元格,整个表格重新渲染一次。数据量小没事,数据量一大,改一个格子卡一下,特别难受。univer 通过数据模型与渲染分离的结构,彻底避免了这个问题。
数据模型存的是“纯数据”:哪个单元格是什么值、什么样式、被什么公式引用。修改单元格时,命令系统只更新这块数据,然后通知渲染引擎“哪些单元格变了”。渲染引擎拿着变更后的数据,只重绘受影响的单元格区域。数据量大时,这种“局部更新”的方式能保持操作流畅,因为它跳过了周边大量无用节点的重绘成本。
这个设计和 React 的虚拟 DOM 思路类似,但运行层级更低,直接面向 Canvas 绘制指令做 diff。理解这一点对排查性能问题特别有帮助,后面会专门讲到。
3. 从零接入 univer:环境配置与最小实现
3.1 环境准备与依赖安装
接入 univer 之前,先把环境理清楚。它的生态是基于 TypeScript 和现代打包工具(Vite、Webpack 都可以)构建的,推荐使用 Vite 作为开发服务器,因为启动快、热更新方便。Node 版本建议 16 以上,我当时用的是 18。
以下是在一个全新的 Vite + React 项目里接入 univer 的基本步骤。
# 创建项目 npm create vite@latest univer-demo -- --template react-ts cd univer-demo # 安装 univer 核心包 npm install @univerjs/core @univerjs/sheets @univerjs/ui @univerjs/sheets-ui @univerjs/sheets-formula # 如果你后续要做协同,再装协同包 npm install @univerjs/sheets-collaboration这里需要注意版本对齐问题。univer 的各个包之间版本必须严格一致,我当时第一次装的时候没注意,core 装了 0.1.0,sheets 却装到了 0.2.0,结果启动直接报类型错误。建议装完后立刻检查一遍 package.json,确保所有 @univerjs 开头的依赖版本号相同。
3.2 初始化核心实例:最小可运行代码
安装完成后,一个最小可运行的初始化代码如下:
import { createRoot } from 'react-dom/client'; import { Univer } from '@univerjs/core'; import { defaultSheetPluginConfig } from '@univerjs/sheets'; import { UniverUIPlugin } from '@univerjs/ui'; // 初始化 Univer 实例 const univer = new Univer({ locale: 'zhCN', plugins: [ UniverUIPlugin({ container: document.getElementById('app')!, }), ...defaultSheetPluginConfig(), ], }); // 创建空白工作簿 univer.createUnit(UniverInstanceType.UNIVER_SHEET, { id: 'sheet-1', name: '示例工作簿', sheetOrder: ['sheet-1'], sheets: [ { id: 'sheet-1', name: 'Sheet1', rowCount: 1000, columnCount: 100, }, ], }); createRoot(document.getElementById('app')!).render( <div id="univer-app" style={{ width: '100%', height: '100vh' }} /> );注意createUnit和createRoot要分别处理:createUnit是在 univer 内部创建一个工作簿单元,createRoot是 React 的渲染入口。UI 插件的 container 指向一个真实的 DOM 节点,univer 会在里面挂载自己的渲染引擎。
初始化后你会看到一个空白表格,能点、能选、能输入,这就是最基础的可用状态。
3.3 实用配置:语言、主题与默认视图
接入时有几个高频配置值得提前了解。
第一个是语言。univer 的界面文案支持国际化,默认英文(enUS),中文需要显式设置:
import { zhCN } from '@univerjs/core'; const univer = new Univer({ locale: zhCN, // 其他配置... });第二个是主题。当前 univer 主要提供浅色主题,深色主题仍在完善中。如果你的项目整体是深色系,可能需要先做一层 CSS 变量覆盖,或者等待官方深色方案稳定后再升级。
第三个是默认视图。工作中我经常需要让表格只显示某些工作区,或者隐藏行列头,可以通过workbookContainer以及布局配置来控制。这块配置相对细,建议以官方文档为准,同时保留一份“能跑优先”的心态,初期不用追求所有 UI 细节一次到位。
4. 数据绑定与表格操作:从“能看”到“能用”
4.1 数据填充的三种方式对比
接入 univer 之后,第一件想做的事通常是填充数据。根据数据来源不同,我总结出三种常见方式。
方式一:初始化时直接传入数据
适合数据一次性确定、后续变化较少的场景,比如报表预览、静态模板。在创建 workBook 时,直接在 sheet 配置里给 cellData 赋值:
sheets: [ { id: 'sheet-1', name: '数据页', rowCount: 1000, columnCount: 100, cellData: { 0: { 0: { v: '姓名', s: { bold: 1 } }, 1: { v: '销售额', s: { bold: 1 } }, }, 1: { 0: { v: '张三' }, 1: { v: 12800 }, }, }, }, ]方式二:通过 API 批量写入
适合数据来自后端接口、需要动态渲染的场景。用ICommandService执行SetRangeValuesCommand来实现:
import { ICommandService, SetRangeValuesCommand } from '@univerjs/core'; const commandService = univer.__getInjector().get(ICommandService); commandService.executeCommand(SetRangeValuesCommand.id, { unitId: 'sheet-1', subUnitId: 'sheet-1', range: { startRow: 0, startColumn: 0, endRow: 9, endColumn: 3, }, value: [ [ { v: '产品' }, { v: '销量' }, { v: '单价' }, { v: '库存' }, ], // 后续行... ], });这种方式比较规范,写入时会自动触发渲染更新,且包含在命令系统里,方便后续做撤销。
方式三:手动逐格输入
完全模拟用户操作,适合测试和演示。通过 UI 层的事件触发,虽然不是数据写入的主流方式,但在写体验用例时很实用。
三种方式各有适用场景,我通常遵循这个原则:初始化数据走方式一,动态接口数据走方式二,交互演示走方式三。
4.2 读取数据与监听变更:拿回控制权
能写数据还不够,很多时候需要把用户编辑的数据“拿回来”。两种思路比较常用。
轮询读取适合定期同步,比如五秒同步一次未保存的修改。直接读取IWorkbook实例的 snapshot 即可:
const workbook = univer.getActiveWorkbook(); const activeSheet = workbook.getActiveSheet(); const cellData = activeSheet.getCellData(0, 0);监听变更事件适合实时响应,比如计算当前表格的总和、检测敏感字段修改。univer 事件系统会派发单元格变更、选区变化等事件,在onCellEdit这类回调里实时抓取数据变化:
import { SheetsEvent } from '@univerjs/sheets'; univer.on(SheetsEvent.CellEdit, (params) => { console.log('单元格变更:', params); });4.3 常用操作:插入行列、删除行列、合并单元格
实际项目中,操作逻辑大概率要封装成自己的工具栏按钮。univer 的命令系统让这些操作变得很规范。插入行:
commandService.executeCommand(InsertRowCommand.id, { unitId: 'sheet-1', subUnitId: 'sheet-1', rowIndex: 3, rowCount: 1, });合并单元格:
commandService.executeCommand(SetRangeValuesCommand.id, { unitId: 'sheet-1', subUnitId: 'sheet-1', range: { startRow: 0, startColumn: 0, endRow: 0, endColumn: 2 }, value: [ [{ v: '合并区域标题' }] ], });这些命令都要通过ICommandService来执行,好处是自动纳入 undo/redo 栈。用户按 Ctrl+Z 能撤销,这套体系是天然支持的。
5. 协同编辑集成:多人同时填表是怎么做到的
5.1 协同的核心原理:命令广播与状态合并
协同编辑是 univer 最吸引人的功能之一。它的核心原理可以这样理解:大家本地各自维护一份表格数据,当谁改了某个单元格,不是把整张表发给别人,而是生成一条“命令”(比如“把 A2 改为 100”),发给服务器,再由服务器广播给所有人。其他人收到命令后,在自己本地的数据上执行同样的修改。
之所以能做到不冲突,是因为 univer 把数据分层拆得很细:每个单元格、每个样式、每个选区都是独立实体,命令精确指向这些小颗粒度对象。两个人同时编辑不同单元格时,各自执行的命令互不干扰,最终合起来就是完整的最新状态。
这套体系在架构上天然适合协同,因为命令系统本来就是 univer 的内建机制。你不需要为了协同重构业务逻辑,只需要把命令序列同步到服务端。
5.2 接入协同的必要配置
要跑起协同,需要三样东西:协同服务端、协同插件、以及一个专门用来记录操作序列的日志模块。
服务端部分,univer 官方提供了univer-collaboration-server示例,但生产环境建议自己基于 WebSocket 实现。基本流程是:客户端把命令发给服务端,服务端做冲突处理,然后广播。
客户端配置:
import { UniverSheetsCollaborationPlugin } from '@univerjs/sheets-collaboration'; const univer = new Univer({ plugins: [ // ...其他插件 UniverSheetsCollaborationPlugin({ // 传入协同服务地址 url: 'wss://your-server.com/collab', }), ], });命令同步逻辑大致如下:
import { ICommandService } from '@univerjs/core'; import { SetRangeValuesCommand } from '@univerjs/core'; const commandService = univer.__getInjector().get(ICommandService); // 本地执行命令 commandService.executeCommand(SetRangeValuesCommand.id, params); // 通过 WebSocket 广播给其他人 ws.send(JSON.stringify({ type: 'command', commandId: SetRangeValuesCommand.id, params, }));5.3 冲突处理与字段锁
做过协同的人都清楚,最怕的不是同时改不同单元格,而是同时改同一个单元格。univer 的默认策略是“后写入覆盖前写入”,但如果你们的产品不希望这种结果,就需要引入更细的字段锁或版本号机制。
我在实际项目中用过一个简单的乐观锁方案:每个单元格维护一个version字段,修改时必须携带版本号;服务端发现版本号不匹配,就返回冲突错误,客户端弹窗提示用户选择“使用难拿的值”或“保留本地值”。这套方案接入成本低,不用深度定制 univer 内部算法,很适合快速上线。
5.4 协同实践中容易踩的坑
踩过几次坑之后,有几个经验特别想分享。
一是不要把大量历史命令一次性广播。初始化协同会话时,新加入的人不应该收到几千条历史命令,应该只拿当前状态快照,然后只同步之后的新命令。否则初次加载会非常慢。
二是要注意命令的幂等性。协同场景要求每一条命令执行多次后的结果等价于执行一次,否则网络重试机制会带来数据错乱。univer 大部分命令满足这个要求,但自定义命令时需要格外注意。
三是服务端必须做操作日志持久化。一旦某个客户端短时间断线重连,需要用日志把错过的命令补发给它。没有日志,协同就是个只能玩不能用的摆设。
6. 性能优化与常见问题排查实录
6.1 大数据量下的性能瓶颈与优化策略
univer 再怎么强调性能,遇到极端情况也一样会有压力点,尤其是三四万行、上百列的超大数据表。性能瓶颈主要集中在三个地方:首次初始化、滚动渲染、批量写入。
首次初始化的优化,核心思路是“延后加载”和“降级渲染”。如果你有 4 万行数据,完完全全铺开填充 cellData,创建速度快不了。可以只给用户看到的前 1000 行填充数据,其余行在滚动到位后再按需加载。univer 支持懒加载模式,可以借助它的可视区域回调来实现。
滚动渲染的优化,主要是调整 Canvas 的像素比和缩放级别。默认值在主流的 2k 屏上没问题,但如果你在 4k 高分屏上还开默认值,会发现滚动速度和清晰度都有损失。可以通过配置canvasPixelRatio来做平衡。
批量写入的优化,尽量走批量命令而不是逐格命令。逐格写入时会触发多次局部重绘,两万行逐格写入比两万行一次写入慢好几倍。合并同区域的写入,是 ini 项目里最实用的一招。
6.2 单元格输入异常与公式不刷新问题
最常见的输入异常表现在中文输入法下。初次接入时,我发现中文输入法状态下输入拼音字母会先输出字母,再输出汉字,而且首字符会错误地进入格子。排查下来,这是输入法合成事件没有完全被 univer 的编辑器识别导致的。
一个简单有效的解决办法是,在输入前通过判断event.isComposing忽略合成中的事件,让输入框在中文输入完成前不提交内容。这个逻辑需要自己在外层拦截,或者在 univer 的编辑器设置里打开原生输入法支持。
公式不刷新问题是另一件高频事。当你手工输入=A1+1时,A1 改了,B1 却不更新。大概率是设置公式的方式不对,或者公式插件没有被正确注册。用命令系统设置公式时,必须确保单引号包裹的公式字符串被正确解析,同时确认@univerjs/sheets-formula在插件列表里处于合适的位置。
6.3 样式更新丢失与撤销栈异常
样式更新丢失的场景主要有两个:通过setStyle直接改完,界面没有重绘;或者改了之后,一旦滚动,样式又变回去了。前者的原因是修改没有经过命令系统,底层数据变了但渲染层不知道。此时应改用命令或 forceRender 强制刷新。后者则要检查是不是在 snapshots 之外修改了不可变数据,要避免直接篡改单元格快照对象。
撤销栈异常最常出现在自定义命令里。写自定义命令时要注意两点:命令类必须注册到 CommandService;命令执行时要区分是否应该纳入撤销历史。有些操作比如“初始化数据”,就不应该让用户 Ctrl+Z 撤销到空白页面。通过调整命令注册的参数,可以控制在 undo/redo 栈中的表现。
6.4 常见问题速查表
我把高频问题整理成一张速查表,方便你对照处理。
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 初始化很卡 | 数据量过大,一次性填充 | 分片加载或懒加载,只渲染可视区域 |
| 滚动掉帧 | Canvas 像素比过高或过低 | 调整canvasPixelRatio,做 1~2 次实测 |
| 中文输入先出字母 | 输入法合成事件处理不完整 | 拦截isComposing,等待合成结束 |
| 公式不刷新 | 公式插件未注册或设置格式错误 | 检查插件顺序,改用命令设置公式 |
| 样式更新后消失 | 绕过命令系统直接修改 | 统一走命令,触发渲染层刷新 |
| 撤销操作异常 | 自定义命令注册不当 | 检查命令是否注册且是否纳入历史栈 |
| 协同错乱 | 命令幂等性不满足 | 服务端做日志归并与冲突校验 |
| 新加用户全量拉取历史 | 初始化策略不当 | 改为快照加增量同步 |
7. 二次开发与扩展:把 univer 变成自家的产品
7.1 理解插件开发基本套路
univer 的扩展性是我最喜欢的一点。插件开发的基本套路,就是继承一个插件基类,注册依赖、监听事件、暴露服务。
import { Plugin, ICommandService } from '@univerjs/core'; export class MyPlugin extends Plugin { constructor(private _config: MyPluginConfig) { super('my-plugin'); } override onStarting() { // 拿到核心服务 const commandService = this._injector.get(ICommandService); } override onReady() { // 插件初始化完成后执行 } override onDestroy() { // 清理资源 } }这个生命周期分三个阶段:onStarting适合注册依赖,onReady适合绑定事件和 UI,onDestroy做清理。整体思路和很多现代前端框架的插件机制相似——生命周期干净,注入清晰。
7.2 开发自定义工具栏按钮:一键导出数据
实际项目里最常做的一个插件,是把表格数据转换为业务所需格式导出。举一个例子:把当前 sheet 的所有选中区域导出为 JSON 数组。
大致思路是在工具栏注册一个按钮,点击后执行一段命令逻辑,读取选区数据并转成目标格式。这个方法里,核心是拿到IWorkbook和SelectionManager,遍历选区取数。
commandService.executeCommand(ExportJsonCommand.id, {}); // 在命令处理函数中 const workbook = univer.getActiveWorkbook(); const sheet = workbook.getActiveSheet(); const selection = sheet.getSelection(); // 遍历选区,读取单元格数据并组装 JSON在这里最容易踩的坑是:没有考虑选区为空或跨表的情况。建议在命令开头加一个空选区判断,提示用户先选中区域。
7.3 嵌入现有系统的思路与注意事项
很多同学关心“怎么把 univer 嵌入到我现有的后台管理系统里”,其实没有想象中复杂。核心是容器和资源隔离。给 univer 分配一个独立容器元素,控制好宽高;CSS 尽量做隔离,避免和你的 UI 框架样式互相污染。univer 自带一套样式变量,你可以在外层主题上统一调整,而不是每个元素去覆盖。
依赖方面,univer 与 React、Vue 都能共存,本身不绑定特定框架。我试过在 Vue 3 项目里嵌入 univer,过程和 React 基本一致。关键是遵守单实例原则,一个页面不要初始化多个 Univer 实例,否则事件和命令体系容易互相干扰。
8. 写在最后:我对 univer 的几点真实感受
聊了这么多,最后分享一些个人体会,也是我在项目里反复验证过的。
第一个体会是:不要贪图一开始就把所有插件都装齐。univer 的插件化设计,本质上就是让你按需使用。我刚开始图省事,一次性把公式、筛选、条件格式全装了,结果首屏加载慢了一截。后来只保留项目真正用到的模块,速度提升非常明显。装得越多,并不代表越好。
第二个体会是:命令系统是 univer 的灵魂。无论你之后要做协同、做撤销、做审计日志,还是做自动化流,最终几乎都要落到命令上。写业务功能时,宁可在命令层多封装一层,也别在 UI 层直接操作数据。这个习惯让我后来做协同接入时省了太多事。
第三个体会是:univer 社区还在快速迭代期,版本变化比较大,接生产环境时一定要锁版本,并且保留升级时间预算。它的 API 在向稳定方向演进,但短时间内不建议每周都追新版本。我在 0.1.x 时期升级两次后,就养成了“每季度评估一次升级”的习惯。
如果你正在为 Web 表格的选型发愁,或者已经在 univer 里摸索了一段时间,希望这篇文章能帮你少走一些弯路。这套架构值得投入时间研究,用顺了之后,你会发现在浏览器里做一套接近桌面体验的电子表格,是完全做得到的。