大概是去年下半年开始,我在前端开源榜单上频繁看到 Univer 这个名字。点进去第一眼的感觉是“它怎么长得跟 Google Sheets 这么像”,再仔细看才发现,它并不是一个单纯的表格组件,而是把电子表格、文档、演示文稿三种能力全部开源出来的办公套件底座。搜 univer 在线这样的关键词时,你会看到官方演示页和一堆 API 文档,但作为开发人员,真正把它嵌入到自己的产品里,又会遇到文档里根本没写明白的坑。这篇文章就是我基于实际搭建和业务集成的完整记录,从架构原理到最小示例再到部署避坑,希望能让你少走一点弯路。
1. Univer 是什么:一个被很多人忽略的技术底座
1.1 在开源表格赛道里,它补上了一个关键空白
先说一个很普遍的现象:当你接到“让用户在网页上像用 Excel 一样编辑表格”的需求时,能用的开源方案其实少得可怜。
传统的表格展示组件,比如那种用 div 模拟行和列的表格库,优点是轻巧,但一旦涉及单元格合并、公式、筛选、撤销重做,你就会开始怀疑人生。另一类方案是 SheetJS 这种专门解析和生成 Excel 文件的库,它很强,但它不提供完整的 UI 交互,你不是拿来当编辑器用的。还有一些商业收费组件能满足交互需求,但一套授权下来费用不低,而且后续想在交互细节上做高度定制,经常被组件本身的设计给限制住。
这中间存在一个真实的产品空白:我既要一个接近现代办公软件的编辑体验,又要源码可控、可以嵌入自己的系统,还要能支撑多人协作的扩展可能。Univer 恰好踩在这个位置上。它不是一个简单的表格库,而是一套以电子表格为核心、同时覆盖文档和演示文稿的可编程办公套件底座,底层用 TypeScript 全部重写,前端层面的能力几乎不受限于旧有框架的包袱。
我在评估时主要关注的是它的工作表(Sheet)能力,因为这是最常被要求嵌入业务系统的部分。文档和演示文稿我当时只做了功能性验证,并没有深入定制。如果你只做后台管理系统,其实大部分时间也只需要把它的表格能力用好,这就已经比其他方案强出一大截。
1.2 项目定位与核心优势
用一句话概括 Univer 的定位:它是面向开发者的智能办公套件基础设施,而不是面向终端用户的成品办公软件。这句话的含义是,你可以通过 npm 安装它,在任意页面里创建一个工作簿实例,然后像一个普通的 React 组件也好、Vue 组件也好,甚至原生 JS 容器也好,把它挂载进去。
我总结了一下它真正有分量的几个优势:
- 插件化架构:核心层只维护数据模型和事务,公式、渲染、UI、操作菜单等功能全部以插件形式挂载。你可以选择不加载工具栏,只保留数据编辑能力。
- 统一的命令分发:用户做的每一次操作,包括输入内容、删除行列、粘贴数据、合并单元格,都走统一的命令通道,这让撤销重做、操作埋点、权限拦截都变得非常可控。
- Canvas 作为主力渲染方案:表格可视区域是基于 Canvas 绘制的,数据量大的时候,渲染压力被压到很低,滚动流畅度比纯 DOM 方案有明显优势。
- 与框架无强绑定:核心不依赖任何前端框架,接入 React 或 Vue 只是换一种封装姿势。
- 可自托管:数据接口和后端逻辑都可以由自己控制,这对企业敏感数据来说非常重要。
这些优势听起来都属于“正确的废话”,但真正跑起来之后,你会感受到其中的差别。我后面会专门拿一节的篇幅讲集成过程中遇到的实际情况。尤其要注意的是,它对 Electron 桌面环境的适配也比大部分同类工具好,我后来把同一份代码跑在桌面包里,发现基础功能没有遇到任何障碍。这对做内部工具型产品的团队来说,是一个值得加分的选项。
1.3 和其他开源表格方案的横向对比
这一段写给所有需要在多个方案里做选型的朋友。我实际调研过的方案包括 SheetJS、Handsontable、AG Grid,还有自己正在维护的旧表格项目,简单做了一个横向对比。
| 方案 | 定位 | 许可 | 编辑体验 | 二次开发成本 | 适合场景 |
|---|---|---|---|---|---|
| SheetJS | 电子表格解析与生成 | Apache 2.0 + 商业授权并存 | 无 UI | 需要自己封装 | 文件导入导出、数据转换 |
| Handsontable | 数据表格组件 | 核心开源、进阶商业 | 中等 | 中等 | 数据录入、操作密集型应用 |
| AG Grid | 高性能数据网格 | MIT + 商业版 | 单元格编辑有限 | 中等 | 企业数据展示、分析 |
| Univer | 办公套件底座 | Apache 2.0 | 接近在线 Office | 偏高但有上限 | 在线编辑、协同场景、自定义界面 |
| 自研表格 | 自行实现 | 无限制 | 取决于投入 | 极高 | 高度定制且有长期投入预算 |
这个表格不能告诉你该选谁,但能帮你快速排查。如果只是要生成和解析 Excel,SheetJS 已经足够好;如果你的核心诉求是复杂数据展示,AG Grid 的生态很成熟;但如果需求是“用户要在这个页面里舒服地编辑一张包含公式、样式、多 Sheet 的工作簿”,Univer 的综合得分是最高的。
2. 技术底座拆解:分层、命令系统与渲染
2.1 核心内核与插件体系的解耦逻辑
我第一次接触 Univer 时,最大的困惑是“怎么跟以前用表格组件不一样”。后来习惯了它的设计语言,才意识到这是一套值得参考的架构范式。
Univer 在概念上分为两大阵营:内核层和功能层。内核层负责的是工作簿、工作表、单元格、行列、范围这些核心对象的建模,以及命令分发、撤销栈、序列化这类基础能力。功能层则是围绕这些模型挂载的插件,比如表格 UI、公式引擎、条件格式、图表、批注、数据校验。每一个功能都不是写死在内核里的,而是独立插件。
这种解耦在工程上的好处很明显:我不需要 UI 的时候就可以不加载 UI 插件,只需要内核加渲染引擎,体积和启动速度都能得到控制;如果我要混用多个实例,也不会因为 UI 状态互相干扰而出问题。
在实际代码里,所有插件都注册在同一个 Univer 实例上。注册时有一个细节,虽然大多数插件之间没有强依赖关系,但 UI 类插件最好放在渲染引擎之后注册,因为 UI 插件渲染时依赖底层的绘制能力。官方示例不一定每次都会强调这个顺序,但我按这个顺序跑下来,一直没遇到界面空白的问题。
2.2 命令系统:为什么所有操作都要走命令通道
对于一个表格应用来说,撤销重做是最难自己实现的部分之一。维护一套真正可靠的历史栈,需要覆盖所有操作类型,还要处理连续输入合并、范围变化、行列结构变化这些边界情况。自己写容易在迭代中崩掉。
Univer 采用了 Command Pattern 统一处理操作。修改单元格、插入行、删除列、粘贴数据、设置样式、调整列宽,全都抽象为命令,由内核统一执行并记录。这意味着你不需要为每一种操作单独建立一套监听逻辑,只要站在命令这一层,就能看到所有数据操作。
对于业务系统,这个机制还有一个让我觉得很值钱的好处:权限拦截。比如客户要求“某些列的数值只允许特定角色修改”,传统做法是监听单元格的 input 事件,再判断当前坐标是否在保护区域内,这个做法不仅繁琐,而且容易漏掉通过粘贴、填充、公式计算绕进来的路径。但在 Univer 的命令机制下,我可以在命令执行之前拦截检查,统一处理所有入口。我在实际项目里就是用这个思路做了只读列控制,代码量大减,逻辑还更清晰。
命令机制还让埋点变得非常简洁:任何操作都有唯一的命令类型,直接根据命令类型做统计,不需要通过 UI 事件反推用户行为。这对于分析用户使用习惯、记录审计日志都很友好。
2.3 Canvas 渲染与 Web Worker:流畅背后的原理
很多人在意的性能问题,其实关键在于渲染方案的选择。老式开源表格组件大多用 DOM 节点模拟单元格,比如一张 1000 行 50 列的表格,可能就创建了几万个 DOM 节点,一旦滚动、拖拽、框选,整个浏览器都在跟随你的操作进行大量布局计算,你说能不卡吗。
Univer 的 Canvas 方案,本质上更接近游戏引擎的做法:它把可视区域当成一块画布来绘制,滚动时只绘制当前视野里的内容,而不是把所有行都创建出来。我实际测试过一张上万行、几百列数据的表格,初次渲染会有一个几百毫秒的等待,但一旦完成绘制,滚动流畅度比同数据量下的 DOM 方案明显要好,这个差距在普通笔记本上就能直接体感出来。
Canvas 方案带来的副作用是,文字输入框和单元格编辑器不能再用简单的 DOM 覆盖实现,所以你会看到 Univer 在进行单元格编辑时,会在特定位置动态创建一个浮动的 HTML input。这个设计既保留了 Canvas 高效绘制的优点,又能保证输入体验符合用户习惯。
它还使用了 Web Worker 来承担部分重型计算,主线程负责交互和 UI,Worker 里处理数据运算。这意味着大量公式计算不会把界面卡成白屏。关于其中可能遇到的问题,我在第 4 节的排查部分还会继续展开。
3. 实操:从零搭建一个可用的 Univer 实例
3.1 环境准备与依赖安装
如果你只是想快速体验,Univer 提供的在线 demo 就够用了。但如果你想把它嵌入自己的系统,我建议完全按照下面的步骤走一遍,把这套流程变成你自己的基础模板。
我选择 Vite 作为脚手架工具,理由是它启动快、依赖简单。用 webpack 当然也可以,但没必要在验证阶段就投入构建配置的处理时间。我的环境只需要 Node 16 以上和 TypeScript,没有任何特殊要求。
先创建一个基础项目:
npm create vite@latest univer-demo -- --template vanilla-ts cd univer-demo npm install接着安装 Univer 的核心包。这里我特别提醒一句:Univer 的包拆得很细,不同版本之间的包名和导出路径可能略有差异,所以下面的包名以你拉取到的最新版本为准。如果你发现某一句 import 报错,先去检查你安装的是不是最新稳定版,不要生搬硬套网上旧帖子的代码。
npm install @univerjs/core @univerjs/sheets @univerjs/sheets-ui @univerjs/ui @univerjs/engine-render这些包的大致分工是这样的:core 管数据模型,sheets 管电子表格逻辑,render 引擎负责 Canvas 绘制,ui 是外围界面,sheets-ui 负责把表格能力与界面结合起来。
3.2 最小可运行示例
项目准备好之后,创建一个 main.ts,初始化一个 Univer 实例并挂载到页面上。
import { Univer } from '@univerjs/core'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui'; import { UniverRenderEngine } from '@univerjs/engine-render'; import { UniverUIPlugin } from '@univerjs/ui'; const univer = new Univer(); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverRenderEngine); univer.registerPlugin(UniverUIPlugin, { container: 'app', }); univer.registerPlugin(UniverSheetsUIPlugin); // 创建一个默认空工作簿 const workbook = univer.createWorkbook({ id: 'blank', sheetOrder: ['sheet1'], });在 index.html 里放一个对应 id 的容器:
<div id="app" style="width: 100%; height: 600px;"></div>同时需要引入组件自带样式,两种 CSS 都要:
import '@univerjs/ui/lib/index.css'; import '@univerjs/sheets-ui/lib/index.css';跑起来之后,你会看到一个界面风格相当现代的表格应用,顶部有工具栏、公式栏,底部有工作表标签,默认是一张空白表。这个页面看起来简单,但它已经是完整的 Univer 实例,后面所有的定制都是在这个基础上展开的。这里最容易踩的坑,是容器 id 和传入的 container 字符串不一致,运行时 UI 层找不到挂载点,会直接报错。
3.3 接入 React 的封装思路
现在大部分前端团队都用 React 或 Vue。我的建议是不要在主组件里直接裸调 Univer,而是封装成一个独立的组件,方便复用和生命周期管理。
React 版本的封装大致长这样:
import { useEffect, useRef } from 'react'; import { Univer } from '@univerjs/core'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui'; import { UniverRenderEngine } from '@univerjs/engine-render'; import { UniverUIPlugin } from '@univerjs/ui'; function UniverEditor(props: { containerId: string }) { const univerRef = useRef<Univer | null>(null); useEffect(() => { const univer = new Univer(); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverRenderEngine); univer.registerPlugin(UniverUIPlugin, { container: props.containerId, }); univer.registerPlugin(UniverSheetsUIPlugin); univer.createWorkbook({ id: 'blank', sheetOrder: ['sheet1'] }); univerRef.current = univer; return () => { univer.dispose(); univerRef.current = null; }; }, [props.containerId]); return <div id={props.containerId} style={{ width: '100%', height: 600 }} />; }有一个细节值得注意:在 useEffect 的清理函数里调用 dispose。很多人第一次写会漏掉这一步,结果组件在单页应用里反复切换后,浏览器内存一路飙升,体验非常糟糕。Univer 实例是一个重量级对象,不手动释放,它的事件监听、渲染线程和存储结构都会停留在内存里。
3.4 从后端加载数据并保存
你做业务肯定不只是要一个空白表,更多时候是想让用户编辑已经在系统里保存的数据。Univer 有自己的数据序列化格式,我的做法是后端按这个 JSON 结构存储,前端初始化时拉取数据,再通过 API 创建工作簿。
伪代码示意如下:
async function loadRemoteWorkbook(containerId: string, workbookId: string) { const resp = await fetch(`/api/workbooks/${workbookId}`); const data = await resp.json(); const univer = new Univer(); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverRenderEngine); univer.registerPlugin(UniverUIPlugin, { container: containerId }); univer.registerPlugin(UniverSheetsUIPlugin); univer.createWorkbook(data); return univer; }保存的逻辑则更简单:前端把工作簿序列化后提交到后端,由后端落库。如果你只是做数据录入工具,不回写 Excel 原文件,那么全程用 Univer 的 JSON 数据结构最稳定,因为不需要做任何格式转换。只有导入导出 xlsx 文件时,才需要走转换这一层,这一块我们后面会说。
3.5 隐藏不需要的编辑器界面元素
不是每个业务场景都需要完整的工具栏和公式栏。比如只读展示场景,用户只需要看数据和图表,不需要任何编辑入口。
Univer 的 UI 插件在注册时通常允许通过配置控制界面元素。你可以通过传参方式决定要不要显示工具栏、菜单栏、工作表标签栏。我在一个数据看板项目里就是这么做的:关闭工具栏,保留滚动和单元格选中,从产品视觉上一下子就和普通页面融为一体。
另一个做法是在界面上用 CSS 覆盖,比如对顶部工具条做display: none。这种做法更直接,但不够工程化,因为一旦组件内部层级结构调整,你的 CSS 覆盖就会失效。更建议优先选择官方配置项,它才是稳定途径。
3.6 自定义单元格类型扩展业务
Univer 的插件机制还允许注册自定义单元格编辑器。我们在内部系统里做了一个“引用另一张表单”的特殊单元格类型,用户点击这个单元格时,弹出一个选择器,选中之后显示被引用记录的 ID 和名称。
这个功能在传统表格组件里要实现,需要改很多内部代码,但在 Univer 的插件体系里,你可以独立封装成一个小插件模块,注册到实例上,业务代码和表格代码之间互不干扰。如果你要做的业务涉及“单元格里不只是文本和数字”的场景,比如日期选择、附件上传、下拉多选、人员选择,这种自定义机制会是你的核心依赖。
4. 常见问题与避坑记录
4.1 页面空白:多数情况下不是代码报错,而是插件没注册全
我前面反复强调插件注册顺序,因为这个坑真的能浪费你半天时间。Univer 出现“工具条能看到,但表格区空白”或者“整个界面完全没有内容”的情况时,多数不是 JavaScript 报错,而是渲染引擎没有正确注册。
为什么会这样?因为 Canvas 绘制是在一个独立的渲染过程中进行的,当UniverRenderEngine没有被注册,UI 层即使已经创建,画布区域也没有任何东西可画,而且控制台不一定给你一个显眼的异常。排查方法也很简单:打开 DevTools,看看有没有类似 worker 加载失败或者 canvas 初始化失败的提示;同时检查注册列表里是否缺少 RenderEngine,UI 插件是否排在它后面。
4.2 中文字体乱码:问题可能出在服务器,而不是组件
Canvas 绘制文字时使用的是浏览器或宿主环境的字体。在个人开发机上跑的时候,系统自带中文字体,一切正常。但一旦把项目部署到了一个精简的 Linux 容器里,容器里没有中文字体,表格里的中文就会变成一个个方框。
这属于运维层面的问题,但很多前端同学会误以为是组件问题。解决办法有两个方向:一个是让运维同事在 Docker 镜像里安装中文字体;另一个是在前端额外引入一份 web font 字体文件并配置到渲染上下文里。第一个方案要调整 Dockerfile,第二个方案会增加资源加载量,你自己根据部署环境取舍。
4.3 版本冲突:模块重复实例化是运行时崩溃的根源
Univer 的迭代速度很快,各子包的版本必须保持一致。如果你在 package.json 里发现@univerjs/core是 0.1.5,@univerjs/sheets-ui却是 0.2.0,那么恭喜你,你进入了一个典型的版本冲突现场。
这种冲突在编译阶段表现得很温和,TypeScript 类型可能能通过,但运行时经常出现“无法读取 undefined 的某个属性”这类难以定位的错误。原因是核心模块和 UI 模块在不同版本之间存在数据结构不一致,甚至产生了两个核心实例。解决方案非常原始但也非常有效:把所有@univerjs/*相关包的版本统一,最好用一个变量管理版本号。
4.4 数据量大的性能优化手段
虽然 Canvas 方案扛住了大量数据的渲染,但当你把十几万行、几十列的数据一次性塞进内存,再叠加公式计算,还是会感到明显的压力。从我实测的情况看,最大瓶颈往往不是渲染,而是数据初始化阶段和公式求值阶段。
我整理了几项亲测有效的优化手段:
- 不要一次性加载全量数据:先拿首屏需要渲染的行列范围,数据滚动到底部时再按需补充。
- 尽量避免全列公式引用,控制公式的作用范围,减轻求值压力。
- 如果是纯展示,不用创建编辑器相关的交互逻辑,直接使用无头模式或轻量渲染。
- 关闭不必要的实时联动:有些场景并不需要边输入边刷新全局统计,可以改成点击按钮或失去焦点后统一计算。
4.5 自托管部署与 Nginx 配置
Univer 的构建产物就是普通前端静态文件,部署逻辑和部署一个 Vue 或 React 应用没有本质区别。需要注意的仍然是 Web Worker 相关细节:如果 Nginx 配置开启了类似default_type application/octet-stream的行为,可能导致 .js 文件被错误识别,worker 加载失败。
下面是一个我实际用过的 Nginx 配置片段,重点放在静态资源缓存和 MIME 类型上:
server { listen 80; server_name your-domain.com; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location ~* \.(js|mjs|css|woff2|ttf)$ { types { application/javascript js mjs; text/css css; font/woff2 woff2; font/ttf ttf; } gzip on; expires 7d; add_header Cache-Control "public"; } }另外,如果你的组件需要访问自己的后端接口,就和其他前端项目一样,要么走同一个域名,要么后端配置好跨域头,没有额外的特殊要求。
4.6 动态尺寸布局下画布不会自动重绘
这是一个比较小众但确实存在的细节:当你通过响应式布局、折叠面板或窗口缩放改变了容器 DOM 的尺寸时,Univer 的画布不一定会自动感知并重绘,有时会出现多出来的白边或者绘制区域与容器不一致。
最稳妥的解决办法,是给容器设置固定宽高,让布局不因外部环境变化而改变尺寸。如果必须做响应式,则需要在尺寸变化后主动触发一次渲染更新,而不是单纯依赖 CSS 的应变。这个坑我排查了很久,一开始以为是 CSS 样式冲突,后来才发现是画布渲染与容器尺寸脱节了。
5. 常见问题速查表
我把前面提到的典型问题整理成一张速查表,方便你遇到问题时直接对照。
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 页面完全空白 | 渲染引擎插件未注册 | 检查是否引入并注册 UniverRenderEngine,且位于 UI 插件之前 |
| 容器 id 找不到 | container 配置与 DOM id 不一致 | 对比配置和 HTML 结构,确保字符串一致 |
| 中文字符显示为方框 | 服务器缺少字体或未加载 web font | 安装中文字体,或初始化时加载自定义字体 |
| 运行时莫名报错 | @univerjs/* 系列包版本不一致 | 统一所有包版本号,用一个变量管理 |
| 组件无法二次进入或内存增长 | 初始化后没有 dispose | 在 useEffect 清理函数或组件销毁时调用 dispose |
| Web Worker 加载失败 | Nginx MIME 类型错误 | 配置正确的 js/mjs MIME 类型并开启缓存 |
| 容器尺寸变化后出现白边 | 画布没有跟随容器尺寸重绘 | 固定容器尺寸,或主动触发渲染更新 |
这七类问题是我在实际项目中真实遇到过的。它们不会都在你的环境里出现,但提前知道对应的解法,排查效率能提升很多。
6. 我的选型建议:什么场景适合直接上 Univer
6.1 适合用的场景
如果你们正在做的是中后台管理系统,产品里已经存在“表格查看”“数据编辑”“配置管理”这些模块,那 Univer 是很值得认真评估的选择。相比花高价采购商业组件,它把成本降到了接近零;相比自己从零写一个编辑器,它把周期缩短到按天计算。
我尤其推荐它用在以下场景:
- 需要在线编辑工作簿,并且希望交互体验接近现代办公软件。
- 需要自托管部署,数据敏感,不能把数据落第三方。
- 需要在表格之上做定制交互,比如特殊单元格类型、权限控制、操作埋点。
- 团队有一定前端工程能力,愿意花几天时间研究文档和源码。
6.2 建议再等等的场景
有一些情况我会劝你谨慎。如果你的需求是对 Excel 格式做毫厘不差的保真,Univer 的导入导出能力虽然持续进步,但还不能和原生 Excel 完全对齐,复杂的样式、宏、精密排版可能达不到要求。如果你有一个非常紧的上线日期,团队没有时间研究它的内部机制,那也应该再评估一下,因为它的学习成本比单纯组件要高一些。如果你们还需要支持旧版本浏览器,尤其是对 Canvas H5 支持不完整的浏览器,那更会折腾到团队怀疑人生。
还有一点,Univer 的实时协作能力并不是开箱即得的完整商业方案。它提供了底层的协作协议支持,但你要真正实现多人同时编辑、冲突合并、离线编辑这些功能,仍然需要自己搭建协同后端。如果只是内部系统做个集体填写,可以用简单的保存提交逻辑代替实时协作;只有在强烈需要“多人鼠标同时出现在一张表上”的体验时,再考虑投入协同服务的建设。
6.3 最后分享一个我自己的实际体感
我在这套开源组件上投入过完整的项目周期,总体的感觉是:它的上限比大多数同类开源项目要高,像插件机制、命令系统这些设计,让你在往深处定制时有底气。但这份底气建立在“愿意把它当项目来学习”的基础上。它不是那种下载就能跑的现成工具箱,你在前两三天可能会频繁查阅源码和 issue,但熬过初始化阶段后,后面就是顺畅的收获期。
另外一个经验是,遇到问题少去依赖“搜整段报错信息”的老方法,直接去 GitHub 的 issue 里搜关键词往往更有效。Univer 的社区还在早期,但核心维护者回复速度不慢,问问题的时候把版本号和复现步骤交代清楚,基本都能得到有用的回应。这也是我想在文章最后强调的:开源项目用得好不好,往往取决于你提交问题的姿态和技术细节的完整度。别怕看源码,它比文档藏着更多答案。