☰
Univer 开源表格引擎:插件化架构与 Canvas 渲染实战
2026/11/3 6:16:16 网站建设 项目流程

1. 从一张表格说起:为什么我们需要 Univer

做过在线表格类产品的人大概都有过这种体验:产品经理跑过来说“我们要在系统里嵌一个能编辑的表格,最好还能支持公式、图表、多人协作”,你打开搜索引擎搜了一圈,发现能选的方案无非就那么几类——要么是重量级的商业组件,授权费按年收,还得看人家脸色;要么是开源方案,但文档稀碎、扩展性堪忧,想加个自定义函数得把源码翻个底朝天。

Univer 就是在这个背景下进入视野的。它是一个开源的、面向在线表格与文档场景的前端 SDK,核心能力是把“电子表格”这件事从零到一地在浏览器里跑起来。你可以把它理解成一套“表格引擎 + 渲染层 + 插件系统”的组合拳,底层用Canvas做高性能绘制,上层用插件架构把功能拆成一个个可插拔的模块,运行时既能在浏览器里跑,也能在Node.js环境里做服务端计算。

我第一次接触 Univer 是因为一个内部数据看板的需求:需要让运营同学直接在页面上改数据、加公式、导出结果,而不是每次改个数字都要找开发。当时评估了几个方案,最后选 Univer 的核心理由有三点:第一,它是真正的前端 SDK,不是那种“iframe 嵌一个别人的表格”的伪集成;第二,插件架构意味着我可以只引入需要的功能,不用为一个简单的只读表格背上整个富文本编辑器的体积;第三,Canvas 渲染在几万行数据滚动时的表现,比 DOM 渲染方案稳太多。

这篇文章适合谁看?如果你正在做在线表格、报表工具、低代码平台里的数据编辑模块,或者单纯想搞清楚一个现代表格引擎是怎么设计出来的,那接下来的内容应该对你有用。我会从整体设计思路讲到核心实现细节,再到实操步骤和踩坑记录,尽量把“为什么这么设计”讲透,而不是只丢一堆 API 文档。

2. 整体架构拆解:Univer 到底是怎么组织的

2.1 三层结构:数据层、逻辑层、渲染层

Univer 的架构可以粗略分成三层,这个分层思路其实和很多成熟的编辑器产品是一致的,但它在“解耦”这件事上做得比较彻底。

最底下是数据层,负责存储表格的原始状态——单元格的值、样式、公式、行列结构、合并信息等等。这一层不关心你怎么画,只关心数据长什么样、怎么变。Univer 用了一套基于命令的模式来管理状态变更,任何对表格的修改都不是直接改数据,而是发一个命令出去,由对应的处理器去执行。这么做的好处是天然支持撤销重做、协同编辑时的操作同步,以及操作日志追溯。

中间是逻辑层,也就是各种 Service 和 Controller。公式计算、数据校验、选区管理、剪贴板逻辑这些都属于这一层。它从数据层拿状态,算完之后告诉渲染层“该画什么”。

最上面是渲染层,基于 Canvas 实现。这里有个关键设计:Univer 并不是把整个表格画成一张大 Canvas,而是做了分层渲染和可视区域计算。你看到的表格视口里,只有当前可见的那部分单元格会被真正绘制,滚动的时候动态更新。这就是为什么它能扛住大数据量——DOM 方案里几万个单元格节点直接把浏览器拖垮,而 Canvas 只画看得见的那几十行。

2.2 插件架构:为什么不是一个大包

Univer 最核心的设计决策之一,就是一切皆插件。核心包只提供最基础的运行时、依赖注入容器和生命周期管理,具体功能——比如公式、条件格式、图表、协同——全部以插件形式存在。

这个选择背后的逻辑很实际。表格产品的需求差异极大:有人只要一个能编辑的网格,有人要完整的 Excel 替代品,还有人只要只读展示。如果做成一个大而全的包,前者会抱怨体积太大,后者会抱怨功能不够。插件化之后,你可以按需组合:

  • 基础表格编辑:@univerjs/sheets+@univerjs/sheets-ui
  • 加公式:再引入@univerjs/sheets-formula
  • 加条件格式:引入@univerjs/sheets-conditional-formatting
  • 加协同:引入协同相关的插件包

每个插件通过依赖注入容器注册自己的服务、命令和 UI 组件,插件之间通过容器解耦,不直接互相引用。这意味着你可以替换掉某个插件的实现,只要它注册的服务接口不变,其他插件完全无感知。

提示:插件化带来的一个实际好处是“按需加载”。在 Web 场景下,你可以把非首屏需要的插件做成动态 import,首屏只加载基础表格,用户点开公式面板时再加载公式插件,首屏体积能省下不少。

2.3 跨端能力:浏览器和 Node.js 共用一套核心

Univer 的另一个特点是它的核心逻辑不依赖浏览器环境。数据层、公式计算、命令系统这些纯逻辑部分,在 Node.js 里也能跑。这意味着你可以做几件有意思的事:

一是服务端计算。比如用户上传一个 Excel 文件,你在服务端用 Univer 的核心包解析、计算公式、导出结果,不需要起一个无头浏览器。二是协同服务。多人编辑时的操作合并、冲突处理可以在服务端完成,客户端只负责发操作和收结果。三是测试。公式引擎这种纯逻辑模块,在 Node.js 里跑单元测试比在浏览器里跑端到端测试快得多,也稳定得多。

这种跨端能力的实现,靠的是把“平台相关”的代码隔离在渲染层和少数适配层里,核心包只依赖抽象接口。你在 Node.js 里引入核心包时,渲染相关的模块根本不会被加载。

3. 核心细节解析:几个关键机制的实际运作方式

3.1 命令系统:所有修改都走同一条路

前面提到 Univer 的状态变更走命令模式,这里展开说一下它具体怎么运作。当你调用一个 API 去修改单元格值时,实际发生的是这样一条链路:

  1. 你调用类似univerAPI.getActiveWorkbook().getActiveSheet().getRange('A1').setValue('hello')的方法
  2. 这个方法内部并不直接改数据,而是构造一个SetRangeValuesCommand并派发出去
  3. 命令被对应的SetRangeValuesCommandHandler接收,处理器校验参数、执行变更、更新数据层
  4. 数据层变更后触发事件,渲染层监听到事件,标记对应区域为“脏区”,下一帧重绘

这条链路看起来比“直接改数据然后重绘”绕,但它换来了几个关键能力。撤销重做只需要记录命令和反向命令,不用去 diff 整个状态。协同编辑时,把命令序列化发给其他端,其他端重放命令即可,天然保证一致性。操作审计也有了基础,每个命令都带着来源和参数。

注意:自定义功能时,正确的做法是注册自己的命令和处理器,而不是绕过命令系统直接改数据。绕过命令系统会导致撤销重做失效、协同不同步,后期排查起来非常痛苦。我见过有人为了图快直接操作内部状态,结果协同场景下两端数据对不上,回头重构花了两倍时间。

3.2 Canvas 渲染:可视区域与脏区重绘

Canvas 渲染的性能关键在于“只画该画的”。Univer 的渲染流程大致是这样的:

首先,渲染层维护一个视口模型,知道当前滚动位置、可见的行列范围。每次需要重绘时,它只遍历可见范围内的单元格,而不是全部数据。对于十万行数据,可见的可能就五十行,绘制量是常数级的。

其次,它做了分层 Canvas。背景网格线、单元格内容、选区高亮、悬浮提示这些分别画在不同的 Canvas 层上。这样当只有选区变化时,只需要重绘选区那一层,内容层不用动。这个思路和地图应用里的图层管理是一样的。

第三是脏区标记。数据变更时,不是整个表格重绘,而是把受影响的单元格区域标记为脏区,下一帧只重绘脏区。改一个单元格就只重绘那一个格子,改一行就重绘那一行。

这套机制组合下来,实际表现是:滚动流畅、编辑响应快、大数据量下不会卡死。当然代价是渲染逻辑比 DOM 方案复杂得多,调试的时候不能像 DOM 那样直接审查元素,得靠日志和渲染层的调试工具。

3.3 公式引擎:依赖图与增量计算

公式是表格的灵魂,也是最容易出性能问题的地方。Univer 的公式引擎核心是一张依赖图:每个公式单元格记录它引用了哪些单元格,每个被引用的单元格记录它被哪些公式依赖。

当某个单元格的值变化时,引擎不是重新计算所有公式,而是沿着依赖图找到所有受影响的公式,按拓扑顺序增量计算。比如 A1 变了,只有直接或间接引用 A1 的公式会重算,其他公式的结果保持不变。

这里有个细节值得说:循环引用的检测。如果 A1 的公式引用 B1,B1 的公式又引用 A1,依赖图里就出现了环。引擎在构建依赖关系时会做环检测,发现循环引用就标记为错误值,而不是无限递归下去。

实操心得:自定义公式函数时,一定要正确声明函数的参数类型和是否易变(volatile)。如果一个函数依赖外部状态(比如当前时间),要标记为易变,否则引擎会缓存结果导致不更新。我当初写一个自定义函数时忘了标记易变,结果时间一直不刷新,排查了半天才发现是这个原因。

3.4 插件生命周期:注册、启动、销毁

一个 Univer 插件的完整生命周期包括几个阶段。插件类实现onStarting和onReady等钩子,在onStarting里注册自己提供的服务和命令,在onReady里做依赖其他插件的初始化工作。销毁时实现dispose清理事件监听和定时器。

依赖注入容器保证了插件之间的依赖顺序:如果插件 B 依赖插件 A 提供的服务,容器会确保 A 先启动。这个机制让插件可以安全地假设“我依赖的服务在我启动时已经就绪”。

4. 实操过程:从零搭一个可编辑表格

4.1 环境准备与依赖安装

先确认 Node.js 环境。Univer 对 Node.js 版本有要求,建议用 18 LTS 或更高版本。检查当前版本:

node -v npm -v

如果版本太低,去官网下载 LTS 版本安装即可。安装完成后,新建项目并安装 Univer 相关包。以一个基础的表格编辑场景为例,需要安装核心包、表格插件和 UI 插件:

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

这里解释一下每个包的作用。@univerjs/core是运行时核心,提供依赖注入、命令系统、生命周期管理。@univerjs/design是设计系统,提供基础 UI 组件和样式变量。@univerjs/engine-formula是公式引擎,@univerjs/engine-render是 Canvas 渲染引擎。@univerjs/sheets提供表格的数据模型和命令,@univerjs/sheets-formula把公式引擎接入表格,@univerjs/sheets-ui提供表格的交互 UI,@univerjs/ui是通用 UI 框架。

提示:包名和版本会随迭代变化,安装前建议去官方仓库确认当前推荐的包组合。不同版本之间 API 可能有差异,混用版本是常见的报错来源。

4.2 初始化实例与注册插件

安装完成后,在入口文件里初始化 Univer 实例。核心思路是创建一个 Univer 对象,然后依次注册需要的插件:

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

注册顺序有讲究。渲染引擎和公式引擎是底层能力,要先注册。UI 插件提供了容器,表格 UI 插件依赖它。如果顺序错了,插件启动时找不到依赖的服务,会直接报错。

4.3 创建工作簿与配置初始数据

插件注册完之后,创建实际的工作簿:

univer.createUnit(UniverInstanceType.UNIVER_SHEET, { id: 'sheet-001', sheetOrder: ['sheet1'], sheets: { sheet1: { id: 'sheet1', name: '数据表', rowCount: 1000, columnCount: 26, cellData: { 0: { 0: { v: '姓名' }, 1: { v: '数量' }, 2: { v: '单价' }, 3: { v: '金额' }, }, 1: { 0: { v: '商品A' }, 1: { v: 10 }, 2: { v: 25.5 }, 3: { f: '=B2*C2' }, }, }, }, }, });

这段配置里,cellData的结构是“行索引 -> 列索引 -> 单元格数据”。v是值,f是公式。注意公式里用的是 B2、C2 这种 A1 表示法,引擎会自动解析成行列索引。

4.4 挂载到页面与样式处理

最后一步是把 Univer 挂到页面的容器元素上。容器需要明确的高度,否则 Canvas 算不出可视区域:

<div id="app" style="width: 100%; height: 600px;"></div>
// 容器准备好后,Univer 会自动在其中创建 Canvas // 如果容器尺寸变化,需要通知渲染层重新计算 window.addEventListener('resize', () => { // 触发重绘逻辑 });

样式方面,Univer 的 UI 组件依赖设计系统的 CSS 变量。如果样式没引入,界面会看起来“裸奔”。确保在入口处引入了@univerjs/design的样式文件。

注意:容器高度为 0 是新手最常见的坑。Canvas 初始化时如果容器没有高度,可视区域计算出来是空的,表格就“消失”了。排查时先看容器有没有实际尺寸。

5. 常见问题与排查技巧实录

5.1 表格不显示或显示空白

这是最高频的问题,原因通常有几类。第一类是容器尺寸问题,前面说过,容器没有高度或者被display: none隐藏时,Canvas 算不出可视区域。第二类是插件注册顺序问题,渲染引擎没注册就去创建表格,自然画不出来。第三类是数据格式问题,cellData的索引结构写错了,比如把行列索引搞反了,数据其实存在但不在可视区域。

排查顺序建议是:先看控制台有没有报错,再看容器尺寸,再看插件注册日志,最后检查数据格式。Univer 的插件启动会打日志,打开 debug 模式能看到每个插件的启动状态。

5.2 公式不计算或计算结果不对

公式问题的排查要分几步。首先确认公式引擎插件和表格公式插件都注册了,缺一个都不行。其次检查公式字符串的格式,A1 表示法要写对,函数名大小写不敏感但拼写要对。第三,如果是自定义函数,检查有没有正确注册到函数注册表里。

还有一种情况是计算结果“看起来不对”,其实是单元格格式的问题。比如计算结果是 0.1+0.2,显示成 0.3 是因为格式化了,实际值还是 0.30000000000000004。这是浮点数的经典问题,不是引擎的 bug。

5.3 大数据量下滚动卡顿

如果数据量上万行后滚动开始卡,先检查是不是所有数据都塞进了初始cellData。Univer 支持虚拟滚动,但初始数据太大时,解析和建索引本身就要时间。建议的做法是初始只加载可见范围的数据,滚动时按需加载。

另一个常见原因是自定义渲染逻辑太重。如果你在单元格渲染回调里做了复杂计算或者频繁 DOM 操作,会拖慢整个渲染循环。渲染回调里只做必要的绘制,复杂逻辑提前算好缓存起来。

5.4 协同场景下数据不一致

协同问题排查起来最麻烦,因为涉及多端。核心原则是:所有修改必须走命令系统,不能绕过。如果发现两端数据不一致,先检查有没有代码直接操作了内部状态。其次是检查命令的序列化和反序列化有没有丢信息,比如某些命令带了不可序列化的参数。

还有一个容易忽略的点是命令的执行顺序。协同场景下,不同端的命令到达顺序可能不同,需要服务端做排序或者用 OT/CRDT 算法处理冲突。Univer 的命令系统提供了基础,但具体的协同策略需要自己实现或引入协同插件。

问题现象可能原因排查方向
表格空白容器无高度检查容器 CSS 尺寸
表格空白插件未注册检查注册顺序和日志
公式不计算公式插件缺失确认两个公式包都注册
滚动卡顿初始数据过大改为按需加载
协同不一致绕过命令系统检查是否有直接改状态
自定义函数不更新未标记易变检查函数元数据配置

5.5 打包体积过大

Univer 的包不少,全量引入后打包体积可能超出预期。优化思路有几个:一是按需引入插件,只装用到的功能包;二是用动态 import 做代码分割,非首屏插件延迟加载;三是检查有没有重复引入不同版本的依赖,用打包分析工具看看哪些包占了大头。

我实测过一个只读表格场景,只引入核心、渲染、表格和表格 UI 四个包,打包后 gzip 体积能控制在比较理想的范围内。加上公式和条件格式后会明显增大,所以按需引入不是可选项,是必选项。

6. 插件扩展:写一个自己的功能模块

6.1 插件的基本结构

写一个 Univer 插件,本质上是实现一个类,在生命周期钩子里注册自己的东西。最小结构大概是这样:

import { Plugin, ICommandService, Inject } from '@univerjs/core'; class MyPlugin extends Plugin { static override pluginName = 'my-plugin'; constructor( @Inject(ICommandService) private _commandService: ICommandService ) { super(); } override onStarting(): void { // 注册命令、服务 } override onReady(): void { // 依赖其他插件的初始化 } override dispose(): void { // 清理资源 } }

onStarting里做注册,onReady里做初始化,dispose里做清理。依赖注入用装饰器声明,容器会自动注入。

6.2 注册自定义命令

假设我们要加一个“批量填充”功能,选中一个区域后填入相同值。先定义命令:

export const MyBatchFillCommand = { id: 'my.command.batch-fill', type: CommandType.COMMAND, handler: (accessor, params) => { const commandService = accessor.get(ICommandService); // 执行填充逻辑 return true; }, };

然后在插件的onStarting里注册:

this._commandService.registerCommand(MyBatchFillCommand);

命令的 handler 里拿到参数后,构造对数据层的修改操作。注意这里要复用 Univer 已有的命令,比如设置单元格值的命令,而不是自己直接改数据,这样才能保证撤销重做和协同正常工作。

6.3 添加 UI 入口

功能有了,还得让用户能触发。Univer 的 UI 插件提供了菜单、工具栏、快捷键等扩展点。以工具栏按钮为例,通过 UI 服务注册一个按钮,绑定到刚才的命令:

// 在 onReady 里注册 UI const uiService = accessor.get(IUIService); uiService.registerToolbarButton({ id: 'my-batch-fill-btn', label: '批量填充', commandId: MyBatchFillCommand.id, });

UI 扩展点的具体 API 会随版本变化,但思路是一致的:找到对应的 UI 服务,注册组件,绑定命令。

实操心得:写插件时尽量把逻辑和 UI 分开。逻辑放在命令 handler 里,UI 只负责触发命令。这样同一套逻辑可以同时被工具栏、快捷键、右键菜单复用,也方便写测试。我一开始把逻辑写在 UI 回调里,后来想加个快捷键触发,发现得把逻辑抽出来重写,白费功夫。

7. 性能调优与生产环境注意事项

7.1 渲染性能的几个调节点

Univer 的渲染层有几个可以调的参数。一是可视区域的行列缓冲,默认会多渲染可视范围外的一两行,滚动时更平滑,但会增加绘制量。数据量特别大时可以调小缓冲。二是重绘的节流,高频数据更新时可以合并到下一帧统一重绘,避免每来一个更新就重绘一次。

另外,单元格的样式尽量用共享样式对象,而不是每个单元格一个独立样式对象。样式对象多了之后,比较和缓存的成本会上升。

7.2 内存管理

大数据量场景下,内存是另一个瓶颈。Univer 的数据层用稀疏结构存储,空单元格不占空间,这是好的。但如果你在渲染回调里创建了大量临时对象,垃圾回收压力会很大。渲染回调里尽量复用对象,避免频繁分配。

插件销毁时要记得清理事件监听和定时器,否则页面切换后旧实例还在后台跑,内存泄漏就是这么来的。

7.3 生产环境构建

生产构建时,确保用 production 模式打包,去掉开发期的警告和检查。如果用了动态 import,配置好 chunk 分割策略,把不常用的插件分到独立 chunk。CDN 部署时注意 Canvas 相关的资源路径,字体文件如果加载失败,表格里的文字会显示异常。

还有一点,Univer 的某些功能依赖 Web Worker 做后台计算。生产环境要确保 Worker 文件被正确打包和部署,路径配置错了 Worker 加载失败,公式计算会退化到主线程,大数据量下会卡。

8. 我对 Univer 这套方案的实际体会

用 Univer 做项目这段时间,最大的感受是它的架构设计确实经得起推敲。命令系统、插件化、Canvas 渲染这几个核心决策,在需求变化时体现出了价值——加功能是加插件,改行为是换命令处理器,性能不够是调渲染参数,各管各的,不会牵一发动全身。

当然它也不是没有门槛。插件架构意味着你得理解依赖注入和生命周期,命令系统意味着你得放弃“直接改数据”的直觉,Canvas 渲染意味着调试方式跟 DOM 完全不同。这些学习成本是实打实的,但一旦跨过去,后面做扩展会顺很多。

如果让我给准备上手的人一个建议,那就是:先把官方的最小示例跑通,然后照着它的模式写一个自己的小插件,把命令注册、UI 扩展、数据修改这条链路走一遍。走通了这一遍,后面再复杂的定制都是在这个骨架上加东西。别一上来就想着改源码,先把扩展机制用熟,大部分需求其实不需要动核心。

最后分享一个排查问题的习惯:遇到诡异现象时,先打开 Univer 的 debug 日志,看命令有没有派发、处理器有没有执行、数据有没有变更、渲染有没有触发。这四个环节定位下来,问题基本就跑不掉了。比盲目翻源码高效得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询