Univer:基于Canvas与Web Worker的开源电子表格引擎解析

1. Univer是什么:为什么一个开源表格项目能火遍全球

这段时间,GitHub上有个项目增速非常夸张,star飙升速度一度超过了很多老牌开源软件,名字就叫 Univer。如果你做过在线表格、数据处理、低代码平台这类前端方向的东西,那你大概率已经在某个技术群里看到过有人讨论它了,而且讨论的点往往集中在“反应快”“内存开销小”“居然能解析Excel大文件”这类很具体的体验上。

Univer简单来说,是一套基于Web的开源电子表格引擎。它最核心的能力就是在浏览器里渲染出一个类似Excel的完整表格界面,支持编辑、公式、图表、样式、多Sheet切换、打印、协同编辑,还能直接把.xlsx文件拖进来打开,也可以在页面里操作完后导出成.xlsx给用户下载。听起来像是“又一个Web Excel”,但真正让我觉得它值得聊的,是它的技术选型:不依赖DOM来做表格单元格渲染,而是直接用Canvas把整个表格“画”出来。这带来的直接好处就是,你在里面滑几万行、操作几千列的表格,交互还能保持比较流畅的水平。

它适合谁看?我建议下面这几类朋友重点留意一下:

  • 前端开发、全栈开发:想给自家产品嵌入一个多Sheet表格组件,而不是花钱买商业套件或者自己从零手写一个表格。
  • 技术选型决策者/架构师:正在评估“在线表格能力要不要自研”,或者对比商业方案、开源方案、拼装方案的成本。
  • 数据应用从业者:做ERP、财务系统、低代码平台、报表BI,需要给用户一个“像Excel一样”操作数据的界面。
  • 对大型前端应用架构感兴趣的人:Univer的Canvas渲染、Web Worker计算、命令系统设计都很有学习价值,拆开看一遍挺长见识。

现在还有一个关键词叫“univer在线”,这其实是社区对Univer应用形态的进一步期待。Univer不光是一个可以在项目中嵌入的组件,它还提供了一套完整的前端侧基础能力,意味着你可以基于它搭建出一个完整的在线协作办公应用,而不是只能把它当控件塞进表单里。开源做基础能力、再在你自己的业务里拼出在线办公产品,这条路径放在国内团队里,能做到这个完整度的项目是不多的。

下面我会从技术核心、架构设计、完整实操、踩坑记录和选型建议这几个角度,把这个项目讲透。

2. 架构与设计:Canvas渲染、Worker计算和命令系统如何协同工作

很多人第一次看到Univer的源码时,会觉得它挺复杂,因为它不是一个简单的“表格组件”,而是一整套分层清晰的架构。我给你拆开来说。

2.1 渲染层:Canvas分层策略是怎么扛住大数据量渲染的

传统表格组件(比如早年很多jQuery插件、Angular表格控件)的做法,是用DOM来渲染每一个单元格,每个格子一个<td>或者<div>。这在几百行数据时问题不大,一旦到几千行你就会发现性能直线往下掉,因为DOM节点数量太多了,每一次重排、重绘都要浏览器付出很大的代价。

Univer的做法完全不同。它在底层把整个表格组件当成一个“场景”来绘制,单元格、边框、选区、行号列号、滚动条等等内容,全部用Canvas分图层绘制。你可以把它理解为游戏引擎的思路——游戏里面那么多角色和场景,不是靠HTML标签堆出来的,而是每一帧都重新绘制到画布上。Univer把滚动、选中、浮动元素等再拆成不同图层,Window渲染器、Sheet组件渲染器、普通单元格渲染器、富文本渲染器各管一摊,绘制时只对脏区域做局部重绘,所以就算数据量大,视觉上也很难感觉到生硬闪烁。

这里有个容易被忽略的细节:Canvas绘制的文本、边框,在浏览器缩放时会模糊或者发虚,Univer在实现里做了大量高DPI适配层面的处理,包括监听devicePixelRatio变化、手动设置canvas的实际尺寸再通过样式尺寸缩放。我第一次集成到项目里,发现高清屏下显示很清晰,还以为是内置了高清字体方案,后来看源码才发现是这一层做了很细腻的适配。

2.2 计算层:公式引擎为什么放到Web Worker里

表格应用最消耗CPU的操作,除了渲染,就是公式计算和单元格依赖链更新。比如你在一张几千行的表里写了一个=SUM(A1:A5000),如果计算在主线程上进行,用户拖动滚动条或者输入内容时,页面会明显出现卡顿,因为主线程被公式计算占用了。Univer把公式引擎、数值计算逻辑独立出来,放到了Web Worker线程中去执行,主线程只负责收集操作事件、更新渲染状态,计算结果再异步地回到渲染层显示。这就是为什么在Univer里输入一个复杂的公式,尤其是有跨Sheet引用的公式,页面整体还能保持相对跟手。

还需要注意一点:公式引擎并不是简单地对每个格子写死一个计算函数,它需要维护一套完整的依赖关系图。假设A1的值被修改,那么所有引用了A1的公式都需要重新求值,而同时这些公式还可能被其他公式引用,所以需要做依赖链的拓扑排序和动态更新。Univer在架构上把公式的注册、依赖分析、计算调度都做成了模块化的服务,开发者还可以注册自己的自定义函数,并通过API调用计算结果。

2.3 状态管理层:Command命令模式如何支撑撤销重做和协同

不少前端开发者第一次集成Univer时,会有一个灵魂困惑:代码里几乎看不到“直接改某个单元格数据”的写法。Univer把操作封装成了可序列化的Command,例如SetRangeValuesCommand负责设置区域单元格值,AddWorksheetCommand负责新增Sheet。每一次操作都先发给CommandService,由命令处理器来执行,并且每个命令都会自动进入撤销栈、重做栈。

这个设计对在线办公场景太重要了。因为只要是命令式的,任何事情都天然可撤销、可重做,这个问题在业务里要是自己手动去维护历史版本,复杂度会非常惊人。更厉害的是,命令本身可以序列化为一段JSON,这意味着客户端可以把命令发给服务端,服务端再做合并和广播——这是协同编辑的基础条件。你在界面上做一个操作,它对应用户来说就是一个可同步的消息,而不是把整张表格的数据全量发送过去。

另外,如果你需要在外部给Univer发指令(比如在业务系统里面某个按钮触发“给选中单元格填充一个数字”),也是先把一个命令对象交给CommandService去执行。这种“操作全部走命令”的模式,还天然方便做权限控制、操作审计,只要在命令通道里加一层拦截器,所有用户的动作都能被记录。

2.4 协同编辑:多人同时操作时,冲突怎么解决

协同是Univer一直强调的能力,它的协同方案基于OT(Operational Transformation),同时又支持用服务端协调来自不同用户的命令。很多人对OT的理解容易停留在“CRDT也很火、是不是比OT好”这个问题上,我的看法是:表格场景下OT仍然够用且效率更高。因为表格的数据结构是扁平的矩阵,不像文档那样有复杂的插入删除节点操作,每个命令的作用区域清晰,做变换和冲突合并的算法成本相对低。

Univer的服务端协同框架同样是开源的,里面集成了文档版本管理、命令合成、冲突处理。一个个用户的编辑命令进去之后,服务端会为每个命令生成一个版本号,并管理所有客户端的并发编辑。这里我多说一句:如果你只把Univer当前端组件用,协同模块可以不启用,直接跑单机模式;但如果你是做在线文档类产品,协同模块会让整个方案完整度高出很多。

3. 从零接入:一个Vite项目中集成Univer的完整实操

理论说再多,不如动手跑一遍。我拿一个Vite + TypeScript的空项目来演示,怎么把Univer从零接入,并让它展示一个带公式的工作簿。

3.1 初始化工程与安装依赖

先创建一个Vite项目:

npm create vite@latest my-univer-demo -- --template vue-ts cd my-univer-demo npm install

接着安装Univer的核心依赖。注意,Univer把各种能力拆成了多个子包,这样做的好处是你可以按需引入,避免一次性把整套东西都打进来。基础场景至少需要下面几个包:

npm install @univerjs/core @univerjs/design @univerjs/ui @univerjs/sheets @univerjs/sheets-formula @univerjs/sheets-numfmt

当前版本的API设计是:core包提供创建Univer实例的基础能力,ui包负责渲染整个界面框架,sheets是表格功能主体,sheets-formula提供公式支持,sheets-numfmt负责数字格式。如果你要做图表、打印、协同,还要另外装对应的包——这也是Univer一个比较显著的特点:它更像一套积木,按需拼装。

3.2 创建Univer实例并注册核心插件

在你的入口文件(比如main.ts)里,初始化一个Univer实例:

import { Univer } from '@univerjs/core'; import { defaultTheme } from '@univerjs/design'; import { UniverUIPlugin } from '@univerjs/ui'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsFormulaPlugin } from '@univerjs/sheets-formula'; import { UniverSheetsNumfmtPlugin } from '@univerjs/sheets-numfmt'; import '@univerjs/design/lib/index.css'; const univer = new Univer({ theme: defaultTheme }); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsFormulaPlugin); univer.registerPlugin(UniverSheetsNumfmtPlugin); univer.registerPlugin(UniverUIPlugin);

这里有两个容易踩的坑。第一个是CSS不引入会导致界面整体错乱,Univer的UI是依赖它自己的设计系统样式的,漏了CSS你会看到一个分组方式很诡异的布局。第二个是注册顺序,我建议顺序保持core基础 -> sheets -> 公式/数字格式 -> UI,虽然部分权威文档说UI插件可以放前面,但从我实际运行的经验来看,这样注册的稳定性最好。

3.3 创建工作簿、写入数据、使用公式

创建实例之后,需要手动创建一个Workbook。下面的代码会创建一个包含3个Sheet的工作簿,并且在第一个Sheet里填充字符串、数字和公式:

import { LocaleType } from '@univerjs/core'; const workbook = univer.createUniverSheet({ id: 'workbook-demo', locale: LocaleType.ZH_CN, sheets: [ { id: 'sheet-1', name: '销售明细', rowCount: 100, columnCount: 20, cellData: { 0: { 0: { v: '商品' }, 1: { v: '数量' }, 2: { v: '单价' } }, 1: { 0: { v: '键盘' }, 1: { v: 120 }, 2: { v: 89.9 } }, 2: { 0: { v: '鼠标' }, 1: { v: 200 }, 2: { v: 39.9 } }, 3: { 0: { v: '合计金额' }, 2: { f: '=B2*C2+B3*C3' } } } } ] });

这里cellData的行号、列号都是数字索引,从0开始。v表示单元格显示值,f表示公式字符串。你可能会注意到第三行我用的是0: { v: ... }, 1: { v: ... }这种嵌套结构,这是Univer里比较标准的单元格数据格式:第一层是行,第二层是列,第三层是单元格对象。如果你直接用A1这种Excel坐标来索引,反而没有这么直观。

公式有个重要细节:在初始化时写入的公式,不会立刻在界面上看到计算结果的值,Univer会经过一个异步计算周期。如果你要用公式结果做后续业务逻辑(例如读取合计金额的值给后端),一定要注册监听或者等待公式计算完成,直接在初始化后同步去读取单元格值,可能拿到的是空字符串。

3.4 数据导入导出与常用交互配置

实际业务里,用户大概率不是从空表格开始,而是要打开已有的Excel文件。Univer提供了一套文件导入导出能力,当前版本推荐使用包@univerjs/sheets-xlsx:

npm install @univerjs/sheets-xlsx

注册到Univer实例上,然后给Univer实例传入一个配置,让它能监听文件拖拽:

import { UniverSheetsXLSXPlugin } from '@univerjs/sheets-xlsx'; univer.registerPlugin(UniverSheetsXLSXPlugin);

拖拽导入这块,内置了是比较完整的:把.xlsx文件拖进表格区域,Univer会解析文件并自动重建工作簿结构。如果你要做自定义的导入按钮,也可以调用插件提供的底层解析方法,拿到Sheet数据后塞给createUniverSheet。

导出操作同样由插件提供,调用时它会自动把当前工作簿的所有Sheet、样式、公式、合并单元格信息一并写入新的.xlsx文件并触发下载。我自己在项目里实测过:包含合并单元格、条件格式、图标集的中型Excel文件(300KB左右),打开和重新导出的还原度非常高;但如果是包含复杂图表、VBA宏这些高级特性的文件,会有一定的能力丢失,这个必须提前和需求方对齐,不能等用户导出了才发现。

4. 实战踩坑记录:我在集成Univer时遇到的六个典型问题

任何开源项目,跑通demo只是第一步,真正做业务集成时问题才会一个个冒出来。我把我在项目里遇到的主要问题整理一下,这些问题网上讨论得比较零散,我集中写出来做个速查表。

问题表现原因解决方案
中文字体白屏单元格里的中文不显示,或者显示成方框Canvas绘制文本依赖font-family,字体未加载完成时绘制失败提前加载字体并确保font配置正确;页面里先渲染一个隐藏中文字体预览
包体积偏大首次加载JS bundle超过1MB,弱网下体验差默认全量引包,UI、公式、渲染组件都打进来了按需注册插件;对静态资源做CDN拆分;配合Webpack/Vite做manualChunks
大数据量能动但卡顿10万行数据滚动不流畅,单元格输入掉帧Canvas再强也扛不住频繁的整表重绘;命令触发的渲染范围过大使用Univer的区间更新接口,尽量对局部区域做setRangeValues;减少频繁的全量选区刷新
与React/Vue集成边界模糊用框架方式写表格单元格内容时总是失效Univer的单元格绘制不是DOM,框架的vdom/directive能力不适用理解Univer渲染模型:业务UI用框架做,单元格交互用Univer命令与事件完成
协同服务搭建困难开箱只有前端,联调时找不到服务端协同需要自建服务端并部署Univer提供的基础协同服务优先用Univer官方的协同服务包;做不了OT就先用“锁+全量刷新”过渡
版本API变化快照着某篇文章写的代码,升级后报错Univer核心API在快速演进期,社区资料滞后锁版本使用;文档与源码对照;关注CHANGELOG

4.1 中文字体白屏与字体加载问题

这个问题在本地开发时很少暴露,因为开发机器上中文字体都是系统自带的。但你的应用跑在用户浏览器里时,情况完全不同:浏览器不会自动加载系统字体,Canvas在绘制文本时需要指定一个已经可用的字体。如果你不做任何处理,Univer初始化后,页面里的中文大概率是空白。

我的处理方式是在应用入口处先用document.fonts.load('16px "PingFang SC"')这类方式主动加载中文字体,加载完成后再调用univer.createUniverSheet(...)。如果你对部署体积不敏感,也可以用@font-face嵌一个字体子集进去,保证跨平台、跨设备都能稳定显示。这里不要觉得多此一举——在线表格产品里,没有中文字体等于整个产品不可用。

4.2 包体积过大:按需引入与CDN加速

Univer的核心包体系拆得很细,但如果你图省事把全部插件都注册了,打包产物会立刻膨胀。默认的引入方式,光@univerjs/ui加@univerjs/sheets两个包,产物就能到六七百KB,这还不算图表的扩展包。

建议的做法是:

  • 业务里只注册真正用到的插件。比如不需要协同就把协同相关包全部去掉。
  • 使用构建工具的拆包能力,把Univer相关代码打进一个独立chunk,让它在用户二次访问时走强缓存。
  • 如果面向企业内网用户,把Univer的静态资源放到自己的CDN或者OSS上,首次加载的体验会好很多。
  • 官方支持通过CDN方式引入,适合快速原型验证;但生产环境我建议还是用npm包配合构建工具做tree-shaking,更可控。

4.3 大数据量渲染卡顿:别把Canvas想得太神

Canvas渲染的确比DOM方案承载力大不少,但它不是万能的。我曾经在测试环境构造了一张15万行、30列的表格,前5万行感觉还行,但从10万行开始继续往下滚动,帧率就明显下来了。分析下来,主要原因不是绘制本身,而是大量单元格在滚动过程中需要动态计算可视区域和样式合并,这个计算量级上去了,主线程就开始吃紧。

要改善性能,关键是减少高频操作触发的全量更新范围。比如不要频繁去调用读取整表数据的API,更不要在外层循环里对成千上万个单元格挨个setRangeValues。改成批量把数据组织成矩阵后一次性写入,性能会有非常可观的提升。

4.4 与React生态集成的边界问题

有一些团队想用Univer做一个表单里的“可编辑Excel表格”,于是希望在React组件里直接控制Univer单元格的状态。但实际上,Univer内部的数据模型和React组件的model并不是同一个东西,你在React里setState,不会自动触发Univer重新计算公式、重绘画布。正确的做法是:把Univer当成一个独立的“黑盒”,React负责外层的布局,Univer自己管理自己的数据。二者如果需要通信,通过Univer的命令API和回调事件来做,而不是强行把Univer的状态挂到React的响应式系统里。

这样做还有一个附带的好处:如果你以后再换业务框架,比如转到Vue或者原生JS,Univer这一层并不受影响。

4.5 协同服务的二次开发复杂度

Univer的核心竞争力之一是协同,但这里要提醒一句:“协同”不是一个开箱即用的功能。前端开箱即用的只是协同客户端的协议支持,服务端需要你自己部署和配置。如果你的业务是登录用户在小范围共享一张表格,部署一套同步服务不是大问题;但如果产品涉及大规模多租户协同,你要考虑房间管理、权限校验、命令冲突频率、服务端存储模型设计——这就不是前端团队的纯技术活了。

我建议刚接触Univer的团队,第一个版本先不考虑协同,把表格的编辑、导入导出、样式还原做好。协同这种能力,等到产品核心流程跑通后,再迭代进来。如果一开始就把协同架构搭进去,排期和复杂度都会失控。

4.6 版本API变动频繁:升级踩坑注意事项

Univer目前还处于快速迭代期,版本之间的小版本升级都可能带来不兼容变更,API命名和模块组织都在不断调整。社区里很多文章写得很好,但你照着文章敲代码会发现有些包已经找不到,或者某个类被移到了别的目录。

我给你的建议是:进入项目期就锁版本号,不要用^裸依赖;升级时先看官方的CHANGELOG,再去源码里搜对应API的最新用法。如果你在社区看中了某个独立功能(比如某篇文章教你如何自定义一个渲染器),记得先确认该文章的版本日期和你的依赖版本是否对得上。

5. 这到底适不适合你的项目:选型建议与二次开发路线

最后聊一聊选型和投入。我在前文已经反复提到,Univer是一个引擎级项目,不是插上就能用的成品。那么什么样的项目适合直接引入,什么样的项目要慎重,这里给一个比较明确的画像。

5.1 适用场景画像

  • 数据填报、审批系统:需要用户在一个仿Excel界面里录入多行结构化数据,同时对数据格式做约束。Univer开箱即用的编辑体验足够,还能通过API获取填完的单元格数据提交给后端。
  • 嵌入式数据分析表格:在BI看板里增加“在线编辑”能力,用户看报表的同时能自己拉公式、算小计、调整格式。Univer的上手成本比接商业套件低,功能覆盖比手写一个jqGrid高好几个维度。
  • 在线教培场景:教育产品里需要让学生和老师共同操作一张表格,做实验记录、项目分工表之类的东西。Univer的协同能力虽然要自建服务,但整体方案是完整可行的。
  • 低代码/零代码平台:需要给搭建者提供一个表格类组件,并且组件能暴露数据绑定、事件回调。Univer在架构上就是为这种可嵌入场景设计的,命令系统还方便做操作日志。

5.2 不适用场景

  • 对Office文档格式保真要求极高的场景:如果你的核心用户群是重度Office用户,要求Excel文件里的每一处细节(包括图表、宏、打印样式、数据透视表等)都必须1:1还原,那Univer目前还达不到商业Office套件的兼容级别。这时候还是要选商业化的在线Office方案。
  • 只为了展示数据,不需要编辑:如果你的需求只是把数据呈现成表格,那用普通的HTML表格配合antd/ElementPlus这类组件库就够了,完全没必要引入一个几十万行代码的引擎。
  • 团队没有前端基建能力:Univer二次开发需要团队能看源码、能调样式、能理解Canvas渲染机制。如果团队只停留在“调用组件API”的层面,遇到性能或兼容性问题会很难处置。
  • 对移动端体验优先级极高:Univer的核心优化集中在桌面浏览器场景。虽然它在触屏设备上也能操作,但和原生移动端表格应用比,手势细节、输入体验都有差距。

5.3 团队二次开发需要备哪些“粮草”

如果你决定用Univer做产品,我建议在团队内部先建立三个方面的工作流。

第一是知识沉淀:安排至少一个核心成员完整阅读Univer的架构文档,把“绘制流程”“数据模型”“命令系统”“插件注册机制”这四个部分梳理成团队内部的PPT或者文档。未来所有集成问题,大概率都能在大方向上归结到这四个部分。

第二是调试工具链:由于Univer的界面是Canvas渲染,传统的前端DOM调试几乎不适用。你需要熟悉它的内部日志能力,在初始化时开启调试模式,一旦界面出现异常,能很快定位是渲染层的问题、数据层的问题,还是命令执行中出现异常。一个顺手的方式是:拿到报错信息后,先查对应的Command名称,再顺着命令源码比对自己的调用方式。

第三是渐进式引入策略:不要一口气把Univer塞进核心业务流程里。先做一个独立页面,只做“上传Excel预览”这个功能,验证包体、加载时间、兼容性;然后做“编辑并导出”;最后再做在线保存、多Sheet联动。每一步都单独上线,让团队逐步熟悉它的脾性。

我个人的体会是:Univer这套技术栈,几乎就是当年WebOffice产品从零到一所需要的“最大公约数”。它把渲染、公式、协同协议这些最难啃的骨头都做成了开源可用的形态,剩下的业务集成越扎实,你越能感受到这个项目的潜力。这个方向值得长期投入,因为它不只是一个表格控件,而是一整套Web办公能力的底座。