ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Univer开源在线表格实战:从Canvas渲染到私有化部署的完整指南

Univer开源在线表格实战:从Canvas渲染到私有化部署的完整指南

第一次注意到 Univer,是在一个很现实的场景里:我们要给内部 ERP 系统加一个数据预览模块,老板要求“像 Excel 一样、但必须在浏览器里用”,最好还能直接嵌进现有页面。一开始我们想过直接用表格组件硬渲染,但拖到十万行数据就卡得没法看,后来无意间翻到 Univer 这个开源项目。一句话总结,它把在线表格、在线文档、幻灯片合成了一套可私有化部署的方案,核心用 Canvas 渲染,性能比传统 DOM 表格强了一个量级。现在大家搜 “univer 在线”,基本都会顺着官方 Demo 找到这个项目。

这篇文章我不打算写成官方文档的复述,而是按我手把手搭过一套内部数据报表系统的经验来聊:它到底解决了什么问题、核心架构为什么这样设计、怎么从零跑起来、实战中会踩哪些坑、以及二次开发时怎么改得动它。无论你是前端负责人还是后端想自建一个在线表格模块,都可以把它当作一份实操笔记来参考。

1. Univer 是什么,它在解决什么问题

1.1 团队自己搞在线表格的痛点

我们最开始其实没想引入一个重型的在线表格框架,只是需要把数据库里的查询结果展示在网页上。需求到了后期就开始变味:要支持单元格合并、公式求和、冻结行列、导入 Excel、多人同时编辑不冲突。这些需求叠加起来,自己开发的工作量已经超出了“一个报表模块”的范围。

市面上的商业在线表格产品确实做得不错,但问题在于它们基本是 SaaS 形态,数据必须经过它们的云端。对很多企业内部系统来说,数据合规和私有化部署是硬门槛,不可能为了一个表格功能把核心数据托管到第三方。而传统的纯前端表格库,比如我之前用过的几种,它们要么偏重数据展示、缺少完整公式和协同能力,要么授权协议对商用并不友好。

Univer 的出现刚好卡在这个点上:它是开源的,你可以自己部署;源码在 GitHub 上完整可看,核心代码用 TypeScript 编写;它不只是“Excel 的网页版外壳”,而是一套以工作表为核心、同时支持文档和幻灯片的办公基础设施。对于“要在自己系统里塞进一个像样的在线表格”这件事,它几乎是这两年最顺手的起点。

1.2 与自研方案、商业产品相比的取舍

我做一个简单的对比表,帮你快速判断它在你场景里适不适合:

对比维度自研 DOM 表格商业在线表格Univer
开发成本高,公式、协同、渲染全得自己造低,开箱即用中,需要一定学习成本
数据归属完全自主通常经过厂商云端完全自主,可私有化
二次开发能力完全可控基本不可控插件架构,核心可扩展
在线协同极难实现现成有协同协议,可自行集成
渲染性能万行级别易卡顿商业优化成熟Canvas 渲染,十万行级别可用
授权成本无按席位/按年付费Apache-2.0 开源,商业友好

这个表不是让大家无脑选 Univer。如果你只是给运营部门做一个每周数据看板,数据量不超过几千行、没有复杂公式、也不需要私有化,那用成熟的商业产品或者简单的自研表格反而是更省事的选择。但一旦你遇到“私有化部署 + 高数据量 + 公式协作 + 深度定制”这几个关键词的组合,Univer 的优势就非常明显了。

1.3 我的选型判断

我个人选型有一条很朴素的判断标准:遇到一个开源项目,先看它“能不能拆”“拆了之后能不能装回来”。Univer 的架构天然支持这种方式,它把渲染、公式引擎、命令系统、协同层全部解耦,这意味着你不会被一个黑盒框架绑架。就算未来它不再维护,你也可以基于它的核心设计继续维护自己的分支。

我们最终确定用 Univer 还有一个非技术因素:它背后是一整个围绕办公套件的设计理念,而不是一个“表格组件”。这意味着未来的需求如果延伸到在线文档批注、幻灯片预览,我们在同一条技术栈里就能延伸,而不必再建一套系统。对需要长期迭代的团队来说,这种架构上的连续性,比短期功能多一个少一个更重要。

2. 核心架构:Canvas 渲染、命令系统和公式引擎

2.1 为什么表格要用 Canvas 而不是 DOM

很多人第一次听说 Univer 用 Canvas 渲染表格,第一反应都是“这不是把简单问题复杂化了吗”。但实际上,当你面对的不是几百行数据,而是几十万行、上百列的工作表时,DOM 方案会先撑不住。一个单元格就是一个 DOM 节点的话,渲染十万个节点足以让浏览器直接进入卡顿状态,更不用说还要处理滚动、选区、框线这些交互重绘。

Univer 的渲染思路更像是游戏引擎的做法:把可视区域裁剪出来,只绘制你当前能看到的那部分单元格。Canvas 就是那块大画布,每次滚动时重新绘制可视区域内的格子,没进入视野的单元格根本不会被创建。你可以想象成烤串,DOM 方案是把十万根串同时架在烤炉上,Canvas 是只把眼前这一排放上去烤,转盘转过去才轮到下一排。这也是为什么它能在数据量大的场景里保持流畅滚动。

有一个细节容易被忽略:纯 Canvas 方案在做输入框、输入法组合文字、屏幕阅读器支持时会有障碍。Univer 的做法是采用 Canvas 加 DOM 双引擎,单元格主体由 Canvas 绘制,但当用户双击进入编辑状态时,会挂载一个文本编辑层承载输入。这种“画出来的表格、真实 DOM 的输入框”混合设计,既能保障性能,又满足了细节交互,是它比较聪明的地方。

2.2 命令系统:撤销、重做和协同的基础

我第一次阅读 Univer 源码时最受启发的,是它的命令系统分层设计。在 Excel 里随便做一次“将 A1 单元格改成 100”的操作,Univer 会拆成两层:

  • Command:表达用户意图,也就是“请把 A1 设置为 100”。
  • Operation:表达真实数据变更,也就是“A1 现在的值已经从 10 变成了 100”。

Command 可以被校验、拦截、取消;只有通过了校验,才会真正落成 Operation。当你想做撤销重做时,不需要去扫描全表比对数据,只需要把最近的 Operation 按逆序回滚,或者按正序重放。这正是撤销机制能稳定工作的前提。

这个分层在协同场景下更值钱。多人同时编辑时,只需要把 Operation 广播给其他客户端,每个客户端在自己的文档副本上应用变更,再配合一套冲突解决策略来合并并发操作,就能实现多人协作。让我用一个生活化类比来解释:Command 是你对服务员说“我要一份不加葱的牛肉面”,Operation 是后厨最终改动的订单记录。只有根据最终改动记录来判断面里到底有没有葱,才能保证回退时准确。Univer 把这种逐层传递的机制抽象得很干净,你在做二次开发时,只需要关注自己关心的操作层,不必把整张表推倒重算。

2.3 公式引擎与 Web Worker

Univer 的公式引擎是我觉得整个项目含金量最高的部分。它支持一大批常用的 Excel 函数,并且把公式计算从主线程剥离出来,跑在 Web Worker 中。这意味着当用户执行一个昂贵的重算任务时,表格界面不会像那些把所有计算都压在主线程里的“半吊子方案”一样卡住。

公式之间通常有依赖关系,比如 C1 的公式是“A1 加 B1”,如果 A1 变化了,C1 必须重新计算。Univer 内部用依赖图维护公式关系,它不会在图里多算任何一个不相干的格子。这个设计让我在接入时省了很多事,因为不需要自己去实现“哪些单元格需要联动刷新”这个细节。

让我特别注意的是,公式引擎被设计为可以脱离表格 UI 单独使用。这一点对我们后来做后端报表服务帮助非常大,后面我专门讲一下。

3. 实操:从零搭建一个可用的在线表格

3.1 初始化工程与依赖安装

Univer 的官方文档提供了非常标准的集成方式,不过实际操练时,我会建议先用一个干净的最小工程来跑通,而不是直接往现有业务代码里塞,免得被你项目里各种历史包袱干扰排查。

我用 Vite 加 TypeScript 模板做了初始化:

npm create vite@latest univer-demo -- --template vanilla-ts cd univer-demo npm install npm install @univerjs/presets @univerjs/presets-sheet

这里要提醒一点:Univer 的 API 在版本迭代中变化不算小,早期版本使用registerPlugin的形式,现在的版本更倾向于通过预设(preset)和应用配方(recipe)来组合能力。你实际安装的版本如果是较新的,请以它自带的 TypeScript 类型和官方文档为准,我下面写的是通用结构的实践路径。

3.2 创建一个最简表格实例

把src/main.ts里的内容清空,写入最基础的创建逻辑:

import { Univer, LocaleType } from '@univerjs/presets'; import { UniverSheetRecipe } from '@univerjs/presets-sheet'; const univer = new Univer({ locale: LocaleType.ZH_CN, recipes: [UniverSheetRecipe()], }); univer.createSheet(document.getElementById('app') as HTMLElement, { name: '测试报表', });

这段代码做了三件事:创建 Univer 核心实例、设定中文语言、把一张新的工作表挂载到#app节点上。如果你跑起来看到一个灰色画布,却没有表头、工具栏、公式栏,说明还在初始阶段,需要继续接上预设里提供的界面组件配方。

在官方预设里,通常会提供类似UniverToolbarRecipe、UniverFormulaBarRecipe、UniverStatusBarRecipe这样的配方,你可以在recipes数组里按需追加。我这里以“加上最常用的顶栏工具栏和底部状态栏”为例:

import { Univer, LocaleType } from '@univerjs/presets'; import { UniverSheetRecipe, UniverToolbarRecipe, UniverStatusBarRecipe, UniverFormulaBarRecipe, } from '@univerjs/presets-sheet'; const univer = new Univer({ locale: LocaleType.ZH_CN, recipes: [ UniverSheetRecipe(), UniverToolbarRecipe(), UniverStatusBarRecipe(), UniverFormulaBarRecipe(), ], });

这时候页面就应该有和成熟在线表格很像的布局了:顶上是格式化、字体、合并单元格等按钮,中间是表格画布,底部是工作表切换标签。

3.3 填充数据与设置单元格格式

实例创建之后,我们通常需要把业务数据往表里塞。这一步如果用 UI 手动点击来操作,显然不现实。我们需要拿到工作表的实例,然后用脚本批量写入数据。

示例:创建一个名为“销售明细”的工作表,然后往 A1 到 J10 的区域写入模拟数据。

const sheet = univer.createSheet(container, { name: '销售明细', }); for (let row = 1; row <= 10; row++) { for (let col = 1; col <= 10; col++) { const cell = sheet.getCell(row, col); if (cell) { cell.setValue(`R${row} C${col}`); } } }

设置单元格格式的逻辑也类似。想让 A1 变成黄色背景、加粗,就可以通过操作对应单元格的样式接口来修改。这类操作在日常业务代码里会被封装成“数据导入器”或者“报表生成器”,把后端返回的 JSON 转成表格数据。

3.4 导入导出与常用功能

真正落地时,导入导出几乎是一定要做的。Univer 的预设体系里支持打开 Excel 文件,也会提供把表格内容导出成 Excel 文件的能力。在内部系统的使用场景下,我通常建议在前端提供两个按钮:一个是“导入数据”,让用户上传本地的 xlsx 文件并在页面中打开;另一个是“导出快照”,把当前表格内容下载为 xlsx,方便用户拿去做后续汇报。

由于底层解析 Excel 文件的逻辑复杂,这里我不打算手写解析代码,直接用官方预设提供的 IO 能力即可。要注意的是,导入较大的 Excel 文件时会经过文件解析、数据结构转换、渲染准备等多个环节,用户会感到明显的加载时间,所以页面需要增加 loading 状态,避免用户以为页面卡死了。

4. 实战中遇到的坑与排查方法

4.1 白屏和样式缺失

我见过最多的问题,是“表格画布出来了,但所有文字和按钮都不显示”或者直接白屏。绝大多数情况是样式没有引入。Univer 内部主题、设计系统、组件样式的 CSS 文件是独立的,你得在入口文件里把相关的 CSS 导入进来。

解决办法:查看官方 demo 的入口文件里 import 了哪些样式,或者在你的入口文件直接引入@univerjs/design等包的样式文件。这个过程不算复杂,但很容易被忽略,因为你不会看到任何报错,只是页面渲染不出来。

还有一个容易踩的坑是包版本不一致。Univer 拆成多个 npm 包,比如核心包、渲染包、表格预设包。如果它们之间的大版本号不一致,运行时往往会出现莫名其妙的不兼容问题。请务必把它们保持在同一个主版本下,不要一个升到最新,一个还停在旧版本。我自己曾因为仅升级了核心包而没有同步升级预设包,结果工具栏按钮全部失效,排查了很久才发现是版本错配。

4.2 与 React、Vue 集成时的生命周期问题

Univer 是一个独立管理自身生命周期的库,它会在容器里挂载自己的 UI。如果你在 React 里使用,最常见的坑是useEffect重复执行。React 的 StrictMode 在开发环境下会故意执行两次 effect,这就导致 Univer 在一个已经挂载过的容器上再次执行初始化,从而报错或者产生重复实例。

解决办法是在 effect 的清理函数里调用对应的销毁方法,把实例安全释放掉。比如:

useEffect(() => { const univer = createUniver(containerRef.current!); return () => { univer.dispose(); }; }, []);

这个原则同样适用于 Vue 的onUnmounted生命周期。框架集成不是把createSheet放在模板里就算完,你必须把 Univer 的创建和销毁纳入到框架的生命周期管控里,否则在路由切换或者组件重新渲染时,内存会不断堆积。

4.3 大数据量下的性能优化

虽然 Univer 的 Canvas 渲染已经比 DOM 方案强很多,但不代表你可以把几百万个单元格一次性全部写入。我们在一次压测里塞入了 50 万行数据,结果初始化时间明显变长,滚动也出现了卡顿。后来定位原因,是因为我们通过脚本逐单元格写入,触发了过多的操作记录和界面刷新。

优化思路是分批写入,并且关闭不必要的实时重算。如果是一次性导入历史数据,尽量在 Worker 里完成数据准备,然后按区块一次性生成到表格中;如果表格里没有公式依赖,还可以暂时关闭公式链路的通知,等数据全部就位后再统一重算。另一个实用技巧是启用冻结窗格,让用户操作时表头始终可见,这虽然不能提升数据写入性能,但能让大表格的浏览体验舒服很多。

4.4 协同与导入导出的坑

如果你要开启协同编辑,需要注意的不只是前端集成了协同插件,还需要后端提供配套的文档 ID、变更广播和冲突合并服务。很多团队拿到前端 Demo 后以为协同也是开箱即用,结果发现还要自己维护服务端逻辑。所以计划里要留出后端协作服务的开发和运维预算,不要把它当成纯前端功能。

导入导出方面,最常见的是大文件的解析内存问题。一个几十 MB 的 Excel 文件,在浏览器里解析可能会占用几百 MB 内存。如果你们的业务场景经常出现超大文件,建议在后端完成解析而不是让浏览器硬扛。

5. 二次开发:写一个自定义插件

5.1 理解插件的注册机制

Univer 的扩展性和它的命令系统是一体两面的。你可以注册新的菜单项、监听单元格选中事件、定义新的命令,甚至把整套界面替换成适合自己业务的模样。这种设计意味着你不会被困在框架默认的形态里。

初次接触时,我建议先不要急着看复杂的插件 API,而是从“监听事件”和“注册菜单”这两个最简单的入口开始。先跑通“能在页面上放一个自己的按钮,点击后对当前选中区域做操作”这个小目标,就足以理解它整个扩展模型的运作方式。

5.2 一个记录单元格点击的小插件

我做一个简单的示例:在表格右键菜单里添加一个条目,点击后弹窗显示当前选中区域的地址。核心思路是注册一个命令,并把命令绑定到菜单项上。

这里我不打算把 API 写死,因为版本差异比较大,但逻辑路径是稳定的:

import { UniverSheetCommandType, IMenuService } from '@univerjs/presets-sheet'; // 1. 注册一个命令 class ShowSelectionCommand { // 命令执行时拿到当前选区,弹出提示 } // 2. 把命令关联到上下文菜单 menuService.addMenuItem({ id: ShowSelectionCommand.id, title: '显示当前选区', // 其他菜单属性 });

这样做的好处是业务扩展不会污染核心数据。你的业务命令可以读表格数据,也可以修改表格数据,但所有变化仍然发生在 Univer 的命令体系之内,撤销、重做、协同依然有效。这比某些框架提供的“外部直接改 DOM 节点”式扩展要安全得多。

5.3 把公式引擎单独抽出来用

这是我们实际项目里收益最大的一部分。因为内部系统的很多报表并不是实时在线编辑,而是由后端定时任务生成数据,再用表格接口返回给前端展示。我们当时想:能不能让后端直接调用 Univer 的公式引擎来批量计算带公式的报表数据?答案是完全可以。

在 Node 环境里,我们可以只引入公式引擎核心包,注册必要的函数,分服务中预定义一批公式模板,对数据源进行同样的计算。这样前端展示的表格,和后端跑批算出来的结果,用的就是同一套公式逻辑,彻底避免了两边公式规则不一致的问题。

这个能力的大意是:把在线表格的“计算脑”和“展示壳”分离。很多项目可能只把 Univer 当做一个表格控件,但深入看你会发现,公式引擎本身就可以作为一个可独立部署的计算服务来使用,这是它相比普通前端表格组件的巨大优势。

6. 选型、版本与长期维护

6.1 开源协议与能力边界

Univer 对外采用 Apache-2.0 开源协议,这意味着你可以自由使用、修改、分发,甚至用于商用项目。但开源不等于所有高级能力都完全免费,部分更偏企业级的插件能力,比如一些高级协同服务、专属运维工具,可能会以商业化的插件形式提供。所以团队在选型时要留一个心眼:基础能力是开源的,但如果你后续需要某些很专业的增值功能,需要提前确认授权边界和采购预算。

这个其实很正常,核心开源加增值服务是很多优秀项目的常见模式。关键是在项目启动前就和版权方或官方渠道确认清楚,不要到项目上线之后才发现某些能力在授权上受限。

6.2 版本升级要留的几条后路

Univer 目前仍处于快速迭代期,API 变化比较频繁。我今天写的这段代码,放到半年后可能就需要调整。团队如果决定深入使用,务必做好版本管理:

  • 锁定主版本,不要随便执行npm update把所有依赖都升上去。
  • 升级前先跑一遍官方示例项目,对照 changelog 看有哪些破坏性变更。
  • 每次可用版本发布时,用 git tag 在你们仓库里打一个基线,出现问题可以随时回退。
  • 最关键的是,不要在业务代码里到处散落 Univer 的 API 调用,要封装一个内部组件层,把框架 API 隔离起来。

我们团队的做法是:所有 Univer 能力都封装在一个内部表格组件中,业务方只需要传入数据源和配置项,不需要接触 Univer 的 API。这样 Univer 版本的升级影响面就控制在一个组件内部,而不是全站修改。

6.3 长期维护时我的一些体会

如果让我给准备投入使用的团队一个最终建议,我会说:把 Univer 当作一个“可嵌入的操作系统”,而不是一个“表格插件”。用这种心态去设计你的接入层,你会更注重数据如何流转、命令如何拆分、协作如何与业务系统打通,而不是只关心某个功能有没有。架构上想清楚,后续的长期维护会轻松很多。

还有一个小细节,在部署到生产环境时,记得把 Univer 构建产物放到独立的静态资源路径下,并配置好 CDN 缓存。它的包体积并不小,如果每次都从应用服务器拉取资源,首屏加载时间会有明显影响。这也是我们实测下来最值得优化的一个点。

最后再分享一个小经验:刚开始接触 Univer 时,先别急着堆功能,把官方 Demo 跑一遍,再用这里的方法搭一个最小版本,最后把你自己的业务数据填进来。这个过程走完,你对它到底适不适合自己团队,心里基本就有数了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进