做了几年前端,最烦的就是听到“这个表格需求很简单”这句话。普通表格展示确实不难,难的是当业务方把Excel里的功能一个个搬出来:合并单元格、条件格式、筛选排序、公式联动、多人同时编辑同一份表格。早年我用DOM表格硬扛,数据量过万页面就开始卡,虚拟滚动、懒渲染、按需计算,各种优化手段加了一遍,最后还是败给了浏览器DOM节点数的物理极限。
后来在技术社区刷到Univer,第一反应是“又一个在线表格Demo”。但把源码和架构文档翻了一遍之后,我改变判断了——这是一个从头到尾用Canvas自绘渲染、把协同编辑和公式引擎内建在底层的开源办公文档引擎。它不是做一个“能看”的表格页面,而是想把Excel、Google Sheets级别的能力做成前端基础设施,直接嵌进你自己的产品。
这篇文章不打算复述官方文档。我会从实际接入和改造的角度出发,把Univer的核心设计思路、项目定位、快速上手方式、二次开发要点,以及我自己踩过的几个真实的大坑,一层一层说清楚。如果你正在做在线表格、数据填报、ERP系统、协同办公产品,或者单纯好奇纯前端团队怎么把“重文档应用”塞进浏览器,这篇应该对你有参考价值。
1. 先搞清楚Univer到底是个什么东西
1.1 它不是表格组件,而是一个“文档内核”
多数人第一次接触Univer是从GitHub上的Star数和README开始的,看到“Sheet”模块就以为它跟Handsontable、Luckysheet一样,是个表格类库。这个理解偏差会直接影响你用它的方式。
Univer的官方定位其实是“统一办公文档引擎”,底层拆成了多个独立模块:Sheet(电子表格)、Doc(文档)、Slide(演示文稿),再加上一套统管渲染、命令、协同、公式、插件的内核。表格只是它目前最成熟的一个场景。这就像一个团队先集中火力把Excel能力做好了,但底层的地基是按“office全家桶”的标准来打的,后续再接其他文档类型时不需要推翻重来。
这个定位意味着什么?你拿到手的不是一组封装好的<Spreadsheet>标签,而是一套可以拆开组合的“零件库”:渲染引擎、命令系统、公式引擎、协同算法是独立的。你可以只用它的表格渲染部分,也可以把协同、公式、菜单全部接上,或者顺手挂一个自己写的插件进去扩充能力。
1.2 技术底座:Canvas渲染 + Web Worker + CRDT协同
Univer在技术选型上和传统表格库拉开差距的核心有三点。
第一,渲染层不用DOM网格。所有单元格、选区、边框、数据条,全部通过Canvas绘制,底层是自研的一套渲染引擎。这让它在大数据量场景下拥有天然优势,表格渲染不再受DOM节点数的约束。你可以把Canvas理解成一个画师,画面里的元素再多,画师也只是一张画布上不断重画,而不是像DOM那样为每个单元格创建一个真实物体。
第二,计算和渲染分离。Univer在多线程设计上做了不少文章,把公式计算、数据处理这类“重体力活”放到Web Worker线程里,UI线程只管渲染和交互。这样即使公式计算量巨大,界面也不会出现长时间无响应。
第三,协同编辑走了CRDT路线。多人同时打开一张表,改同一个单元格,不同客户端之间的操作怎么合并、怎么保持最终一致,这是协同办公最硬核的部分。Univer在这块直接采用CRDT(无冲突复制数据类型)作为底层同步数据结构,比传统的OT(操作转换)方案在跨域、离线场景下更容易做收敛。这部分官方文档目前描述得不算细,但如果你的产品需要做多人协作,这个选型方向是对的,跟主流在线文档的思路是一致的。
1.3 谁适合用Univer,谁不适合
结合我这段时间的实践,下面两类场景适合选Univer:
- 业务系统里有一块“复杂表格区域”:不是单纯展示数据,而是要支持公式、条件格式、单元格合并、历史操作撤销、甚至多人协同,例如财务系统、ERP、数据分析后台。
- 团队有前端研发能力并且愿意花精力二次开发:Univer提供引擎和插件机制,但UI细节、权限逻辑、后端存储对接都要自己实现。没有开发人员,期望拿来即用,目前还做不到。
反之,如果只是需要一个轻量的表格编辑,或者只在移动端简单展示数据,那用Handsontable甚至直接写个二维数组渲染都更轻便。Univer本身带有比较明显的“重引擎”属性,引入成本和学习曲线都偏高,杀鸡用牛刀没有必要。
2. 架构选型拆解:为什么说它走了一条“难而正确”的路
2.1 自研Canvas渲染的取舍与底气
做过大数据量表格的人都知道,DOM方案再怎么优化,到了几万行就接近天花板。给每个单元格生成一个DOM节点,内存和Layout计算都会被拖垮;用虚拟滚动只渲染可视区,滚动时又容易出现白屏和闪烁。Univer选择全部自绘,确实是从根上解题。
但“全部自绘”不是没有代价。第一,实现成本极高,一个成熟的Canvas表格引擎,底层要处理字体测量、边框绘制、选区选中态、滚动重绘、事件坐标命中检测,这些全是体力活。第二,无障碍访问更困难,Canvas里的文字对读屏软件来说是不可见的,如果产品有严格的a11y合规需求,需要额外做补充方案。第三,文本编辑要自己模拟光标、选区、输入法合成,这些都是Excel体验里最细枝末节又最容易出Bug的地方。
Univer能走通这条路,说明团队在自研渲染上是有底气的。它把渲染拆成了“场景树”和“图层”两层结构,类似游戏引擎的概念:所有可视化控件都挂在场景树里,渲染器按需绘制图层。这样做的好处是局部更新时只需要重绘某个区域,性能更容易控制。
2.2 插件化架构与跨框架设计
Univer包结构拆得非常细,@univerjs/core、@univerjs/sheets、@univerjs/ui、@univerjs/formula、@univerjs/network,每个功能都是一等公民。这种组织方式有几个直接的好处。
首先,不绑前端框架。Univer核心层与框架无关,React项目里能用,Vue项目里能用,原生JS也能用。它只是向外暴露实例和API,UI层通过适配器对接。这一点对团队技术栈不统一的公司来说很友好,一套表格引擎可以同时服务多个不同框架的老项目。
其次,功能可以按需加载。不需要用协同,协同模块就不打进主包;不需要公式引擎,公式模块也可以去掉。首包体积能控制在一个合理范围内。Univer后续还提供了@univerjs/presets这类聚合包,面向只想快速跑通的场景,直接引入预设即可。这种“既要灵活又要省事”的思路,本质上是在降低使用门槛。
再看插件机制。Univer的菜单、工具栏、右键菜单、快捷键都是可以注册的。一个按钮从点击到执行命令,走的是“UI事件 -> Command -> 数据变更 -> 重绘”的命令链路。这种设计借鉴了Redux和领域驱动设计的思想,所有变更都收敛到统一路径,天然支持撤销重做和历史记录。
2.3 主流表格库横向对比:Univer、Luckysheet、Handsontable、SpreadJS
我把自己实际评估过的几个方案放在一起对比:
| 方案 | 渲染方式 | 协同能力 | 公式引擎 | 开源协议 | 扩展成本 | 适合场景 |
|---|---|---|---|---|---|---|
| Univer | Canvas自绘 | CRDT,可用在多场景深度集成 | 内建,支持自定义 | Apache 2.0 | 中高,需要理解内核和插件机制 | 复杂表格、协同办公、长期产品化 |
| Luckysheet | Canvas + DOM混合 | 服务端需自行实现 | 内建 | MIT | 较低,上手快 | 中小数据量的在线表格功能嵌入 |
| Handsontable | DOM + 虚拟滚动 | 需额外方案 | 有限 | 商业授权(非商业免费) | 低 | 快速搭建表格编辑,数据量中等 |
| SpreadJS | Canvas + DOM混合 | 商业方案配套 | 强大 | 商业授权 | 低,成熟度高 | 企业级表格应用,预算充足 |
这里想特别提一句Luckysheet。它早年是开源的在线表格方案里成熟度比较高的一个,但从维护状态和架构演进看,Univer更像是它的“下一代”产品——同一批人在技术路线上做了彻底升级。如果你的项目面向未来三到五年,重视协同和扩展,Univer更合适;如果只要一个稳定且轻量的表格页面,Luckysheet或Handsontable仍可一战。
协议方面,Univer采用Apache 2.0,对商用集成比较友好,二次开发时不需要像纯GPL项目那样担心传染性开源问题。但商业友好不等于免费服务,社区支持、文档、案例沉淀目前还是它的短板,这需要自己有源码阅读能力。
3. 实操上手:10分钟跑起一个Univer表格
3.1 环境准备与依赖安装
我下面的示例以当前主版本为准,Univer迭代很快,API细节可能已经微调,但整体接入思路是稳定的,动手前先检查一下你安装版本的官方变更日志。
一个最简单的Univer表格项目,只需要Node环境和包管理器。它的基础依赖分为三块:核心模块@univerjs/core,表格功能模块@univerjs/sheets,UI渲染模块@univerjs/ui。早期版本还有一个聚合入口@univerjs/presets,新手可以用它一条命令拉齐配置。
npm install @univerjs/core @univerjs/sheets @univerjs/ui如果网络环境不太好,或者公司内网用私有npm源,这几个包都有不少传递依赖,装的时候建议直接装最新稳定版,不要锁定太老的版本,否则容易碰到模块间版本不匹配的问题。
3.2 初始化一个最小实例
安装完成后,创建一个index.js,核心代码如下:
import { Univer, UniverInstanceType } from '@univerjs/core'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverUIPlugin } from '@univerjs/ui'; const univer = new Univer(); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverUIPlugin); const sheet = univer.createUniverSheet();这段代码做了三件事:创建Univer内核实例、注册表格插件和UI插件、创建一个Sheet实例。Univer的依赖注入做得比较重,所有能力都通过registerPlugin挂到核心上。如果你发现某个默认功能没生效,优先检查是不是对应的插件没注册。
UI插件注册后会在指定的DOM容器里挂载整个表格视图。通常你需要在HTML里放一个占位容器,然后通过配置把容器传给创建方法,类似这样:
univer.createUniverSheet({ container: document.getElementById('app'), sheet: { id: 'sheet-001', name: '第一个表格', rowCount: 1000, columnCount: 100 } });首次跑起来的时候我反而建议把行列数设小一点,比如1000行100列,先确认交互流畅度,再逐步调大。因为Canvas首次全量绘制时,行列数直接决定场景树的加载量,一次性开到Excel级别的百万行,首屏会有一段明显的构建时间。
3.3 数据填充与基础配置
表格跑起来之后,下一步是塞数据。Univer的数据操作接口设计成了命令模式,直接改cellValue是最快的填充方式:
const workbook = univer.getUniverInstance(UniverInstanceType.UNIVER_SHEET); const worksheet = workbook.getActiveSheet(); worksheet.getRange(0, 0, 10, 10).setValues( Array.from({ length: 10 }, (_, row) => Array.from({ length: 10 }, (_, col) => ({ v: `第${row + 1}行-第${col + 1}列`, s: { bl: 1 } // 加粗 })) ) );有的同学会问,为什么单元格值要包一层{ v: ... }对象,而不是直接传字符串?因为Univer的单元格模型里,除了显示值v,还有样式S、公式F、富文本p、备注等多个字段。统一的CellValue结构,是为了让每个单元格都具备“被公式引用、被样式装饰、被协同同步”的能力。直接塞字符串看起来方便,后续改样式和加公式时反而要多一步转换。
配置层面有两个常用项:主题和语言。Univer默认支持中文,但要在入口处声明:
univer.registerPlugin(UniverUIPlugin, { container: document.getElementById('app'), locale: 'zhCN', skins: { modern: { primaryColor: '#316dca' } } });颜色主题、字体、功能区布局都可以通过配置覆盖,这部分跟CSS变量类似,改起来不费劲,但如果你的产品有完整的设计规范,建议尽早把主题配置梳理成一套公司内部的设计Token,而不是散落在各个页面里硬编码。
4. 二次开发实践:把Univer揉进自己的产品线
4.1 自定义菜单与命令注册
Univer默认的功能区已经自带了一系列操作:求和、筛选、排序、合并单元格等。但真实业务中几乎一定会遇到“增加一个自定义按钮”的需求,比如把选中区域的数据一键提交到后端。
官方推荐的做法是走命令链路。先声明一个Command:
import { CommandType } from '@univerjs/core'; const SAVE_SELECTION_COMMAND = 'sheet.command.save-selection'; univer.registerCommand({ id: SAVE_SELECTION_COMMAND, type: CommandType.OPERATION, handler: async (context, params) => { const worksheet = context.getActiveSheet(); const selection = worksheet.getSelection(); const values = selection.getValues(); await fetch('/api/save', { method: 'POST', body: JSON.stringify(values) }); return true; } });然后在工具栏注册按钮并绑定这个命令。这套设计的好处是,命令本身不依赖UI实现,你可以同时从菜单、右键菜单、快捷键、甚至外部脚本触发同一段逻辑,避免“重新写一遍业务逻辑”在多个入口之间复制粘贴。回调函数的返回值也很有讲究,返回true代表命令已消费,返回false则会被判定为失败,可以用在撤销栈和状态同步上。
4.2 自定义函数
公式是Univer的重头戏。它内置了日常办公高频使用的大部分函数,但业务场景中经常会有“按公司规则计算”的特定函数,比如把一段数值按阶梯提成比例拆算。
Univer暴露了函数注册接口,大致的注册结构是:
import { FunctionType, registerFunction } from '@univerjs/formula'; registerFunction({ id: 'COMMISSION', functionType: FunctionType.NORMAL, minParams: 2, maxParams: 2, calculate: (base, rate) => { if (base < 0) return 0; if (base < 5000) return base * rate; return base * rate + (base - 5000) * 0.1; } });注册完成后,单元格里就能直接写=COMMISSION(A2, B2)了。这里要特别提醒:自定义函数里的计算逻辑要尽量够纯(不依赖外部状态、不产生副作用),因为Univer的公式引擎可能在其他线程或离线环境下执行计算。如果你在函数里直接访问window或操作DOM,很容易在协同场景或Web Worker模式下炸掉。
4.3 与后端数据双向绑定
表格不是孤立存在的,几乎每个业务方都要求“加载服务端数据,用户改完再保存回服务端”。这一块Univer没有强行规定接口协议,你需要自己做映射。
我的做法是分三层:
- 全量数据加载时,用
worksheet.getRange().setValues()一次性绘制,这比逐单元格赋值快一个数量级。 - 用户编辑产生变更时,监听
onCellChange事件,收集变更范围,拖一段时间后批量提交。 - 服务端返回结果后,用
worksheet.getRange().setValues()回填服务端修正后的值。
worksheet.on('cellChanged', (params) => { const { row, column, value } = params; changeQueue.push({ row, column, value }); });批量提交可以用简单的防抖策略:新增编辑进队列后启动一个200ms的定时器,如果队列里有数据就整体发送。这样做既能减少请求次数,又不会让用户感到明显的等待。千万不要每次改一个单元格就发一个请求,在协同系统里这会把后端打垮。
4.4 协同编辑接入注意事项
Univer的协同能力虽然内建在底层数据模型中,但默认并不是开箱即用的,服务端同步、操作分发的通道需要自己实现。我见过不少团队以为“用了Univer就有协同”,结果集成时才发现还要自己搭一套WebSocket接口。
接入协同前,有几件事必须先想清楚:
- 房间概念。多个表格实例如何组织成一个协同房间,谁有权邀请成员,离开后怎么清理状态。
- 权限热切换。普通成员和编辑者同时在房间里,权限变化时需要通过命令实时调整。
- 冲突提示。CRDT能避免数据冲突,但没法避免业务冲突,比如一个成员锁定了某列,其他人又改这列。业务层的锁需要你们自己设计。
- 离线编辑。如果产品有移动端离线场景,CRDT同步后的合并策略需要在接入前做充分测试。
协同是Univer最核心的卖点之一,但也是最需要产品规划投入的部分。建议第一步先不做真正的实时同步,而是基于命令链路做“操作广播+全量状态回传”,跑通链路后再上CRDT。
5. 踩坑实录:那些让我想换项目的地方
5.1 常见问题速查表
| 现象 | 原因 | 解决思路 |
|---|---|---|
| 首次渲染白屏,控制台报Canvas相关错误 | 容器高度为0,或Canvas初始化时DOM未挂载 | 确保容器有明确高度,初始化前检查DOM是否ready |
| 中文输入法下拼音上屏 | 合成输入事件未正确处理 | 升级到新版UI插件,或检查自带输入法兼容补丁 |
| 大数据量下滚动卡顿 | 性能配置未开启,频繁全量重绘 | 调整渲染节流参数,开启局部重绘模式 |
| 页面里引入组件库弹层被表格遮挡 | Canvas层级高于普通DOM弹层 | 给弹层设置更高z-index,或放到独立渲染容器 |
| 自定义命令点击后没有反应 | 命令ID未注册,或注册顺序晚于UI触发 | 检查注册顺序,把registerCommand放到创建Sheet之前 |
| 公式数据更新但界面不变 | 公式引擎未开启动态计算 | 检查公式模块配置项,避免使用了纯静态快照模式 |
5.2 性能调优三板斧
针对大数据量场景,我个人实践下来最有效的优化是三件事。
第一,降低首屏渲染数据量。不要在初始化时就把几百MB的历史数据一次性set进表格页,按需加载、分页加载、只加载可视区域附近的数据,这些策略在Univer下依然需要你做。表格引擎解决的是“渲染一万行数据不卡”,不是“把一百万行数据塞进内存还很快”。数据源优化永远是第一位的。
第二,关闭非必要的动画和实时特效。Univer支持单元格高亮、条件格式、数据条等一系列视觉效果,这些在Excel里很常见,但在浏览器里每一项都意味着额外的绘制开销。后台数据频繁更新时,临时把这些视觉配置关掉或降级,能明显提升滚动流畅度。
第三,控制协同消息的碰撞频率。协同模式下,每个操作消息都会触发一次状态变更和重绘。如果多人同时编辑一个区域,每秒可能产生几十条CRDT消息,这时候要在服务端做消息合并和节流,而不是让前端硬扛。
5.3 与其他组件库混用的细节坑
实际业务里,Univer很少单独存在,身边总有一堆antd、Element Plus之类的组件库。Canvas和普通DOM混用时的层级问题是最常见的坑。
Univer的表格区域是Canvas,普通组件库的弹窗是DOM。如果画布区域创建了一个高z-index的DOM节点,它永远显示在弹层下面,因为Canvas自身是同一个层级的元素,内部再高也没用。解决办法是给弹层组件使用body级别的Portal,并且显式设置z-index大于表格域的叠加层。反过来,如果你需要做“点击表格单元格之外的空白区域收起弹窗”,监听的事件不能挂在Canvas内部,而是挂在Canvas外层的容器上,否则eas点击坐标会被Canvas绘制事件吞掉。
还有一个不起眼的点:右键菜单。Univer自带的右键菜单也是Canvas绘制的,如果产品自定义了右键菜单,要注意两套菜单的触发入口是否冲突。我自己的做法是直接禁用Univer自带的ContextMenu插件,全部用业务自定义菜单替换,这样交互统一,维护成本也低。
6. 写在最后:一点使用心得
如果让我给还在观望的人一句话:Univer现在还不够“傻瓜化”,文档和API还在快速迭代,集成过程中你大概率会遇到依赖版本不一致、API变更、官方示例跑不起来的尴尬。但它的技术路线确实把“表格引擎”中最难啃的部分做完了——Canvas渲染性能、公式引擎、协同数据模型、跨框架设计,这些东西自己从头造轮子,一个五人团队干半年都不一定够。
我的经验是先跑通最小Demo,不要一上来就追求把所有功能都接上。把表格塞进页面,塞几条数据,跑一下自定义命令,再逐步往里面加公式、加协同。每加一块,就带着具体业务场景去翻一遍源码,比对着API文档猜要高效得多。后续等它的Doc模块和协同服务端方案更稳定之后,我还会继续跟进,到时拿到新的实测结论再回来补一篇。
如果你已经在用Univer做项目,或者正在调研选型阶段,欢迎多交流各自踩到的坑——这类项目还是太新了,社区里多一份真实经验,后面的人就能少走一段弯路。