1. Univer 到底是个什么东西,为什么值得单独拿出来聊
第一次听到 Univer 这个名字,很多人会以为是某个新出的前端框架或者 UI 组件库。其实不是。Univer 是一套开源的在线电子表格与文档协作引擎,核心定位是让开发者能把“类 Excel”“类 Google Sheets”的能力直接嵌进自己的产品里。它对外暴露的核心入口是Facade API,底层渲染依赖Canvas,服务端和构建链路则深度绑定Node.js生态。这几个关键词——univer、SDK、Node.js、Canvas、Facade API——基本就是它的技术骨架。
我最早接触 Univer 是因为一个内部数据填报系统的需求。业务方想要一个能在浏览器里直接编辑、支持公式、支持多人同时改单元格的东西,但又不想引入一整套笨重的商业表格控件。当时评估了几条路线:自己用 Canvas 从零画表格、用现成的开源表格库、或者找一个带协作能力的引擎。自己画表格这件事,做过的人都懂,光是单元格合并、冻结行列、公式依赖链就能把人拖垮。现成的开源表格库大多只解决“展示”,不解决“编辑 + 协作 + 公式”。Univer 恰好卡在这个位置上:它把电子表格的渲染、公式计算、协同编辑、插件体系都做成了可复用的 SDK。
所以这篇文章不是官方文档的搬运,而是我作为一个实际把它接进项目里的人,把 Univer 的核心设计、Facade API 的用法、Canvas 渲染的坑、Node.js 侧的配合、以及踩过的那些坑,完整地摊开讲一遍。适合谁看?如果你正在做在线表格、数据看板、低代码平台里的表格模块、或者任何需要“在网页里编辑结构化数据”的产品,这篇内容能帮你少走至少两周弯路。如果你只是好奇 Canvas 怎么画一个高性能表格,里面关于渲染分层和脏矩形的内容也值得一看。
Univer 的能力边界要说清楚:它不是 Excel 的完整替代品,宏、VBA、复杂的透视表这些它不覆盖;它也不是一个开箱即用的 SaaS 产品,你需要自己写代码把它集成进去。它更像是一套“表格能力中间件”,你给它一个容器,它给你一个可编辑、可扩展、可协作的表格运行时。
2. 整体架构拆解:为什么是 Canvas + Facade API + Node.js 这套组合
2.1 Canvas 渲染:为什么不用 DOM 表格
这是被问得最多的问题。HTML 里用<table>或者 div 网格不也能做表格吗,为什么 Univer 要用 Canvas?答案在“规模”和“交互”两个词上。
DOM 方案在几百个单元格时没问题,但一旦到几万、几十万个单元格,浏览器要维护的 DOM 节点数量会直接压垮渲染性能。每个单元格是一个节点,滚动时浏览器的重排重绘成本极高。Canvas 则是一块画布,所有单元格都画在同一个位图上,节点数量恒定为 1。滚动、缩放、选区高亮这些操作,本质上只是重绘画布的一部分区域,性能曲线要平缓得多。
但 Canvas 也有代价:它没有 DOM 的事件模型。你点一个单元格,浏览器不会告诉你“你点了第 3 行第 5 列”,你得自己根据鼠标坐标反算行列号。文本选择、复制粘贴、输入法、无障碍访问这些 DOM 天然具备的能力,Canvas 全都要自己实现。Univer 的做法是在 Canvas 上层叠一个透明的 DOM 层专门处理输入和事件,画布负责“画”,DOM 负责“收事件”。这个设计思路很关键,后面讲 Facade API 时会再提到。
2.2 Facade API:把复杂内核包成一层好用的壳
Univer 的内核其实相当复杂,有渲染引擎、公式引擎、协同层、插件系统。如果让业务开发者直接操作内核对象,学习成本会高到劝退。Facade API 就是在这个背景下出现的——它是一层门面(Facade 模式),把常用的操作封装成直观的方法。
举个最直接的对比。你要往 A1 单元格写一个值,内核层面可能涉及命令的构造、命令的派发、撤销栈的记录、协同层的广播。而 Facade API 里就是一行:
const sheet = univerAPI.getActiveWorkbook().getActiveSheet(); sheet.getRange('A1').setValue('hello');这层封装的价值在于:它把“命令式”的内核操作,变成了“声明式”的业务调用。你不需要知道命令怎么派发,只需要告诉它“我要把 A1 设成 hello”。同时 Facade API 还统一了同步和异步的边界,很多看起来是同步的调用,内部其实是异步命令,Facade 帮你处理了 Promise 的编排。
2.3 Node.js 在链路里的角色
很多人以为 Univer 是纯前端的东西,Node.js 只是用来跑构建工具。这个理解只对了一半。Node.js 在 Univer 生态里至少有三个角色。
第一是构建与开发环境。Univer 的源码是 TypeScript,包管理、打包、本地开发服务器都跑在 Node.js 上。你npm install装依赖、npm run dev起本地服务,这些都离不开 Node.js。第二是服务端协同。Univer 的协同编辑需要一个服务端来中转和持久化操作,官方提供的协同服务就是 Node.js 写的。第三是服务端渲染与导出。如果你要在服务端把表格导出成图片或者 PDF,需要在 Node.js 环境里跑 Canvas 的渲染逻辑。
所以“Univer + Node.js”不是随便凑的关键词,而是真实存在的技术依赖。你如果 Node.js 版本太老,装依赖时就会遇到各种奇怪的报错,这个后面会专门讲。
3. 环境搭建:Node.js 版本选择和依赖安装的实操细节
3.1 Node.js 版本到底选哪个
这是新手最容易翻车的地方。Univer 的依赖树里有一些包对 Node.js 版本有硬性要求,版本太低会直接编译失败。我实测下来,Node.js 18.20.4 LTS 和 20.x LTS 都是稳的,22.x 也能跑但个别依赖会有警告。不建议用 16.x 及以下,也不建议用奇数版本(比如 21.x),因为奇数版本不是 LTS,生态兼容性差。
怎么确认自己装的是哪个版本:
node -v npm -v如果输出是v18.20.4这种就对了。如果你机器上有多个版本,建议用 nvm 管理,切换起来方便。Windows 用户如果不想折腾 nvm,直接去官网下 LTS 版本的安装包,一路下一步就行,安装时记得勾选“Add to PATH”。
提示:安装完 Node.js 后一定要重开一个终端窗口,否则 PATH 可能没刷新,
node -v会提示找不到命令。
3.2 创建项目并安装 Univer 依赖
我习惯用 Vite 起一个干净的 TypeScript 项目,因为 Univer 本身是 TS 写的,类型提示能省很多事。
npm create vite@latest univer-demo -- --template vanilla-ts cd univer-demo npm install然后装 Univer 的核心包。Univer 是拆成多个包发布的,核心包、预设包、协同包是分开的。最小可用集合是核心加一个预设:
npm install @univerjs/core @univerjs/presets @univerjs/preset-sheets-core这里有个细节:@univerjs/presets是聚合包,@univerjs/preset-sheets-core是电子表格的核心预设。如果你还要公式、协同、导出,得再装对应的 preset。不要一次性把所有包都装上,按需装,否则打包体积会很难看。
3.3 一个最小可运行的表格实例
装完依赖后,写一个最简单的入口。核心逻辑是:创建一个 Univer 实例,挂到一个 div 上,然后拿到 Facade API 往里面写数据。
import { createUniver, LocaleType, merge } from '@univerjs/presets'; import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core'; import '@univerjs/preset-sheets-core/lib/index.css'; const { univerAPI } = createUniver({ locale: LocaleType.ZH_CN, presets: [ UniverSheetsCorePreset({ container: 'app', }), ], }); univerAPI.createWorkbook({ sheets: { sheet1: { id: 'sheet1', name: '第一个表', cellData: { 0: { 0: { v: '姓名' }, 1: { v: '分数' } }, 1: { 0: { v: '张三' }, 1: { v: 92 } }, 2: { 0: { v: '李四' }, 1: { v: 88 } }, }, }, }, });这段代码跑起来,页面上就会出现一个可编辑的表格,A1 是“姓名”,B1 是“分数”,下面两行是数据。cellData的结构是行号 -> 列号 -> 单元格对象,行列号从 0 开始。这个结构看起来有点绕,但它是 Univer 内部数据模型的原貌,Facade API 的很多方法最终都会落到这个结构上。
注意:
container对应的 div 必须提前在 HTML 里存在,而且要有明确的宽高。如果 div 高度是 0,表格会渲染不出来,这个坑我踩过,排查了半天以为是渲染引擎的问题,结果是 CSS 没给高度。
4. Facade API 深入:从单元格操作到公式与选区
4.1 单元格读写与批量操作
Facade API 里最常用的对象是FRange,它代表一个区域。你可以通过getRange拿到它,然后做读写。
const sheet = univerAPI.getActiveWorkbook().getActiveSheet(); // 单个单元格 sheet.getRange('A1').setValue('标题'); // 区域批量写 sheet.getRange('A2:B4').setValues([ ['张三', 92], ['李四', 88], ['王五', 95], ]); // 读取 const values = sheet.getRange('A2:B4').getValues(); console.log(values); // [['张三', 92], ['李四', 88], ['王五', 95]]setValues接收的是二维数组,行优先。这里有个容易搞混的地方:setValue是单数,写一个值;setValues是复数,写一个二维数组。如果你把二维数组传给setValue,它不会报错,但结果可能不是你想要的。
批量写比逐个写快很多,因为每次写操作都会触发一次渲染调度。逐个写 1000 个单元格会触发 1000 次调度,批量写只触发一次。这个差异在数据量大的时候非常明显。
4.2 公式的写入与计算
Univer 内置了公式引擎,写入公式和写普通值的方式一样,只是值以=开头。
sheet.getRange('C1').setValue('=SUM(B2:B4)'); sheet.getRange('C2').setValue('=AVERAGE(B2:B4)');公式写入后,Univer 会自动计算并显示结果。但要注意,公式的计算是异步的,如果你在写入后立刻读取getValue(),可能拿到的是公式字符串而不是计算结果。正确的做法是监听计算完成事件,或者用getCell拿到单元格对象后读它的计算值。
// 不推荐:可能拿到公式字符串 const v = sheet.getRange('C1').getValue(); // 推荐:等公式计算完成 univerAPI.getActiveWorkbook().onCommandExecuted((command) => { // 命令执行完后再读 });公式引擎支持大部分常用函数:SUM、AVERAGE、COUNT、IF、VLOOKUP、INDEX、MATCH 这些都有。但一些 Excel 特有的、依赖外部数据的函数(比如 WEBSERVICE)不支持,这个要有预期。
4.3 选区与事件监听
选区是表格交互的核心。Facade API 提供了获取当前选区、监听选区变化的能力。
// 获取当前选区 const selection = sheet.getSelection(); const range = selection.getActiveRange(); console.log(range.getA1Notation()); // 比如 "A1:B4" // 监听选区变化 univerAPI.getActiveWorkbook().onSelectionChanged((selection) => { const range = selection.getActiveRange(); console.log('当前选中:', range.getA1Notation()); });选区事件在做什么用?比如你想做一个“选中区域后显示统计信息”的功能,或者“选中区域后弹出操作菜单”,都依赖这个事件。我做过一个需求是选中一列数字后实时显示求和,就是用onSelectionChanged拿到区域,再用getValues读值求和。
实操心得:
onSelectionChanged触发非常频繁,用户拖动选区时会连续触发。如果你在回调里做重计算,一定要加防抖,否则会卡。我一般用 100ms 的防抖,体验和性能都能兼顾。
5. Canvas 渲染层:性能优化的几个关键点
5.1 渲染分层与脏矩形
Univer 的 Canvas 渲染不是每次操作都全量重绘,而是用了脏矩形(dirty rectangle)机制。只有发生变化的区域会被重绘,其他区域保持不动。这个机制是表格能流畅滚动的关键。
理解这一点对排查渲染问题很重要。如果你发现某个单元格更新后没刷新,很可能是它没有被标记为“脏”。这种情况通常出现在你直接改了内部数据模型而没走命令派发的时候。永远通过 Facade API 或命令来改数据,不要直接改内部对象,否则渲染层不知道要重绘。
5.2 大数据量下的滚动性能
我做过一个压力测试:10 万行、20 列的表格,用 Univer 渲染,滚动基本流畅。但如果单元格里有大量富文本或者复杂样式,帧率会下降。优化方向有几个。
第一,减少样式数量。每个单元格的样式对象如果都不一样,渲染时要处理的样式组合会爆炸。能复用样式就复用。第二,避免在单元格里塞超长文本。Canvas 绘制长文本的成本比短文本高很多,而且换行计算也耗时。第三,如果不需要编辑,考虑用只读模式,只读模式下渲染层可以跳过很多交互相关的计算。
5.3 导出图片时的 Canvas 处理
Univer 支持把表格导出成图片,这个功能在服务端跑的时候依赖 Node.js 的 Canvas 实现。这里有个坑:浏览器里的 Canvas 和 Node.js 里的 Canvas 不是同一个东西,字体渲染会有差异。如果你在浏览器里预览正常,导出到服务端生成的图片字体不对,大概率是服务端没有装对应的字体。
解决办法是在服务端环境里安装表格里用到的字体,或者导出时指定字体回退链。这个坑比较隐蔽,因为本地开发时往往不会触发,一上生产就暴露。
6. 协同编辑与服务端配合的实操要点
6.1 协同的基本原理
Univer 的协同基于操作变换(OT)或者 CRDT 的思路,把每个用户的编辑操作变成一个可合并、可排序的命令,通过服务端广播给其他客户端。客户端收到远端命令后,在本地重放,从而保持状态一致。
这个机制对使用者的影响是:你不能假设“我改了数据,数据立刻就是我改的值”。在协同场景下,你的修改可能和别人的修改冲突,最终结果由合并算法决定。所以协同场景下,读数据要读“当前状态”,而不是“我写入的值”。
6.2 服务端需要做什么
官方提供的协同服务是 Node.js 写的,核心职责是:接收客户端发来的操作命令、持久化、广播给同一文档的其他客户端。如果你要自己实现,至少要处理三件事:连接管理(谁在编辑哪个文档)、命令排序(保证所有客户端看到的顺序一致)、断线重连(用户网络抖动后要能同步到最新状态)。
我建议初期直接用官方提供的协同服务,等业务稳定了再考虑自研。自研协同的复杂度很高,光是冲突处理就能吃掉大量时间。
6.3 协同场景下的常见问题
最常见的问题是“我改了但别人看不到”。排查顺序是:先确认命令有没有发出去(看网络面板),再确认服务端有没有收到(看服务端日志),最后确认其他客户端有没有收到广播。这三步能定位 90% 的协同问题。
另一个问题是“两个人同时改一个单元格,结果不对”。这是正常的冲突,最终结果取决于合并策略。如果你的业务对冲突敏感,需要在 UI 上做提示,比如“该单元格正在被他人编辑”。
7. 常见问题与排查速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 表格渲染不出来,白屏 | 容器 div 没有宽高 | 检查 CSS,给容器明确的高度 |
| 安装依赖时报编译错误 | Node.js 版本过低 | 升级到 18.20.4 或 20.x LTS |
| 写入数据后不刷新 | 直接改了内部模型,没走命令 | 改用 Facade API 写入 |
| 公式读出来是字符串 | 公式计算是异步的 | 等计算完成事件后再读 |
| 选区变化回调卡顿 | 回调触发太频繁 | 加防抖,100ms 左右 |
| 导出图片字体不对 | 服务端缺字体 | 服务端安装对应字体 |
| 协同编辑不同步 | 命令没发出去或没广播 | 按网络、服务端、客户端三步排查 |
| 大数据量滚动卡 | 样式太多或文本太长 | 复用样式,精简单元格内容 |
避坑技巧:Univer 的包版本要统一。如果你装了
@univerjs/core的 0.1.x 和@univerjs/presets的 0.2.x,可能会出现类型不匹配或者运行时错误。安装时尽量用同一个版本号,或者直接用@univerjs/presets里带的依赖,不要单独指定核心包版本。
8. 我在实际项目里踩过的几个坑
第一个坑是容器尺寸。前面提过,但值得再强调。Univer 初始化时会读取容器的宽高来决定画布尺寸。如果容器在初始化时高度是 0(比如放在一个还没展开的折叠面板里),画布尺寸就是 0,后面即使容器展开了,画布也不会自动调整。解决办法是监听容器尺寸变化,手动调用resize,或者确保初始化时容器已经可见。
第二个坑是样式污染。Univer 的 CSS 是全局注入的,如果你的项目里也有表格相关的全局样式,可能会互相影响。我遇到过一次,项目里的td样式把 Univer 的某些 UI 元素搞乱了。解决办法是把 Univer 挂在一个独立的容器里,用 CSS 作用域隔离,或者检查全局样式里有没有过于宽泛的选择器。
第三个坑是内存泄漏。Univer 实例如果反复创建销毁,不调用销毁方法,会残留事件监听和 Canvas 上下文。在单页应用里切换路由时尤其要注意,离开页面时一定要调univer.dispose()。
第四个坑是公式循环引用。如果 A1 的公式引用了 B1,B1 又引用了 A1,Univer 会检测到循环引用并报错。这个报错信息有时候不够直观,排查时要顺着公式依赖链找。
9. 后续可以扩展的方向
Univer 的插件体系是它比较有想象力的地方。你可以写自定义插件,往表格里加自定义的工具栏按钮、自定义的单元格渲染器、自定义的命令。比如我做过一个插件,在单元格里渲染进度条,就是通过自定义渲染器实现的。
另一个方向是服务端能力。Univer 的公式引擎理论上可以在 Node.js 里独立跑,这意味着你可以在服务端做批量计算、数据校验、报表生成。这个方向我还在探索,目前的做法是把表格数据同步到服务端,用 Node.js 跑一遍公式校验,再把结果推回前端。
如果你要做的是数据填报系统,还可以考虑把 Univer 和表单校验结合,在单元格级别做数据校验,比如“这一列必须是数字”“这一列不能为空”。Univer 本身有数据验证的能力,但需要自己配置规则。
最后分享一个小技巧:Univer 的 Facade API 文档虽然全,但有些方法的参数说明不够细。遇到不确定的方法,直接在浏览器控制台里把对象打印出来,看它的原型链上有哪些方法,比翻文档快。我很多用法都是这么试出来的。