Univer实战:开源电子表格组件的集成、命令机制与二次开发

1. Univer是什么:一个被低估的电子表格类开源项目,我为什么盯上它

先说结论:Univer是一套开源、可扩展的办公文档解决方案,核心起步点是电子表格(Spreadsheet),但它并不是又一个"换个皮肤的花式Excel克隆"。它把自己定位成"Canvas渲染 + 前端实时协作 + 插件化架构"的底层平台,未来还要覆盖文档和幻灯片。这个定位意味着,如果你打算在自家产品里嵌入一套表格能力(不是那种静态展示表格,而是用户能真正编辑、能跑公式、能多人协作的在线表格),Univer是目前少数能让你从"差不多能用"直接跳到"贴着业务需要二次开发"的候选方案。

我第一次注意到Univer,是因为它在GitHub上的热度涨得很快,Star数量翻得猛,但社区里的中文实操文章却少得可怜。很多人在讨论区问"这跟Luckysheet什么关系""能不能直接用React/Vue包一层上线",回答零零散散。我花了两三天把它的文档、源码示例、插件机制翻了一遍,又直接上手拉了一个React演示项目跑通,这篇就是想把"从一个标题到真正跑起来"的全过程,包括那些文档没写明白的坑,一次性讲透。

如果你属于下面几类人,这篇文章应该对你有用:

  • 正在做低代码平台或SaaS应用,需要内置一套可编辑表格,但不想从零写公式引擎和选区渲染。
  • 之前用过Luckysheet、Handsontable,想评估迁移到Univer的成本和技术可行性。
  • 对前端Canvas渲染、协同编辑、插件化架构感兴趣,想找一个真实项目做源码阅读对象。

需要提醒的是,Univer目前版本迭代非常快,接口变化也不小。我写这篇文章时用的是它2.x版本的思路和代码,你读的时候如果遇到API对不上,优先去看官方文档的Migration说明,这本身也是开源项目跟进的常态。

2. 为什么值得关注:表格类库的选择困境与Univer的差异点

在真正把Univer跑起来之前,值得先花点时间想清楚一个问题:我们为什么需要Univer,而不是继续用已经成熟的方案?不是所有项目都需要它,但如果你恰好处于下面这些场景,Univer的价值会非常明显。

2.1 现有方案解决不了的那类需求

市面上的表格方案大致可以分三类:

  • 纯展示型,比如用HTML表格、Canvas绘制的静态报表。优点是轻量,缺点是用户没法在上面做真正的编辑操作。
  • 组件型,比如Handsontable、AG Grid。这些组件在数据录入、行列操作、虚拟滚动方面做得很好,但公式引擎、样式模型、多Sheet联动通常比较弱,或者说它们更像"带有网格界面的数据组件",而不是完整的表格应用。
  • 完整型,比如Luckysheet、SpreadJS、腾讯文档表格。这类是从用户视角出发的完整在线表格。

Univer想做的就是完整型,但它不是走Luckysheet那种"先把功能堆满"的路线,而是先把底层抽象好:业务逻辑与渲染分离、命令可追溯、插件可插拔。这带来两个直接影响:

第一,二次开发时可以改底层逻辑而不动UI。比如你要做一套"审批流的表格"——用户在表格里填数,提交后锁定某些区域,Univer的Command机制能让你在命令派发层面把锁定逻辑做进去,而不是去监听一堆DOM事件。

第二,协作能力是架构内置的,不是后来补丁式加上去的。Univer从设计上就把操作封装成命令(Command),这个设计对于协同场景几乎是必备的——你需要知道每个人做了什么操作、操作顺序是什么、冲突怎么处理。前端编辑类项目做到后面,十有八九会发现自己需要一套类似Redux的纯函数式操作流,Univer干脆把这个抽象做成了核心,而不是插件。

2.2 架构先行带来的协作潜力

我见过不少团队用Luckysheet做在线编辑,做到多人同时编辑时开始头疼,因为要自己补OT(操作变换)或者CRDT逻辑,改动量很大。Univer的做法是,所有用户操作统一走Command——比如"修改单元格A1的值为100"就是一个Command。既然是Command,它就能被记录、序列化、同步、合并。多人协作时,服务器其实不需要关心里面表格内部是怎么渲染的,只需要转发Command并处理冲突。

这意味着,如果你未来要做类似"多人同时编辑同一张预算表"的功能,Univer的底子比从零开始做一个编辑区要扎实得多。它多样化的核心能力都建立在命令模型之上,这不只是技术选型的问题,更是一个架构决策——它决定了后期协作功能和权限系统的改造成本。

当然,这不是说Univer已经是一个完整的在线Office产品。它目前的重心仍然在表格,文档模块和幻灯片模块还处于早期。如果你要的是"Word + Excel + PPT全套开箱即用",现阶段可能还得再等等。

2.3 跟Luckysheet、Handsontable的一个直观对比

我整理了一张对比表,方便你根据自己项目的侧重点做判断:

维度UniverLuckysheetHandsontable
协作基础内置命令模型,天然适合同步需要自行扩展需要自行扩展
二次开发插件化 + 命令机制,逻辑和UI分离支持,但不少逻辑绑定在内部状态上组件化,偏数据录入场景
公式引擎自带完整公式能力自带,但迁移成本高较弱,通常要自己接
渲染性能Canvas渲染,大数据量表现好Canvas渲染,成熟度高DOM/virtual DOM,受DOM限制
生态成熟度正在上升期,文档和示例在快速完善成熟,踩坑文章多商业化功能强,社区成熟

这段对比并不是为了说"Univer吊打所有",而是想说明:它选了一个差异化赛道——编辑部级体验 + 低代码友好 + 协同友好,不值得用"表格组件"的心态来评估它。

3. 如何跑起一个最小可用的Univer:从React项目到第一个在线表格

这一节我直接用真实可复现的方式带大家走一遍。假设你已经有一个React项目(或者愿意现在创建一个),我们用npm包的方式把Univer集成进去,目标只有一个:让页面里出现一个能编辑、能算公式的表格。

3.1 安装依赖时最容易踩的坑

Univer不是单一npm包,而是一组相关包的组合。最基础的是几个核心包,我把它们列出来:

npm install @univerjs/core @univerjs/sheets @univerjs/ui @univerjs/sheets-ui @univerjs/sheets-formula @univerjs/engine-render @univerjs/engine-formula

注意,不要只安装 @univerjs/core,否则你在页面上连表格UI都看不到。Univer把UI、引擎、公式处理全都拆成了独立包,这么设计是为了让使用者按需加载,但对于第一次接触的人来说这就是第一个坑——搞不清该装哪些包。

还有一点要特别留意:这些包对React版本有要求。我自己测试时用的React 18,完全没问题;如果你用的是React 17或更老,建议先看一眼项目的peerDependencies,免得装完起来就报错。

3.2 初始化Univer实例,注册插件

Univer的入口是一个Univer实例。这个实例负责串联所有插件,本身不关心具体是表格还是文档。你可以把它理解成一个空壳容器,要什么功能就往里面注册什么插件。

import { Univer } from '@univerjs/core'; import { defaultTheme } from '@univerjs/theme'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui'; import { UniverSheetsFormulaPlugin } from '@univerjs/sheets-formula'; import { UniverRenderEnginePlugin } from '@univerjs/engine-render'; import { UniverFormulaEnginePlugin } from '@univerjs/engine-formula'; const univer = new Univer({ theme: defaultTheme, locales: [{ locale: 'zhCN' }], }); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsUIPlugin, { container: 'univer-container', }); univer.registerPlugin(UniverSheetsFormulaPlugin); univer.registerPlugin(UniverRenderEnginePlugin); univer.registerPlugin(UniverFormulaEnginePlugin);

这段代码我要多说几句。locales里我写的是zhCN,这样菜单和右键弹出的选项会是中文。如果你不做这个配置,默认可能是英文,实际使用中观感差很多。

注册UniverSheetsUIPlugin时传入的container就是你要挂载表格的DOM节点id。也就是说,Univer负责渲染出一个完整的Canvas表格区域,你的React应用只需要给它一个空容器。

3.3 创建一个带公式的工作簿

有了Univer实例,下一步就是创建Workbook。每个Workbook相当于一个Excel文件,里面可以有多个Sheet。

const workbook = univer.createUniverSheet({ sheets: [ { name: 'Sheet1', cellData: { 0: { 0: { v: '项目' }, 1: { v: '金额' }, }, 1: { 0: { v: '房租' }, 1: { v: 3000 }, }, 2: { 0: { v: '水电' }, 1: { v: 400 }, }, 3: { 0: { v: '合计' }, 1: { f: '=SUM(B2:B3)' }, }, }, }, ], });

这里的cellData是一个双层嵌套对象,第一层是行号,第二层是列号,v是单元格的静态值,f是公式。我给B4单元格写了=SUM(B2:B3),就是希望用户一刷新就能看到公式自动计算。这里有个容易搞错的地方:如果用f表示公式,就不要同时给同一个单元格赋v,否则有些版本会以静态值为准,公式不起作用。

3.4 把创建过程封装成React组件

上面是直接在业务代码里创建Univer,但如果你要在React组件里维护它,建议把它封装成一个useUniver之类的Hook,防止组件重复渲染时创建出多个Univer实例。我这里示范一下最简单的封装思路:

import { useEffect, useRef } from 'react'; import { Univer } from '@univerjs/core'; // ... 其他插件导入略 export default function UniverSheet() { const containerRef = useRef<HTMLDivElement>(null); useEffect(() => { if (!containerRef.current) return; const univer = new Univer({ /* ...初始配置 */ }); univer.registerPlugin(/* ...插件注册 */); const workbook = univer.createUniverSheet({ // ...Workbook配置 }); return () => { univer.dispose(); }; }, []); return <div ref={containerRef} style={{ width: '100%', height: 600 }} />; }

为什么用dispose()?因为Univer创建后会启动自己的渲染引擎、监听DOM事件,如果组件卸载时不做清理,在React StrictMode或页面频繁切换时很可能出现内存泄漏或重复创建报错。这种清理逻辑在官方示例里不显眼,但实际接入时几乎一定会遇到。

4. Univer的核心概念拆解:为什么命令机制如此重要

很多人在跑通上面这个demo之后,觉得"无非是渲染一个表格"。但一旦你的需求开始变复杂——比如需要收集用户的编辑行为、需要撤销重做、需要多人协作、需要锁定特定单元格——你就必须理解Univer的Command机制。这一节我会把它的核心概念拆开讲清。

4.1 Render / Command / State三者如何协作

Univer的整体架构可以用这个逻辑理解:

  • 用户界面(UI):包括工具栏、右键菜单、公式栏。它只是入口,不直接改数据。
  • 命令队列(Command):用户的每次操作,比如修改单元格、插入行列、设置样式,都会生成一个Command实例,并被派发到CommandService。
  • 业务状态(State):真正存储单元格值、样式、行列信息的地方,状态改变后,渲染引擎重新绘制Canvas。

这么设计的核心好处是:UI不直接碰数据,数据驱动渲染,操作能够被记录。你可以把Command机制理解为前端版的"审计日志",每一次操作都有迹可循。

举一个实际场景:假设你的业务希望在用户执行"清除某一区域内容"之前弹一个确认框。在传统组件化方案里,你可能需要监听键盘事件和单元格的编辑接口,非常绕。但在Univer里,你只需要监听对应的Command执行前事件(beforeCommandExecute),在回调里决定要不要阻止这个Command继续执行。

univer.getCommandService().beforeCommandExecute().subscribe((command) => { if (command.id === 'sheet.command.clear-selection') { // 在这里拦截并做业务判断 } });

这种"所有操作都是命令"的设计,做审计、权限控制、自定义校验都会变得非常有条理。在普通的组件方案里,即使有钩子函数,也只是"编辑前/编辑后"这两个粒度,很难做到对"插入行、删除列、合并单元格、改字体颜色"统一做审批逻辑。

4.2 用自定义Command给表格加业务能力

假设你的业务场景是:表格里的A列是成本中心编号,只有以"C"开头的编号允许被修改。这个需求如果靠监听键盘事件来做,不仅要处理粘贴、拖拽、填充等一系列入口,容易漏;但如果用Command机制,就简单很多:

  1. 监听sheet.command.set-cell之类的修改单元格Command。
  2. 在Command执行前,取出目标单元格的坐标和被修改的值。
  3. 判断目标是否在A列,以及值是否合规,不合规则阻止。

这种统一拦截方案,优势在于:不管用户是通过键盘输入、公式计算、还是复制粘贴触发的修改,最终都会走同一条Command路径。实际上Univer里很多编辑动作最终都会落到几个核心Command上,所以我们拦截住核心入口,就能覆盖绝大多数场景。

当然,这里也要注意,不同版本的Command名称可能不同,最稳妥的方式是打印出所有Command的id,筛选出你关心的那几条,再写逻辑。我通常会在开发环境里加一行日志:

univer.getCommandService().onCommandExecuted().subscribe((command) => { console.log('command:', command.id); });

这一步对理解Univer的工作方式非常有帮助。你随便在表格里输入一个值、改一下颜色,看控制台输出,会比读文档更快理解它内部在干什么。

4.3 数据获取与回写

实际项目中,表格数据最终要保存到后端。Univer的数据模型不会自动帮你同步到服务器,你需要自己决定何时把数据序列化出来发送给后端。

最基本的做法是监听状态变化,然后把整个Workbook的数据json取出来:

const snapshot = workbook.getSnapshot(); // 你可以把snapshot保存到后端

这里有个性能问题:如果你在每次单元格变化时都全量保存,数据量会非常大。对于多人协作或频繁编辑的场景,更好的做法是基于Command流做增量同步,或者设计节流机制,比如用户停止输入1秒后再保存。

从我个人经验来看,单机版工具可以先做"手动保存 + 全量快照",能快速满足业务需求;但如果一开始就面向协作场景,建议直接用Command同步方案,否则后面重构成本不小。

5. 我实际测试Univer时遇到的三个坑与排查思路

代码demo跑通不难,难的是真到自己项目里接入时遇到的各种环境问题。下面三个坑是我实测过程中真实遇到的,每一个都花了不少时间定位,希望你能直接避开。

5.1 坑一:"Cannot find module '@univerjs/engine-render'"这类依赖错误

这种错误很常见,通常不是真的缺少这个包,而是版本不一致导致的peerDependency冲突。比如你安装了@univerjs/ui的2.x版本,而@univerjs/core却被解析到了1.x,内部路径就对不上了。

我的排查链路是这样:

  1. 先npm ls @univerjs/core查看当前项目中该包的解析链。
  2. 发现确实存在两个大版本同时存在的可能性,原因是某个依赖把版本固定老了。
  3. 手动把@univerjs下的包统一升级到同一版本线,删掉node_modules和lockfile重新安装。

Univer这种多包架构对版本一致性要求比较高,实践中不要混用不同大版本的@univerjs包,否则你会遇到很多莫名其妙的问题。官方发布时一般会保证同一版本线的包互相兼容,所以统一版本是省心第一原则。

5.2 坑二:容器高度为0,表格不显示

第一次接入时,我直接在页面里放了一个div,没给高度。结果刷新页面后,表格区域一片空白。这不是Univer没渲染,而是Canvas的父容器高度为0,导致绘制尺寸为0。

排查过程比较快,打开DevTools看元素,确实canvas存在,只是宽高为0。解决办法就是给容器设置明确的高度,比如:

<div ref={containerRef} style={{ width: '100%', height: 600 }} />

还有一种情况是,你在弹窗、抽屉、或折叠面板里初始化Univer,容器本身是隐藏的。这时即使设置了高度,Canvas也可能获取不到正确的尺寸。Univer在容器变成可见后,通常需要重新触发一次尺寸计算。比较现实的做法是等弹窗动画完全结束后再初始化Univer,或者手动调用一次resize方法。这个点官方文档讲得少,但遇到时非常容易卡住。

5.3 坑三:大数据量渲染时的卡顿感,以及合理的优化顺序

用Univer渲染一万行数据,初始打开其实不慢,因为它是Canvas渲染,不是DOM节点堆叠。但我实测发现,如果一次性往cellData里塞很多对象,并且每行有很多样式,初始化依然会卡,卡点主要在数据解析和样式建模阶段,而不在绘制阶段。

遇到卡顿,我的优化顺序很清楚:

  1. 先减少初始化数据量。用分页加载或虚拟数据流,只加载可视区域附近的数据。
  2. 再检查是不是样式设置得过于细碎。比如给每个单元格单独设置边框、背景色,比设置整行整列样式慢很多。
  3. 最后考虑调整渲染帧率或关闭不必要的动画效果。

对于真正的大数据量场景,Univer虽然渲染底层不错,但数据装载和状态更新仍需要你自己做分片处理,别指望一个数组直接塞进去就能秒开几百万行。

6. 什么时候不该选Univer:边界条件与更优替代方案

这篇文章一直在讲Univer的好,但一个成熟的从业者,在做技术选型时更应该知道它的边界在哪里。我说几个我认为不应该用Univer的场景。

6.1 只需要静态表格展示

如果你的需求只是把数据用表格展示在页面上,没有编辑需求,Univer完全是杀鸡用牛刀。引入一堆包和Canvas渲染,徒增页面体积和复杂度。这时候直接用HTML表格、Ant Design Table或者AG Grid,都更轻快。

6.2 业务核心是复杂数据录入,而不是公式计算

比如做后台管理系统里的订单明细录入,需要校验、联动、批量操作,建议优先考虑Handsontable或AG Grid这类组件。它们对单元格编辑、键盘导航、输入校验的封装非常成熟,而且文档和社区更丰富。Univer项目本身也非常好,但它定位更偏向完整的表格应用,如果只是把表格当作表单控件用,那选择更轻量的数据网格(Data Grid)组件会离业务目标更近。

6.3 团队没有前端工程化能力

这句话虽然不是技术条件,但很现实。Univer目前仍是源码复杂、插件体系庞大的项目,需要团队有阅读源码和排查问题的能力。如果团队只熟悉基本的组件开发,遇到依赖问题、版本问题会很难推进。相比之下,商用控件虽然收费,但有完善的技术支持,对某些团队反而是最优解。

7. 如果更进一步:把Univer接入你的业务系统的建议路线

如果你最终决定在业务系统里使用Univer,下面这个路线是我个人推荐的推进方式,可以避免一开始就陷进复杂架构:

第一阶段:只做查看和简单编辑

这一阶段的目标是用最少代码把Univer嵌入现有页面,实现展示和编辑,不做任何自定义业务逻辑。配置好Workbook初始化,读取后端数据塞进cellData,编辑后把快照传回后端,就可以了。

第二阶段:把业务校验做成Command拦截

这个阶段开始利用Univer的Command机制。把所有"阻止用户操作"的规则梳理出来,比如某个Sheet只读、某些列禁止修改、未登录用户不能编辑等,统一写到Command拦截层。

第三阶段:接入协同和权限

如果需求确实需要多人协作,再评估服务端技术方案。Univer提供了一套协作的底层概念,但服务端落地仍需要你设计房间(Room)、操作同步、权限管理等。这一步要投入的研发资源不小,值得单独立项来评估。

第四阶段:自定义工具栏和插件

在业务运行稳定后,再去扩展自己的UI能力,比如增加业务相关的工具栏按钮、自定义右键菜单项。这时候你对Univer插件机制的掌控会更顺手,不容易被框架束缚住。

按这个路线走,每个阶段都有一个明确的交付物,不会出现"插手太深、骑虎难下"的情况。我见过太多团队一上来就想实现各种炫酷功能,最后卡在参数和插件机制上,连基础表格都没跑顺,这个坑值得避免。

8. 对Univer生态现状的一点观察和实操建议

Univer从开源到现在,增长速度确实很快,核心团队也很活跃。但从一个实际使用者的角度,我对它的生态现状有一些个人观察,不一定全面,但应该能帮你少走弯路。

第一点是文档和示例的完善度。官方文档在快速进步,但相比Luckysheet这种成熟项目,中文案例仍然不够丰富。遇到问题时,除了官方文档,我更推荐直接读源码。特别是Command机制和插件注册部分,源码写的其实比文档更清楚。

第二点是版本演进带来的学习成本。Univer的版本升级不完全是兼容式的,有些API会调整,比如插件名称、初始化参数、监听事件名。这意味着你在网上查到的文章,可能是三个月前的,代码却已经跑不起来了。我的经验是,尽量以官方仓库里的examples和changelog为准,不要迷信搜索引擎里的旧教程。

第三点是社区活跃度。Univer在GitHub的Issues区很活跃,你提问题通常能得到反馈。但在提Issue之前,建议先看一下已有的Issue,很多问题别人已经踩过。另外,官方Discord和微信群也有不少讨论,这种实时沟通对新手尤其友好。

如果你打算在团队里引入Univer,我还有一个非常实用的建议:先让一个人用一周时间做技术预研,跑通一条完整链路——创建表格、编辑、公式计算、保存快照、恢复数据。这个预研脚本可以成为团队内部的知识沉淀,后续其他人介入时,不用再从零开始啃源码。

9. 一个可复制的实践示例:从接口数据到在线表格编辑

理论讲了不少,最后我用一个完整的伪代码思路,串一遍"从后端拉数据 -> 在Univer里编辑 -> 保存回后端"的闭环。这个示例不一定能直接粘贴到你的项目里,但整体流程可以作为参考。

第一步,后端接口返回如下结构:

{ "rows": [ { "id": 1, "name": "市场部", "budget": 10000 }, { "id": 2, "name": "技术部", "budget": 20000 }, { "id": 3, "name": "运营部", "budget": 15000 } ] }

第二步,前端把这个结构转换成Univer的cellData格式:

function rowsToCellData(rows) { const cellData = {}; // 表头 cellData[0] = { 0: { v: '部门ID' }, 1: { v: '部门名称' }, 2: { v: '预算' }, }; // 数据行 rows.forEach((row, index) => { const rowIndex = index + 1; cellData[rowIndex] = { 0: { v: row.id }, 1: { v: row.name }, 2: { v: row.budget }, }; }); return cellData; }

第三步,用户编辑完成后,把Workbook的快照转回业务数据结构:

const snapshot = workbook.getSnapshot(); const sheet = snapshot.sheets[0]; // 假设文档里只有一个sheet const { cellData } = sheet; const rows = []; for (let r = 1; r < 20; r++) { if (!cellData[r]) continue; rows.push({ id: cellData[r][0]?.v, name: cellData[r][1]?.v, budget: cellData[r][2]?.v, }); } // 把rows发送给后端

这里有个小细节:遍历行号时我设了一个硬编码上界20,实际应该根据数据量动态计算最大行。真正实现时,可以遍历Object.keys(cellData)拿到所有行号,再取最大值,避免空白区域的数据被错误读取。

此外,从快照里读出来的单元格值可能有多种类型——数字、字符串、对象、空值。统一做一次类型转换会让后端数据处理更稳。不要直接把cellData原样丢给后端,否则后端要解析的格式会很别扭。

10. 一些个人经验总结

最后说几个我跑完这个项目后的体感,不一定系统,但都是从实际体验里长出来的。

Univer很适合"以表格为核心能力之一"的产品来投入,比如低代码平台、数据分析工具、项目管理工具、财务系统前端。但它不适合只把表格当页面里的一个小控件来使用的场景,那样你会被它的架构和包体积反噬。

版本管理是我的第一优先级事项。只要项目里用了Univer,我会确保所有相关包都锁在同一版本线,不轻易升也不轻易降。它与周边生态的兼容性还远不如那些商业化程度更高的控件,一旦版本漂移,排查成本会非常大。

对于想入门源码的读者,我强烈建议不要从头到尾读那些核心引擎代码,而是顺着你的业务需求去读。比如我想做单元格锁定,就先带着这个问题去源码里搜"lock"和相关的Command,看它如何解析、如何生效。这种问题驱动式的源码阅读,效率远高于逐行阅读。

说到底,Univer是一个很有潜力的开源项目,但技术在快速发展期,"如何正确用它"比"它有多强"更重要。希望这篇内容能让你少踩几个我踩过的坑,把更多时间花在实际业务价值的交付上。