1. 从"univer"这个名字说起:它到底在解决什么问题
第一次看到"univer"这个词,很多人会以为是"universe"的缩写,或者某个新出的前端框架。实际上,Univer 是一套开源的在线电子表格与文档协同引擎,核心定位是让开发者能在自己的产品里嵌入一套类似在线表格的能力——单元格编辑、公式计算、多人在线协作、插件扩展,这些原本需要几十人团队做一两年的东西,它试图用一套 SDK 帮你省掉。
我最早接触它是因为一个内部数据填报系统的需求。业务方想要一个"能像在线表格那样用"的界面,但又不想要那种重量级的商业方案,预算和可控性都不允许。当时评估了几条路线:一是自己用 Canvas 从零画表格,二是基于开源表格库二次开发,三是直接找现成的协同引擎。第一条路我试过,画个静态表格不难,但一旦涉及公式、选区、复制粘贴、撤销重做,工作量会指数级爆炸;第二条路的问题是大多数开源表格库只解决"展示和编辑",不解决"协同"和"扩展"。Univer 吸引我的点就在于,它把渲染层、数据层、协同层、插件层做了分层设计,你可以只用它的渲染,也可以整套接入。
这里要先说清楚一个容易混淆的概念:Univer 不是"一个在线表格产品",而是"一套让你能做出在线表格产品的 SDK"。这个区别很关键。前者你只能用它给的功能,后者你可以决定它长什么样、接什么后端、支持哪些自定义能力。它的关键词里出现了 SDK、Node.js、Canvas、插件架构,这四个词基本勾勒出了它的技术轮廓——用 Canvas 做高性能渲染,用插件架构做能力扩展,用 Node.js 做服务端协同与公式计算的可能落点。
适合读这篇的人大概分三类:一是前端工程师,想在自己的项目里嵌入表格能力;二是全栈或后端同学,关心协同和公式计算怎么落地;三是技术选型的人,想搞清楚它和传统方案比到底值不值得投入。下面我会按"它怎么工作—怎么跑起来—怎么扩展—怎么避坑"的顺序,把我在实际项目里踩过的东西摊开讲。
2. Univer 的分层架构:为什么它敢用 Canvas 重画一切
2.1 Canvas 渲染与 DOM 渲染的根本分歧
传统网页表格,比如很多开源表格库,底层是 DOM 表格或者虚拟 DOM。每个单元格是一个 DOM 节点,浏览器帮你处理布局、滚动、选区高亮。好处是开发简单,坏处是当单元格数量上去之后,DOM 节点数量会拖垮性能。一个 1000 行 × 50 列的表格就是 5 万个节点,滚动和重绘都会卡。
Univer 走的是 Canvas 路线。整个表格区域就是一块(或几块)Canvas,所有单元格、网格线、文字、选区框都是画上去的。这意味着无论表格多大,DOM 层面始终只有几个 Canvas 元素。滚动时不是移动 DOM,而是重新计算可视区域、重绘这一屏的内容。这就是为什么它能撑住大表格——渲染成本和表格总规模解耦,只和可视区域大小相关。
但 Canvas 路线不是没有代价。最直接的问题是无障碍访问和文本选择。DOM 表格天然支持屏幕阅读器、支持鼠标拖选文字、支持浏览器自带的查找。Canvas 里这些都要自己实现。Univer 的做法是在 Canvas 之上叠一层透明的 DOM 层来处理输入和选区,这个思路和很多在线文档产品是一致的。你在实际接入时会发现,它的输入框、公式栏、右键菜单这些仍然是 DOM,只有表格主体是 Canvas。
提示:如果你的场景对无障碍要求很高,或者需要浏览器原生查找功能,Canvas 方案需要额外评估。这不是 Univer 独有的问题,而是所有 Canvas 表格的共同取舍。
2.2 数据层与渲染层的解耦
Univer 内部把数据模型和渲染分得很开。数据层维护的是单元格的值、公式、样式、合并信息等,渲染层只负责"把当前视口该显示的东西画出来"。这种解耦带来的直接好处是:协同更新时不需要重建整个表格,只需要更新数据模型,然后触发重绘。
我实测过一个场景:两个客户端同时编辑同一张表,一个改 A1,一个改 B2。如果数据层和渲染层耦合,每次远端更新都可能触发全量刷新。Univer 的做法是数据变更走一套事件机制,渲染层订阅变更、按需重绘。实际体验下来,几十个单元格的并发更新不会造成明显卡顿。
这里有个细节值得说:Univer 的数据模型不是简单的二维数组。它用的是类似稀疏存储的结构,只记录有内容的单元格。这对大表格很友好——一张 10000 行的表如果只有 200 行有数据,内存占用不会因为声明了 10000 行就爆掉。你在做数据导入时可以利用这一点,不必预先分配整个矩阵。
2.3 插件架构:能力不是写死的,是挂上去的
Univer 的插件架构是我认为它最有价值的部分。核心包只提供最基础的表格能力,公式、协同、条件格式、数据验证、图表这些都是以插件形式存在的。每个插件可以注册自己的命令、监听事件、往渲染层加东西。
这种设计的好处是按需加载。你如果只需要一个静态表格展示,完全可以不引入公式插件和协同插件,打包体积会小很多。反过来,如果你要做一个完整的在线表格,就把需要的插件都装上。
插件之间通过一套依赖注入和生命周期机制协作。每个插件有onStart、onReady、onDispose这样的钩子,可以在合适的时机做初始化。我踩过的一个坑是:插件的注册顺序会影响依赖解析。如果你自定义的插件依赖公式插件提供的服务,就必须保证公式插件先注册。Univer 内部有一套依赖声明机制,但自定义插件如果没声明清楚,运行时可能拿到 undefined。
// 插件注册的典型写法(示意) import { Univer } from '@univerjs/core'; import { UniverFormulaEnginePlugin } from '@univerjs/formula'; import { UniverSheetsPlugin } from '@univerjs/sheets'; const univer = new Univer(); // 先注册基础表格能力 univer.registerPlugin(UniverSheetsPlugin); // 再注册公式引擎,它依赖表格的数据模型 univer.registerPlugin(UniverFormulaEnginePlugin);上面这段代码的顺序不是随便写的。公式引擎需要读取单元格的值来计算,所以它必须在表格数据模型就绪之后才能工作。如果你把顺序反过来,公式插件启动时找不到数据源,要么报错要么静默失效。
3. 把 Univer 跑起来:环境、依赖与第一个可交互表格
3.1 Node.js 环境准备中的版本陷阱
Univer 的开发环境依赖 Node.js。这里有个很实际的问题:Node.js 版本选不对,装依赖阶段就会失败。我在一台老机器上用的是比较旧的 LTS 版本,npm install直接报错,提示某些包要求更高的 Node 版本。后来换到 18.x 的 LTS 才顺利装上。
具体来说,Univer 的构建工具链用到了较新的 ESM 特性和一些构建插件,Node 16 以下基本不用考虑。推荐用 18.20.4 或更高的 LTS 版本。如果你机器上已经装了别的版本,建议用版本管理工具切换,而不是直接覆盖安装——很多其他项目可能还依赖旧版本。
安装步骤本身不复杂,但有几个点容易忽略:
- 确认 Node 和 npm 版本匹配。Node 18 自带 npm 9,如果你手动升级过 npm 到很新的版本,偶尔会有兼容问题。
- 网络问题。Univer 的依赖里有不少包,如果网络不稳定,建议配置国内镜像源,否则装到一半超时很折磨。
- 包管理器选择。官方示例用的是 npm,但 pnpm 和 yarn 也能用。我个人倾向 pnpm,装得快、磁盘占用小,但要注意 workspace 配置,Univer 的 monorepo 结构对包管理器有一定要求。
# 检查当前 Node 版本 node -v # 如果低于 18,建议切换 # 安装依赖(在项目根目录) npm install # 启动开发服务器 npm run dev3.2 最小可运行示例的搭建逻辑
跑通一个最小示例,核心就三步:创建 Univer 实例、注册必要插件、挂载到 DOM 容器。但每一步都有细节。
创建实例时,Univer 需要一个容器元素。这个容器的大小决定了表格的初始视口。我见过有人把容器设成height: 0或者没有明确高度,结果表格渲染出来是空白的——因为 Canvas 按容器尺寸初始化,容器没高度,Canvas 就没高度。
注册插件时,最小集合通常包括核心包、表格插件、渲染插件。如果你要公式,再加公式插件。这里要注意的是渲染引擎的初始化时机。Univer 的渲染是异步的,你不能在创建实例后立刻去读某个单元格的 DOM,因为那时候还没画出来。正确的做法是监听渲染完成的事件,或者在onReady回调里做后续操作。
挂载完成后,你会看到一个空表格。这时候可以通过 API 往里面写数据。Univer 提供了一套命令式的 API,比如设置单元格值、设置样式、合并单元格。这些 API 的设计思路和很多表格库不同——它不是直接操作数据对象,而是发命令。命令会被插件拦截、处理、然后更新数据模型。这种设计是为了协同和撤销重做服务的,因为命令可以被记录、回放、同步。
// 往 A1 写值的示意 // 注意:实际 API 名称以官方文档为准,这里展示的是调用思路 univer.getActiveWorkbook().getActiveSheet().getRange('A1').setValue('Hello Univer');3.3 首次运行最容易卡住的三个地方
第一个是样式丢失。Univer 的 Canvas 渲染依赖一些基础样式,如果你在集成时把它的 CSS 过滤掉了,表格会画得乱七八糟。确保引入它提供的样式文件。
第二个是容器尺寸变化不重绘。浏览器窗口 resize 时,Canvas 不会自动跟着变。你需要监听 resize 事件,调用 Univer 的重绘方法。我一开始没做这个,用户把窗口拉大后表格右边一片空白,还以为是 bug。
第三个是多实例冲突。如果你在同一个页面创建多个 Univer 实例,要注意它们的容器 ID 和事件监听不能互相干扰。我做过一个对比页面,左右两个表格,结果发现滚动一个另一个也跟着动——原因是事件监听挂到了 document 上。后来改成各自容器内监听才解决。
4. 协同与公式:Univer 真正难啃的两块骨头
4.1 协同编辑的冲突处理思路
在线表格的协同,核心问题是多个人同时改同一个单元格怎么办。Univer 的协同模型基于操作变换(OT)或者类似的思想,每个编辑动作被抽象成一个操作,操作之间定义合并规则。
举个具体例子:A 把 A1 从 "1" 改成 "2",B 同时把 A1 从 "1" 改成 "3"。如果简单按到达顺序覆盖,后到的会赢,先到的用户会看到自己的修改"消失"了。OT 的思路是,当服务端收到两个基于同一版本的操作时,会对后到的操作做变换,让它基于前一个操作的结果生效。对于单元格赋值这种操作,变换规则通常是"后者覆盖前者",但至少系统知道发生了什么,可以给用户提示。
Univer 把协同做成了插件,意味着你可以选择用它的协同方案,也可以自己接一套。如果你自己接,需要理解它的操作数据结构。我建议如果不是有特殊需求,直接用官方协同插件,自己实现 OT 的坑太深。
实际部署协同服务时,Node.js 是常见选择。服务端要做的事情包括:接收客户端操作、做冲突变换、广播给其他客户端、持久化。这里有个性能点:广播不要全量广播,只广播操作增量。Univer 的操作本身是增量的,但如果你在服务端做了错误的合并,可能把增量变成全量,带宽和延迟都会上去。
4.2 公式引擎的计算时机与依赖追踪
公式是表格的灵魂。Univer 的公式引擎支持常见的函数,也支持自定义函数。它的计算模型是依赖追踪 + 增量重算。
当你写=A1+B1在 C1 时,引擎会记录 C1 依赖 A1 和 B1。当 A1 变化时,引擎知道要重算 C1。如果 C1 又被 D1 引用,D1 也要重算。这套依赖图维护得好不好,直接决定大表格下的性能。
我实测过一个有几千个公式的表,修改一个被大量引用的单元格,重算会有可感知的延迟。这不是 Univer 独有的问题,所有公式引擎都面临这个。优化方向通常是:减少不必要的依赖、避免整列引用、把不常变的公式结果缓存。
自定义函数是扩展点。你可以注册自己的函数,比如接一个内部汇率接口,写=MYRATE("USD","CNY")。注册时要提供函数名、参数定义、计算逻辑。注意自定义函数如果是异步的(比如要请求接口),处理起来会复杂一些,因为公式计算默认是同步的。Univer 对异步公式有支持,但需要按它的约定来写。
4.3 协同场景下公式的一致性
协同 + 公式会引出一个棘手问题:不同客户端看到的公式结果必须一致。如果 A 的客户端算出 C1 是 10,B 的客户端算出 11,那就乱套了。
保证一致性的做法通常是:公式计算在服务端做权威计算,客户端做本地预计算用于即时反馈,服务端结果回来后校正。Univer 的协同插件支持这种模式。但要注意,如果自定义函数依赖外部数据,而外部数据在不同时间点可能不同,就会导致不一致。所以自定义函数最好是纯函数,或者把外部数据快照进操作里一起同步。
5. 插件扩展实战:从零写一个自定义插件
5.1 插件的基本结构与生命周期
写一个 Univer 插件,本质上是实现一个类,提供onStart等方法。在onStart里你可以拿到注入的服务,注册命令,监听事件。
// 自定义插件示意 class MyPlugin { onStart(context) { // 注册一个自定义命令 context.commandService.registerCommand({ id: 'my.custom.command', handler: (params) => { // 命令逻辑 return true; } }); } onDispose() { // 清理资源 } }生命周期里最容易出问题的是清理。如果你的插件监听了全局事件、开了定时器、创建了 DOM 节点,在onDispose里一定要清掉。我见过插件卸载后定时器还在跑,导致内存泄漏。
5.2 往渲染层加东西的正确姿势
如果你想在表格上画点自定义的东西,比如给某些单元格加个角标,不能直接往 Canvas 上画——那样会被下一次重绘覆盖。正确做法是注册一个渲染扩展,让 Univer 在重绘时调用你的绘制逻辑。
Univer 的渲染层有分层概念,你可以在合适的层插入自己的绘制。关键是拿到当前的视口信息和坐标转换方法,把数据坐标转成屏幕坐标再画。这个转换在滚动和缩放时会变,所以你的绘制逻辑必须每次重绘都重新计算,不能缓存屏幕坐标。
5.3 插件间通信与依赖声明
插件之间要通信,不能直接互相 import,那样会耦合死。Univer 提供了依赖注入机制,一个插件可以声明它依赖哪些服务,运行时框架会把服务注入进来。
我踩过的坑是循环依赖。插件 A 依赖 B 的服务,B 又依赖 A 的,启动时就会死锁或者拿到未初始化的对象。解决办法是把公共逻辑抽到第三个插件或服务里,让 A 和 B 都依赖它,而不是互相依赖。
6. 性能调优与常见故障排查
6.1 大表格下的渲染优化
前面说过 Canvas 渲染和表格规模解耦,但这是有前提的:你的数据模型和依赖追踪不能拖后腿。如果一张表有几十万单元格,即使只渲染可视区域,数据层的遍历和事件分发也可能成为瓶颈。
优化手段包括:冻结不常变的区域、减少公式依赖链深度、避免在单元格级别做大量样式对象。样式对象在 Univer 里是共享的,如果你给每个单元格都创建一个独立的样式对象,内存会涨得很快。正确做法是复用样式对象。
6.2 常见报错与定位方法
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 表格空白 | 容器无高度 / 渲染未初始化 | 检查容器 CSS,监听 onReady |
| 滚动卡顿 | 数据层遍历过重 / 公式链过深 | 用性能面板看主线程耗时 |
| 协同不同步 | 操作未广播 / 版本号错乱 | 检查服务端操作日志 |
| 公式不计算 | 插件未注册 / 依赖未声明 | 确认公式插件已加载 |
| 自定义插件无效 | 注册顺序 / 依赖未注入 | 检查 onStart 是否执行 |
排查时我习惯先看控制台有没有报错,再看网络面板有没有异常请求,最后用性能面板定位卡顿。Univer 的日志输出比较克制,必要时可以在插件里自己加日志。
6.3 打包体积与按需引入
Univer 的包不少,全量引入打包体积会比较大。实际项目里应该按需引入:只用表格就只引表格相关包,不用协同就不引协同包。构建工具方面,Vite 和 Webpack 都支持,但要注意 ESM 和 CJS 的混用问题。有些依赖可能只提供 CJS,打包时要做兼容处理。
7. 我在实际项目里总结的几条经验
第一条,不要一上来就追求全功能。我见过团队一开始就把协同、公式、图表全接上,结果每个都有问题,排查起来互相干扰。正确做法是先跑通静态表格,再加公式,再加协同,每加一层都确保稳定。
第二条,数据导入导出要单独设计。Univer 的内部数据结构和 Excel 的文件结构不是一回事。如果你要支持导入 Excel,需要做格式转换。这个转换工作量大且容易出细节问题,比如合并单元格、条件格式、公式引用,建议单独排期。
第三条,协同的测试要用真实多端。单机模拟多客户端和真实多设备差别很大,网络延迟、时钟不同步、断线重连这些在单机测试里暴露不出来。我建议至少用两台设备加一个弱网环境测一轮。
第四条,关注版本升级的破坏性变更。Univer 还在快速迭代,API 可能变。升级前先看 changelog,重点看核心包和插件包的版本对应关系。我吃过一次亏,核心包升了但插件没升,运行时各种诡异报错。
最后分享一个调试小技巧:Univer 的实例上可以拿到当前工作簿和工作表对象,调试时在控制台直接操作这些对象,比反复改代码刷新页面快得多。比如临时改个单元格值看渲染反应,或者打印依赖图看公式链,都很方便。这个习惯帮我省了大量来回编译的时间。