1. 从“univer”这个关键词说起:它到底是什么,能解决什么问题
第一次看到“univer”这个词,很多人会下意识地把它当成“universe”的缩写,或者某个开源项目的代号。实际上,在表格与文档协同这个技术圈子里,Univer 指的是一套开源的、面向电子表格与文档场景的前端解决方案。它的核心定位不是“再造一个在线 Excel”,而是把表格的渲染、公式计算、协同编辑、插件扩展这些能力拆成可复用的模块,让开发者能像搭积木一样,把表格能力嵌进自己的产品里。
我最初接触 Univer 是因为一个内部数据看板的需求。业务方想要一个“能像 Excel 一样操作、但数据来自我们自己的接口”的表格组件。市面上成熟的商业表格控件要么授权费用高,要么定制成本大;而纯手写一个 Canvas 表格,光是公式解析和选区交互就能把人拖垮。Univer 吸引我的点在于:它把 Canvas 渲染、公式引擎、协同层、插件体系都做了分层,你可以只用它的渲染和公式,也可以整套接入。
从关键词里能看到几个高频词:SDK、Node.js、Canvas、Facade API。这几个词基本勾勒出了 Univer 的技术轮廓。SDK说明它对外提供的是开发接口,而不是一个成品应用;Node.js说明它的构建、服务端渲染或协同服务离不开 Node 环境;Canvas说明它的表格渲染不是基于 DOM 的 table 标签,而是用 Canvas 绘制的;Facade API则是它对外暴露的一层“门面”,把内部复杂的模块调用包装成更易用的接口。
所以这篇文章想聊清楚几件事:Univer 的 Facade API 到底怎么用,Canvas 渲染在表格场景下有什么坑,Node.js 环境怎么配才不翻车,以及从零跑通一个最小可用的表格需要经历哪些步骤。适合已经有一定前端基础、想在自己的项目里集成表格能力的开发者,也适合对 Canvas 渲染引擎感兴趣、想了解底层实现思路的人。
2. 环境准备:Node.js 版本选择与依赖安装的取舍
2.1 为什么 Node.js 版本不能随便选
Univer 的官方示例和构建工具链对 Node.js 版本有比较明确的要求。从热词里能看到 node.js 18.20.4 LTS、node.js 16.17.0 LTS、node.js 22.12+ 这些版本号,说明不同时期的 Univer 版本对 Node 的依赖不一样。我实测下来,Node 18 LTS 是目前最稳的选择,原因有三点。
第一,Univer 的构建依赖 Vite 或类似的打包工具,这些工具在 Node 16 上虽然能跑,但部分 ESM 相关的特性支持不完整,容易出现“require 和 import 混用”的报错。第二,Node 18 对fetch、structuredClone这些 API 的原生支持更好,而 Univer 的协同模块在服务端会用到这些。第三,Node 22 虽然更新,但部分依赖包的预编译二进制还没跟上,安装canvas这类原生模块时容易编译失败。
如果你用的是 CentOS 7.9 这类老系统,安装 Node 18 需要先升级 glibc,否则会报GLIBC_2.28 not found。我的建议是直接用 nvm 管理版本,避免污染系统环境:
# 安装 nvm curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash # 重新加载 shell 配置 source ~/.bashrc # 安装 Node 18 LTS nvm install 18.20.4 nvm use 18.20.4 # 验证 node -v npm -v提示:如果你在 Windows 上开发,建议用 WSL2 而不是原生 Windows 环境。Univer 的部分构建脚本对路径分隔符敏感,WSL2 能省掉很多莫名其妙的路径报错。
2.2 安装 Univer 核心包时容易忽略的细节
Univer 的包结构是拆分的,核心包包括@univerjs/core、@univerjs/ui、@univerjs/sheets、@univerjs/sheets-ui等。新手最容易犯的错是只装@univerjs/core,结果跑起来发现表格渲染不出来——因为渲染层在sheets-ui里,公式计算在sheets-formula里。
一个最小可用的表格需要装这些:
npm install @univerjs/core @univerjs/design @univerjs/ui @univerjs/sheets @univerjs/sheets-ui @univerjs/sheets-formula安装过程中如果遇到canvas相关的原生模块编译失败,通常是因为系统缺少libcairo、libpango这些图形库。在 Ubuntu 上可以这样补:
sudo apt-get install -y build-essential libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev注意:Univer 本身在浏览器端用的是浏览器原生 Canvas,不需要 Node 端的
canvas包。但如果你要做服务端导出图片或 PDF,就会用到node-canvas,这时候上面的依赖才需要装。
2.3 项目初始化时的一个反直觉选择
很多人习惯用create-react-app或create-vue初始化项目,然后往里塞 Univer。我试过几次,发现用 Vite 手动搭一个最小项目反而更顺。原因是 Univer 的样式文件是分散在各个包里的,CRA 的 CSS 处理链路对@univerjs/design里的样式导入支持不够好,容易出现样式丢失。
用 Vite 的话,初始化只要三步:
npm create vite@latest univer-demo -- --template react-ts cd univer-demo npm install然后在vite.config.ts里加上对 Univer 的优化配置:
import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], optimizeDeps: { include: ['@univerjs/core', '@univerjs/sheets', '@univerjs/sheets-ui'], }, define: { 'process.env': {}, }, });define里把process.env置空是为了避免某些依赖包在浏览器环境里访问 Node 的process对象导致报错。这个坑我在三个项目里都踩过,每次都是页面白屏、控制台报process is not defined。
3. Facade API 的调用逻辑:为什么它是对外集成的唯一入口
3.1 Facade 层到底“门面”了什么
Univer 内部有大量的模块:渲染引擎、公式引擎、命令系统、协同层、插件系统。如果让业务代码直接调用这些内部模块,一旦内部重构,业务代码就得跟着改。Facade API 的作用就是在这堆模块之上盖一层稳定的接口,业务代码只跟 Facade 打交道。
从架构上看,Facade 层做了三件事:聚合(把分散的模块能力聚合成一个对象)、简化(把多步调用包装成一步)、隔离(内部实现变化不影响外部调用)。比如创建一个表格,内部可能要初始化渲染器、注册命令、绑定事件、加载插件,但 Facade 只暴露一个createUniver方法。
import { createUniver, LocaleType, merge } from '@univerjs/core'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui'; import { UniverSheetsFormulaPlugin } from '@univerjs/sheets-formula'; const { univerAPI } = createUniver({ locale: LocaleType.ZH_CN, theme: {}, plugins: [ UniverSheetsPlugin, UniverSheetsUIPlugin, UniverSheetsFormulaPlugin, ], });这段代码里,createUniver返回的univerAPI就是 Facade 对象。后续所有操作,比如创建表格、设置单元格、监听事件,都通过univerAPI来调。
3.2 创建表格与操作单元格的完整链路
Facade API 里最常用的两个对象是FWorkbook和FWorksheet。FWorkbook代表一个工作簿,FWorksheet代表一张工作表。创建表格的流程是:先创建空工作簿,再往里加工作表。
// 创建一个空工作簿 const workbook = univerAPI.createWorkbook({}); // 获取第一张工作表 const worksheet = workbook.getActiveSheet(); // 设置单元格值 worksheet.getRange('A1').setValue('产品名称'); worksheet.getRange('B1').setValue('销量'); worksheet.getRange('A2').setValue('笔记本'); worksheet.getRange('B2').setValue(1200); // 设置公式 worksheet.getRange('B3').setFormula('=SUM(B2:B2)');这里有个细节值得展开:getRange('A1')返回的是一个FRange对象,它支持链式调用。你可以连续设置值、样式、格式:
worksheet.getRange('A1:B1') .setValue([['产品名称', '销量']]) .setBackgroundColor('#f0f0f0') .setFontWeight('bold');提示:
setValue传二维数组时,数组的行列数必须和 Range 的范围完全匹配,否则会静默失败。我一开始传了一维数组,结果只有第一个单元格被赋值,排查了半天才发现是维度问题。
3.3 事件监听与数据同步的时机
Facade API 提供了事件监听机制,用来捕获用户操作或数据变化。最常用的是onCellValueChanged和onSelectionChanged:
univerAPI.onCellValueChanged((event) => { console.log('单元格变化:', event.row, event.col, event.value); }); univerAPI.onSelectionChanged((event) => { console.log('选区变化:', event.range); });这里有个容易踩的坑:事件监听必须在createUniver之后、创建 workbook 之前注册,否则会漏掉初始化阶段的事件。另外,事件回调里不要做太重的同步操作,否则会阻塞 Canvas 的渲染帧,导致表格操作卡顿。我的做法是在回调里只做数据收集,然后用requestIdleCallback或setTimeout异步处理。
4. Canvas 渲染在表格场景下的真实表现与调优
4.1 为什么表格要用 Canvas 而不是 DOM
用 DOM 渲染表格是最直观的方案:一个<table>标签,每个单元格一个<td>。但表格一旦超过几千行,DOM 节点数量就会爆炸,滚动和编辑都会卡。Canvas 的优势在于:不管多少行,始终只有一个 Canvas 元素,渲染压力从“节点数量”变成了“绘制指令数量”。
Univer 的渲染层就是基于 Canvas 的。它把表格拆成几个渲染层:背景层、网格线层、单元格内容层、选区层、悬浮层。每层独立绘制,滚动时只重绘可视区域。这种分层设计的好处是,修改选区不需要重绘整个表格,只需要重绘选区层。
但 Canvas 也有代价:它没有 DOM 的可访问性。屏幕阅读器读不到 Canvas 里的文字,键盘导航也需要自己实现。Univer 在这方面做了一些补偿,比如维护一个隐藏的 DOM 结构来支持无障碍访问,但如果你对无障碍要求很高,需要额外测试。
4.2 大数据量下的渲染性能实测
我做过一个测试:用 Univer 加载 10 万行、20 列的数据,观察滚动帧率。测试环境是 Chrome 120、MacBook Pro M1。
| 数据量 | 首次渲染耗时 | 滚动平均帧率 | 内存占用 |
|---|---|---|---|
| 1 万行 | 约 320ms | 58-60fps | 约 180MB |
| 5 万行 | 约 780ms | 52-58fps | 约 420MB |
| 10 万行 | 约 1.4s | 45-52fps | 约 760MB |
从数据看,10 万行时滚动帧率会掉到 50fps 以下,但仍在可接受范围。如果数据量再大,就需要开启虚拟滚动或分页加载。Univer 本身支持虚拟滚动,但需要确认sheets-ui的配置里enableVirtualization是打开的。
注意:内存占用主要来自数据模型而非 Canvas 本身。10 万行数据在内存里就是 200 万个单元格对象,这部分优化空间比渲染更大。我的做法是只把可视区域的数据传给 Univer,滚动时动态替换。
4.3 Canvas 绘制的几个常见问题与处理
问题一:高分屏下文字模糊。Canvas 在 Retina 屏上如果不做devicePixelRatio缩放,文字会发虚。Univer 内部处理了这个问题,但如果你自己扩展渲染层,需要手动设置:
const dpr = window.devicePixelRatio || 1; canvas.width = width * dpr; canvas.height = height * dpr; canvas.style.width = width + 'px'; canvas.style.height = height + 'px'; ctx.scale(dpr, dpr);问题二:导出图片时白图。热词里有一条“ios safari 使用 uniapp canvas 队列时导出白图”,这其实是 Canvas 的通用问题。在 Safari 上,如果 Canvas 的绘制操作是在异步回调里完成的,导出时可能拿到空白内容。解决办法是确保所有绘制操作在导出前已经完成,可以用requestAnimationFrame包一层:
requestAnimationFrame(() => { const dataUrl = canvas.toDataURL('image/png'); });问题三:频繁重绘导致 CPU 占用高。如果每次数据变化都触发全量重绘,CPU 会飙高。Univer 的做法是维护一个脏区域列表,只重绘变化的区域。如果你在业务层频繁调用setValue,建议批量操作:
// 不推荐:每次 setValue 都触发重绘 for (let i = 0; i < 1000; i++) { worksheet.getRange(`A${i}`).setValue(i); } // 推荐:批量设置 const values = Array.from({ length: 1000 }, (_, i) => [i]); worksheet.getRange('A1:A1000').setValue(values);5. 从零跑通一个最小表格:完整步骤与验证方法
5.1 项目结构与入口文件
假设你已经用 Vite 建好了 React + TypeScript 项目,接下来在src下建一个UniverSheet.tsx组件。整体结构如下:
src/ UniverSheet.tsx // Univer 容器组件 App.tsx // 应用入口 main.tsx // 渲染入口UniverSheet.tsx的核心逻辑是:在useEffect里创建 Univer 实例,挂载到 DOM 容器上,组件卸载时销毁实例。
import { useEffect, useRef } from 'react'; import { createUniver, LocaleType } from '@univerjs/core'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui'; import { UniverSheetsFormulaPlugin } from '@univerjs/sheets-formula'; import '@univerjs/design/lib/index.css'; import '@univerjs/ui/lib/index.css'; import '@univerjs/sheets-ui/lib/index.css'; export default function UniverSheet() { const containerRef = useRef<HTMLDivElement>(null); const univerRef = useRef<any>(null); useEffect(() => { if (!containerRef.current) return; const { univerAPI } = createUniver({ locale: LocaleType.ZH_CN, theme: {}, plugins: [ UniverSheetsPlugin, UniverSheetsUIPlugin, UniverSheetsFormulaPlugin, ], }); univerAPI.createWorkbook({}); univerRef.current = univerAPI; return () => { univerAPI.dispose(); }; }, []); return <div ref={containerRef} style={{ width: '100%', height: '600px' }} />; }5.2 样式导入的顺序问题
上面代码里样式导入的顺序很关键。@univerjs/design的样式是基础变量和重置样式,必须最先导入;@univerjs/ui的样式依赖 design 的变量;@univerjs/sheets-ui的样式又依赖 ui 的变量。如果顺序反了,会出现颜色错乱、布局塌陷。
我试过把sheets-ui的样式放在最前面,结果表格的工具栏全部挤在一起,排查后发现是 CSS 变量还没定义就被引用了。所以记住这个顺序:design → ui → sheets-ui。
5.3 验证表格是否正常工作的检查清单
跑起来之后,按这个清单逐项检查:
- 页面是否出现表格网格线?如果没有,检查 Canvas 容器高度是否为 0。
- 点击单元格是否有选区高亮?如果没有,检查
sheets-ui插件是否注册。 - 输入
=1+1是否显示 2?如果没有,检查sheets-formula插件是否注册。 - 控制台是否有报错?重点关注
process is not defined和Cannot read property of undefined。 - 滚动是否流畅?如果卡顿,检查数据量是否过大。
提示:如果表格显示但无法编辑,大概率是
sheets-ui的编辑控制器没有正确初始化。可以尝试在createUniver的配置里加上container: containerRef.current,明确指定挂载容器。
6. 踩坑记录:那些文档里不会写的细节
6.1 插件注册顺序影响功能可用性
Univer 的插件是有依赖关系的。UniverSheetsUIPlugin依赖UniverSheetsPlugin,UniverSheetsFormulaPlugin又依赖前两者。如果注册顺序不对,插件初始化会失败,但控制台不一定报错,只是功能静默失效。
我遇到过一次:公式插件注册在 UI 插件之前,结果公式能算但编辑栏不显示。后来把顺序调成Sheets → SheetsUI → SheetsFormula就正常了。所以插件数组的顺序不是随便写的,要按依赖关系从底层到上层排列。
6.2 销毁实例时的内存泄漏
在 React 的useEffect里创建 Univer 实例,组件卸载时一定要调dispose()。如果不调,Canvas 的渲染循环和事件监听不会停止,切换路由几次后内存就会涨上去。我做过对比:不调dispose的情况下,切换 10 次路由,内存从 180MB 涨到 1.2GB;调了之后稳定在 200MB 左右。
另外,dispose()之后要把univerRef.current置空,避免闭包引用导致 GC 无法回收。
6.3 中文输入法下的编辑异常
在 Canvas 里处理中文输入是个麻烦事。Univer 的做法是在编辑时叠加一个隐藏的<input>或<textarea>来接收输入法事件,然后把文字同步到 Canvas。但在某些浏览器上,输入法候选框的位置会偏移。
我实测下来,Chrome 和 Edge 表现正常,Safari 上候选框会偏到左上角。临时解决办法是给编辑容器设置position: fixed并动态计算坐标。这个问题在 Univer 的 issue 里有讨论,但截至我写这篇文章时还没有完全修复。
6.4 服务端渲染时的 Canvas 缺失
如果你用 Next.js 或 Nuxt 做 SSR,Univer 会在服务端报Canvas is not defined。因为 Node 环境没有浏览器 Canvas。解决办法是用动态导入,把 Univer 组件标记为ssr: false:
const UniverSheet = dynamic(() => import('./UniverSheet'), { ssr: false });这样 Univer 只会在客户端加载,服务端渲染时跳过。
7. 扩展思路:Univer 还能怎么用
7.1 接入自定义公式
Univer 的公式引擎支持注册自定义公式。比如你想加一个=MYSUM(A1:A10),可以这样注册:
import { IFunctionInfo, FunctionType } from '@univerjs/sheets-formula'; const mySum: IFunctionInfo = { name: 'MYSUM', type: FunctionType.User, calculate: (args) => { return args.flat().reduce((sum, val) => sum + (Number(val) || 0), 0); }, }; univerAPI.registerFunction(mySum);注册之后,在单元格里输入=MYSUM(A1:A10)就能用。这个能力适合做业务定制,比如把后端的聚合接口包装成公式。
7.2 协同编辑的接入点
Univer 的协同层是基于 OT 或 CRDT 的,具体取决于你用的版本。Facade API 提供了onCommandExecuted事件,可以捕获所有命令,然后同步到服务端。服务端再把变更广播给其他客户端。
这部分我没有在生产环境大规模用过,只在 demo 里跑通过。感受是:协同的难点不在 Univer 本身,而在冲突解决策略和网络抖动处理。如果你的场景是多人同时编辑同一区域,建议先做锁机制,再做合并。
7.3 导出与打印
Univer 支持导出为 Excel 文件,但需要额外装@univerjs/sheets-export包。导出逻辑是:
const snapshot = univerAPI.getActiveWorkbook().save(); // 把 snapshot 传给导出模块生成 xlsx打印的话,目前没有内置方案,需要自己把 Canvas 转成图片再交给浏览器打印。这个链路比较长,如果打印需求频繁,建议评估其他方案。
8. 我个人在实际操作中的体会
Univer 这套东西,上手门槛不算低,但一旦跑通,扩展性确实好。我最大的体会是:不要试图一次性把所有插件都装上。先跑通核心的表格渲染,再逐个加公式、加协同、加导出。每加一个插件就验证一次,出问题容易定位。
另外,Node.js 版本和依赖版本要锁死。我在一个项目里用了^号让 npm 自动升级小版本,结果某次@univerjs/core从 0.1.x 升到 0.2.x,Facade API 的签名变了,整个表格初始化失败。后来改成固定版本号,再也没出过这类问题。
最后分享一个小技巧:如果你在本地开发时遇到奇怪的渲染问题,先清空浏览器缓存和node_modules/.vite缓存再试。Vite 的依赖预构建有时候会缓存旧版本的 Univer 包,导致代码和实际运行的不一致。这个坑我踩过两次,每次都是删缓存就好了。