1. 从一张表格说起:为什么我们需要重新审视 Univer
第一次接触 Univer 是在一个协同办公项目的技术选型阶段。当时团队需要一套能同时覆盖表格、文档、幻灯片的在线编辑方案,候选名单里有几个方向:一是基于开源表格库二次开发,二是直接采购商业组件,三是自研一套轻量级渲染引擎。前两条路都试过,要么扩展性受限,要么成本高得离谱,最后把目光落在了 Univer 上。
Univer 是一套开源的办公套件 SDK,定位很明确——把电子表格、文档、幻灯片这些能力做成可嵌入的模块,让开发者能像搭积木一样组装出自己的在线办公产品。它用 TypeScript 写核心逻辑,渲染层基于 Canvas,运行时既能在浏览器里跑,也能在 Node.js 环境下做服务端计算。这个组合挺有意思:Canvas 保证了渲染性能,插件架构保证了功能可裁剪,Node.js 支持则打开了服务端批处理、自动化报表这类场景的大门。
适合谁来读这篇内容?如果你正在做在线表格、协同文档、低代码平台里的数据编辑模块,或者单纯想搞清楚一个现代办公 SDK 的架构是怎么设计的,那接下来的拆解应该对你有用。我会从整体设计思路讲到核心模块的实现细节,再落到实际集成时踩过的坑,尽量把“为什么这么设计”和“实际怎么用”都说清楚。
2. 整体架构拆解:Univer 为什么选择插件化加 Canvas
2.1 核心设计思路与模块划分
Univer 的架构可以用一句话概括:内核极简,能力外挂。它的核心包只负责最基础的事情——管理生命周期、维护插件注册表、提供事件总线。真正的表格计算、公式解析、渲染绘制、协同同步,全部以插件形式存在。
这种设计带来的直接好处是包体积可控。一个只需要静态表格展示的场景,完全可以不引入公式引擎和协同模块,最终打包出来的体积可能只有完整版的几分之一。我在一个数据看板项目里做过对比:完整引入所有插件时 gzip 后大约 800KB,裁剪到只保留核心加基础表格渲染后降到了 210KB 左右,对于首屏加载敏感的场景差别很明显。
模块划分上,Univer 大致分成这几层:
- 核心层(@univer/core):提供 Univer 实例、插件系统、命令系统、事件机制。这一层不包含任何业务逻辑。
- 领域层:表格(Sheets)、文档(Docs)、幻灯片(Slides)各自独立成包,每个领域内部再细分数据模型、渲染器、UI 组件。
- 基础设施层:公式引擎、协同层、导入导出、Canvas 渲染器这些横切能力,以独立包的形式存在,按需引入。
注意:Univer 的包命名遵循
@univerjs/xxx的约定,但不同版本之间包名有过调整,集成前务必确认版本对应的包结构,否则会出现引用了不存在的包或者 API 对不上的情况。
2.2 为什么是 Canvas 而不是 DOM
这是被问得最多的一个问题。用 DOM 做表格渲染,天然有浏览器布局引擎帮忙处理文本换行、单元格合并这些事,开发成本低。但 DOM 的瓶颈也很明显:当单元格数量上万之后,节点数爆炸,滚动和编辑都会卡。
Canvas 的方案是把整个表格画在一张画布上,单元格不是 DOM 节点,而是绘制指令。这样无论表格多大,DOM 树始终只有画布这一个节点。代价是所有交互都要自己实现——点击命中检测、文本编辑、选区绘制、滚动条,全部得手写。
Univer 在这块的处理比较成熟。它维护了一套视口坐标系和单元格索引的映射关系,鼠标事件先转成画布坐标,再通过索引快速定位到具体单元格。文本编辑则采用“画布上覆盖一个隐藏的输入框”的方案,编辑时把输入框定位到目标单元格位置,用户实际是在原生输入框里打字,打完再同步回数据模型。这个方案兼顾了渲染性能和输入法兼容性,实测在中文输入法下表现稳定。
2.3 插件架构的注册与通信机制
插件在 Univer 里的生命周期大致是:注册 -> 依赖解析 -> 初始化 -> 就绪。每个插件声明自己依赖哪些其他插件,内核按拓扑排序决定初始化顺序。这个机制保证了比如“公式插件”一定在“表格数据插件”之后初始化,因为它需要读取单元格数据。
插件之间的通信不走直接引用,而是通过命令总线和事件总线。一个插件想修改数据,不是直接改另一个插件的数据结构,而是派发一个命令,由对应的命令处理器去执行。这样做的好处是解耦彻底,任何插件都可以被替换或移除,只要命令契约不变。
// 插件注册的典型写法 import { Univer, UniverInstanceType } from '@univerjs/core'; import { UniverSheetsPlugin } from '@univerjs/sheets'; const univer = new Univer(); // 注册表格插件 univer.registerPlugin(UniverSheetsPlugin); // 创建表格实例 const workbook = univer.createUnit(UniverInstanceType.UNIVER_SHEET, { id: 'workbook-1', sheetOrder: ['sheet-1'], sheets: { 'sheet-1': { id: 'sheet-1', name: 'Sheet1', cellData: {}, }, }, });上面这段代码展示了最小化的表格创建流程。实际项目里通常还会注册公式插件、渲染插件、UI 插件等。每个registerPlugin调用都会触发依赖检查,如果某个插件依赖的插件没注册,控制台会给出明确提示。
3. 核心细节解析:数据模型、公式引擎与渲染管线
3.1 数据模型的设计取舍
Univer 的表格数据模型采用稀疏存储。什么意思?就是只有真正有内容的单元格才会被记录,空单元格不占空间。数据结构大致是cellData[row][col]的嵌套对象,每个单元格存值、样式、公式等信息。
这种设计对内存友好,但带来一个查询上的问题:要获取某一列的所有值,不能简单地遍历数组,得先拿到这一列有哪些行有数据。Univer 内部维护了行索引和列索引来加速这类查询。我在处理一个十万行级别的报表时测过,按列聚合的耗时在可接受范围内,但如果要做全表扫描类的操作,还是建议走服务端或者 Web Worker。
样式部分单独存储,单元格只存样式 ID,实际样式定义放在一张样式表里。这样多个单元格共享同一样式时不会重复存储,对于格式统一的表格能省不少内存。
3.2 公式引擎的计算链路
公式引擎是 Univer 里相对独立的一块。它的工作流程是:解析公式文本生成 AST,然后对 AST 求值。求值时需要访问单元格数据,这部分通过依赖收集来实现——每个公式会记录自己引用了哪些单元格,当这些单元格变化时,公式自动重算。
依赖收集的实现方式是:求值过程中,引擎拦截所有对单元格的读取操作,把当前正在计算的公式和读取的单元格建立关联。这个思路和 Vue 的响应式系统很像,都是“读取时收集依赖,变更时触发更新”。
// 公式依赖关系的简化示意 const formula = '=SUM(A1:A10)'; // 解析后得到 AST,求值时读取 A1 到 A10 // 引擎记录:formula 依赖 A1...A10 // 当 A3 被修改时,formula 被标记为脏,触发重算实际使用中要注意循环引用。Univer 对循环引用有检测机制,但检测是在求值阶段进行的,如果公式链特别长,检测本身也有开销。我遇到过用户不小心写出跨表循环引用导致页面卡死的情况,后来在业务层加了一层公式复杂度限制,超过阈值就提示用户而不是直接计算。
3.3 Canvas 渲染管线的分层
渲染这块 Univer 分了几个层:背景层、内容层、选区层、覆盖层。背景层画网格线和单元格底色,内容层画文本和公式结果,选区层画选中高亮和边框,覆盖层放浮动元素比如下拉菜单、批注气泡。
分层的好处是重绘时可以只重绘变化的部分。比如只是移动选区,背景层和内容层不需要动,只重绘选区层即可。这个优化在滚动和拖拽选择时效果明显,帧率能稳定在 60fps。
渲染的触发走的是脏矩形机制。数据变化时,计算出受影响的单元格范围,只把这个范围标记为脏,下一帧只重绘脏区域。如果变化范围很大,比如整列删除,那就退化成全量重绘。实测下来,日常编辑操作基本都是局部重绘,性能没问题。
提示:如果你在集成时发现滚动卡顿,优先检查是不是每次数据变化都触发了全量重绘。常见原因是直接替换了整个 workbook 对象,而不是通过命令去修改具体单元格。前者会让引擎认为所有数据都变了。
4. 实操集成:从零搭建一个可运行的表格应用
4.1 环境准备与依赖安装
先确认 Node.js 版本。Univer 的构建工具链对 Node 版本有要求,建议用 18 LTS 或更高。我试过在 16 上跑,部分依赖会报错。安装完 Node 后,用 npm 或 pnpm 初始化项目。
# 创建项目目录 mkdir univer-demo && cd univer-demo npm init -y # 安装核心依赖 npm install @univerjs/core @univerjs/sheets @univerjs/sheets-ui npm install @univerjs/design @univerjs/engine-formula npm install @univerjs/engine-render @univerjs/ui # 如果要用 React 集成 npm install @univerjs/sheets-ui @univerjs/ui-adapter-react依赖装完后,建议先跑一个最小示例验证环境。创建一个 HTML 文件,引入打包后的脚本,初始化一个空表格。这一步能跑通,说明基础环境没问题,再往上加功能。
4.2 初始化流程与关键配置
初始化的顺序有讲究。必须先创建 Univer 实例,再注册插件,最后创建数据单元。插件注册顺序虽然内核会做依赖排序,但同类插件之间还是有先后要求的,比如 UI 插件要在渲染插件之后注册。
import { Univer, LocaleType, merge } from '@univerjs/core'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui'; import { UniverFormulaEnginePlugin } from '@univerjs/engine-formula'; import { UniverRenderEnginePlugin } from '@univerjs/engine-render'; import { UniverUIPlugin } from '@univerjs/ui'; // 中文语言包 import DesignZhCN from '@univerjs/design/locale/zh-CN'; import UIZhCN from '@univerjs/ui/locale/zh-CN'; import SheetsZhCN from '@univerjs/sheets/locale/zh-CN'; const univer = new Univer({ locale: LocaleType.ZH_CN, locales: { [LocaleType.ZH_CN]: merge({}, DesignZhCN, UIZhCN, SheetsZhCN), }, }); // 按依赖顺序注册 univer.registerPlugin(UniverRenderEnginePlugin); univer.registerPlugin(UniverFormulaEnginePlugin); univer.registerPlugin(UniverUIPlugin, { container: 'app', // 挂载的 DOM 容器 id }); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsUIPlugin); // 创建表格 univer.createUnit(UniverInstanceType.UNIVER_SHEET, { id: 'demo-workbook', sheetOrder: ['sheet-1'], sheets: { 'sheet-1': { id: 'sheet-1', name: '数据表', rowCount: 100, columnCount: 20, cellData: { 0: { 0: { v: '姓名' }, 1: { v: '部门' }, 2: { v: '工时' }, }, 1: { 0: { v: '张三' }, 1: { v: '研发' }, 2: { v: 160 }, }, }, }, }, });这段代码跑起来后,页面上会出现一个带数据的表格,可以编辑、可以选中、可以输入公式。container参数指定挂载点,确保对应的 DOM 元素在初始化之前已经存在。
4.3 数据导入导出的处理
实际项目里,表格数据往往来自后端接口或者 Excel 文件。Univer 提供了导入导出插件,但要注意格式转换的开销。我处理过一个两万行的 Excel 导入,直接在主线程做转换会卡住界面好几秒,后来改成在 Web Worker 里解析,主线程只负责接收结果,体验好很多。
导出方面,如果只是导出数据,直接遍历 cellData 生成二维数组就行。如果要保留样式和公式,得用官方的导出插件,它会生成符合 Excel 格式的文件。这里有个细节:导出时公式会被转成 Excel 公式语法,如果用了 Univer 特有的函数,导出后可能不兼容,需要提前做映射。
// 简单的数据导出示例 function exportToArray(workbook: Workbook): any[][] { const sheet = workbook.getActiveSheet(); const { cellData, rowCount, columnCount } = sheet.getSnapshot(); const result: any[][] = []; for (let r = 0; r < rowCount; r++) { const row: any[] = []; for (let c = 0; c < columnCount; c++) { const cell = cellData[r]?.[c]; row.push(cell?.v ?? ''); } result.push(row); } return result; }4.4 与后端协同的接口设计
如果要做协同编辑,Univer 的协同层需要后端配合。核心思路是:前端把用户的编辑操作转成命令,通过 WebSocket 发给服务端,服务端广播给其他客户端,各客户端再执行同样的命令。这样保证所有端的状态最终一致。
命令的粒度要控制好。太细会导致网络消息过多,太粗会导致冲突处理困难。我的经验是按“一次用户操作”为单位,比如输入一个单元格的值、插入一行、设置一个样式,各算一个命令。批量操作比如粘贴一片区域,可以合并成一个命令发送。
注意:协同场景下,本地先执行命令再发网络请求,还是等确认后再执行,这两种策略各有优劣。前者响应快但可能出现回滚,后者一致性强但有延迟。Univer 默认是前者,如果业务对一致性要求极高,需要在应用层做额外处理。
5. 常见问题与排查技巧实录
5.1 渲染相关问题的排查
问题一:表格显示空白,控制台无报错。这种情况多半是容器尺寸问题。Canvas 初始化时会读取容器宽高,如果容器此时宽高为 0(比如父元素还没渲染完),画布尺寸就是 0。解决办法是确保容器有明确的尺寸,或者在容器尺寸变化后调用resize方法。
问题二:滚动时出现残影。通常是脏矩形计算有误,或者多个渲染层没有同步清除。检查一下是不是在渲染过程中修改了数据,导致渲染管线状态不一致。另外,如果自定义了渲染插件,要确保在dispose时清理掉自己的绘制内容。
问题三:中文输入法下文字错位。这是 Canvas 编辑的经典问题。Univer 的输入框定位依赖单元格的屏幕坐标,如果表格有缩放或者滚动,坐标换算容易出错。排查时先确认缩放比例是否为 1,再检查滚动偏移量有没有正确参与计算。
5.2 公式与数据问题速查
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 公式不计算 | 公式引擎插件未注册 | 检查 registerPlugin 调用 |
| 公式结果错误 | 单元格引用格式不对 | 确认是 A1 还是 R1C1 格式 |
| 修改数据后公式不更新 | 依赖收集失效 | 检查是否直接改了 cellData 而非走命令 |
| 大数据量卡顿 | 全量重算触发 | 检查是否有循环引用或大范围依赖 |
| 导入后公式丢失 | 格式不兼容 | 确认导入插件的版本和配置 |
5.3 插件冲突与版本管理
Univer 生态里包比较多,版本不一致是常见坑。比如 core 是 0.1.x,sheets 是 0.2.x,两者 API 可能对不上。建议在 package.json 里锁定所有 @univerjs 包的版本号,用统一的版本。升级时整体升级,不要单独升某一个包。
插件冲突的另一个来源是功能重叠。比如同时注册了两个都处理单元格点击的插件,事件会被重复处理。排查时可以在事件回调里打日志,看是不是触发了多次。解决方法是明确各插件的职责边界,或者用事件优先级来控制。
提示:Univer 的插件系统支持动态卸载。如果某个功能模块在特定场景下不需要,可以在运行时卸载对应插件,释放资源。但要注意卸载前先清理该插件注册的命令和事件监听,否则会留下悬空引用。
6. 服务端渲染与 Node.js 集成场景
6.1 为什么要在 Node.js 里跑 Univer
有些场景不适合在浏览器里做计算。比如定时生成报表、批量处理上传的表格文件、服务端公式校验。这些任务如果放在前端,要么用户得一直开着页面,要么得用无头浏览器,都不太优雅。Univer 的核心逻辑不依赖 DOM,可以在 Node.js 里直接跑,只做数据计算不做渲染。
我做过一个服务端报表生成的服务:用户上传 Excel,服务端解析后套用公式模板,计算出结果再返回。整个流程在 Node.js 里完成,用 Univer 的公式引擎做计算,比用其他表格库更贴近前端的计算行为,保证了前后端结果一致。
6.2 服务端使用的限制与变通
Node.js 环境下不能用 Canvas 渲染相关的插件,因为依赖 DOM API。但数据模型、公式引擎、命令系统这些都可以正常使用。初始化时只注册核心插件和公式插件,不注册 UI 和渲染插件。
// Node.js 环境下的最小初始化 import { Univer } from '@univerjs/core'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverFormulaEnginePlugin } from '@univerjs/engine-formula'; const univer = new Univer(); univer.registerPlugin(UniverFormulaEnginePlugin); univer.registerPlugin(UniverSheetsPlugin); // 创建数据、设置公式、读取计算结果 // 全程不需要 DOM要注意的是,某些插件在初始化时会尝试访问window或document,在 Node 环境下会报错。遇到这种情况,要么找该插件的服务端版本,要么用条件判断跳过注册。社区里有人做过 Node 适配的补丁,但版本更新后可能失效,建议自己维护一个适配层。
6.3 批量计算的性能调优
服务端批量计算时,性能瓶颈通常在公式求值。如果几千个单元格都有公式,串行计算会很慢。可以利用 Node.js 的多核能力,把不同的工作表分配到不同的 Worker 线程里并行计算。Univer 的实例之间是隔离的,每个 Worker 里跑一个独立实例,互不干扰。
内存方面,处理大文件时要注意及时释放。Univer 实例用完后调用dispose方法清理资源,否则在长时间运行的服务里会内存泄漏。我见过一个服务因为没调 dispose,跑了一周后内存涨到几个 G。
7. 一些实际项目中的经验沉淀
7.1 性能优化的几个关键点
第一,减少不必要的重渲染。数据变更尽量走命令,不要直接操作底层数据结构。命令系统会做批量合并和脏区域计算,直接改数据会绕过这些优化。
第二,合理设置行列数量。创建表格时如果 rowCount 设得很大但实际没那么多数据,滚动条和索引计算会有额外开销。按实际需要设置,或者用动态扩展。
第三,公式尽量用区域引用。SUM(A1:A1000)比一千个单独的加法公式效率高得多,因为依赖收集和重算的粒度不同。
第四,样式复用。相同样式的单元格共享样式对象,不要每个单元格都创建新样式。样式对象多了之后,样式表的查找也会变慢。
7.2 扩展性设计的考量
如果要在 Univer 基础上做二次开发,建议把业务逻辑放在独立的插件里,不要直接改核心包。核心包升级时,自己的插件只要适配接口变化就行,不用跟着改一堆代码。
自定义渲染器时,继承官方的渲染基类,只重写需要定制的绘制方法。这样官方渲染管线更新时,能自动继承大部分优化。
命令的命名要有自己的前缀,避免和官方命令冲突。比如官方是sheet.command.set-range-values,你的可以是myapp.command.xxx。
7.3 我踩过的几个坑
第一个坑是版本升级导致的 API 断裂。早期版本里创建表格用的是univer.createUniverSheet,后来改成了createUnit。升级时没注意,跑起来直接报错。教训是升级前先看 changelog,小版本号变化也可能有破坏性改动。
第二个坑是语言包缺失导致界面显示 key。只注册了表格的语言包,没注册 UI 的,结果工具栏按钮显示的是toolbar.undo这种原始 key。后来把 design、ui、sheets 的语言包都合并进去才正常。
第三个坑是在公式里用了未注册的函数。Univer 的公式函数需要单独注册,不是内置全部。用了VLOOKUP发现不计算,查了半天才意识到要注册对应的函数插件。
第四个坑是协同场景下的命令回放顺序。网络延迟导致命令到达顺序和产生顺序不一致,表格状态就乱了。后来在命令里加了时间戳和序列号,服务端按序列号排序后再广播,问题解决。
7.4 后续可以扩展的方向
如果这套东西跑通了,往这几个方向扩展会比较自然:一是接入协同后端,做成多人实时编辑;二是加自定义函数,把业务特有的计算逻辑注册成公式;三是做移动端适配,Univer 的 Canvas 渲染在移动端理论上可行,但触摸交互需要额外处理;四是做服务端定时任务,用 Node.js 跑批量报表。
我个人在实际操作中的体会是,Univer 的学习曲线主要在前期的架构理解上。一旦搞清楚了插件注册顺序、命令流转、渲染分层这几个核心概念,后面加功能就是按部就班的事。真正花时间的是各种边界情况的处理——空数据、超大表格、异常输入、协同冲突,这些在文档里往往一笔带过,但实际项目里一个都躲不掉。建议在早期就建立一套自己的测试用例集,把这些问题覆盖到,后面迭代会省很多事。