1. 从一次团队踩坑说起:为什么我们需要 Univer
今年年中,我们团队接到一个内部中台改造需求:把原来基于老旧前端表格组件、一次最多只能渲染几千行数据的管理系统,升级成能扛住十几万行业务数据、还要支持多人同时编辑的在线表格。最初几个候选人摆上台面,无非是 SpreadJS、Handsontable 这类商业授权组件,或者自己从头写一个 Canvas 渲染引擎。前者授权费用按年算不便宜,后者周期动辄三个月起步,根本排不上期。
后来我们注意到了 Univer。它是一套开源的前端办公套件项目,核心部分是一个基于 Canvas 渲染的高性能表格组件,也附带文档、幻灯片能力模块。在做技术选型对比时,Univer 的定位非常明确:用现代前端工程化方式重做一套类似 Excel 的交互体验,同时把协同编辑、公式计算、条件格式这些重量级能力全部插件化。它能解决的问题很直接——第一,百万行级数据下的流畅滚动和单元格渲染;第二,像 Excel 一样丰富的公式、图表、格式操作能力;第三,集成成本低,一个 npm 包加几行配置就能在 React 或 Vue 项目里跑起来。
这篇内容更适合三类人看:正在做在线表格类产品选型的前端负责人,需要给内部系统快速接入表格编辑能力的全栈开发,以及对前端高性能渲染、WebWorker 计算方案感兴趣的技术人。接下来我会从技术拆解到实操集成,再到我们实际踩过的坑,完整过一遍。
2. Univer 的整体设计思路拆解
2.1 插件化架构:为什么它比传统表格组件更灵活
Univer 最核心的设计理念就是一切皆插件。它不像传统表格组件那样把数据解析、渲染、交互、公式计算全部揉在一个不可拆分的实例里,而是拆成了几个相对独立但又通过统一上下文(UniversalContext)串起来的模块。
你可以把 Univer 的插件化理解成一套可积木式拼装的系统:核心只负责最基础的文档结构存储和生命周期管理,而工具栏按钮、右键菜单、公式计算、筛选排序、协同编辑等能力,全部由对应的 Plugin 单独注册。按需加载、按需启用,用不到的模块完全不进主包,这样首屏体积、可维护性、扩展性都会好很多。
这一点在做选型时相当重要:很多业务场景并不需要完整套件。比如我们只需要数据表格、只需要公式功能、不需要多人协同,就可以在初始化配置里关闭协同相关插件,甚至只安装 Sheet 插件而完全跳过 Doc 和 Slide 模块,避免无关代码影响主应用。这种自由度是商业表格组件通常给不了的。
实际使用体验中,Univer 的插件开发接口也做得比较清晰。开发者只需要实现一个 Plugin 类,在 onMounted 钩子里注册自定义命令、UI 组件或者交互逻辑,再通过 plugin 管理机制挂载到对应的生命周期中即可。整个过程和 Vue 插件的自定义开发类似,但内部封装了更多表格相关的 API,比如单元格读写、选区管理、命令调度,上手门槛比从零扩展一个表格引擎低很多。
2.2 Canvas 渲染与 DOM 渲染的取舍:为什么 Univer 敢做百万行
传统 DOM 表格在数据量一大之后崩溃,原理上不用多说:每个单元格如果是 DOM 节点,15 万行 × 6 列就已经接近 100 万个节点,浏览器绘制和重排的压力直接拉满。Univer 的解法是完全放弃为每个单元格生成独立 DOM,而是像游戏引擎一样,在 Canvas 上直接绘制可视区域的单元格内容,配合虚拟滚动(Virtual Scrolling)只渲染当前视窗可见的部分。
这里有一个特别值得说的设计:Univer 不只是把单元格内容画到 Canvas 上,它还维护了一套区域脏标记机制。当你改了某一格的数值,只会触发该区域相关的重绘,而不是整表重绘。这个机制就像家用路由器里的 QoS 限速——只针对特定数据流做处理,而不是把所有数据都堵在门口排队。实测在我们业务场景,一个 20 万行 × 18 列的中型表格,普通鼠标滚动、键盘上下键切换、单元格输入响应基本没有明显感知延迟。
当然,Canvas 渲染也带来一个代价:HTML 能力用不了。这意味着单元格内不能直接放 input、select 这类原生控件。Univer 的应对方案是提供编辑器层(在 Canvas 上叠加一个真实 DOM 层用于输入)和自定义渲染器接口,你可以在单元格里用绘制 API 画自定义图形或文本。这个思路有点像游戏里的 UI 系统:Canvas 负责底图和复杂渲染,真正需要交互的原生控件只在需要时临时覆盖上去。
整体来说,选 Canvas 而非 DOM 是一个非常明确的取舍决策:牺牲一部分原生可访问性和开发便利性,换取大规模数据下的性能保障。对于我们要做的业务数据管理、导出分析类系统来说,这个取舍非常划算。
2.3 协同与数据模型:从本地单元格到多人实时编辑
Univer 在多人协同上的设计也值得一提。它没有自己重新发明一套数据同步协议,而是直接基于 Yjs(一种 CRDT 技术实现)来支撑协同编辑。每个单元格的增删改操作会同步为一个 Yjs 文档中的类型操作,通过规则引擎做冲突合并。
这里顺便解释一下为什么协同需要 CRDT 而不是传统操作转换(OT):在多人网络环境下,操作到达服务器端的顺序不可控,基于 OT 的方案需要中心化服务器统一调解操作顺序,一旦服务器压力大、链路长,就容易出现等待和锁冲突。CRDT 的核心思路是,让所有参与者的数据副本最终收敛到一致状态,不依赖中心服务器的顺序裁决。Yjs 是比较常见的 CRDT 实现,成熟度、包体大小、生态都还不错,被用在很多协同编辑产品里。
不过要注意,Univer 开源版本只提供了协同编辑的前端数据结构和远端事件对接方式,后端保存、消息广播、鉴权等能力都需要自己实现。也就是说,Univer 给了你一套协同车厢,但火车头、轨道和调度室需要自己建。
我们在实际集成协同能力时,服务端直接用了 Yjs 社区常见的 y-websocket 方案,把 Yjs 的文档更新通过 WebSocket 广播给当前房间内所有在线客户端。整体方案不复杂,但要注意连接状态管理和断线重连时的文档同步状态。
3. 核心能力解析与实操要点
3.1 数据层设计:命令(Command)与撤销重做
Univer 的所有数据变更,走的都不是直接改数据这条捷径,而是通过一套命令系统:发送 Command → 执行器执行 → 变更数据 → 触发视图刷新。初次接触会觉得多了一层,但这正是它能做撤销/重做、插件拦截、协作文档历史恢复的前提。
每个命令都有唯一的 type 标识和对应的 handler。比如修改单元格值对应的命令携带的参数包括:单元格所在的行列索引、新值、旧值、操作类型等。执行器在处理命令时,会先把旧值记录到撤销栈,然后更新数据,再通知渲染层做局部脏区域重绘。
实操层面,如果你想在 Univer 的表格组件上做自己的业务操作按钮(比如“批量填充部门名称”),应该优先考虑通过注册自定义 Command 的方式实现,而不是直接改底层数据源或操作 DOM。这样做的好处有几个:
- 批量操作也能像普通编辑一样支持撤销;
- 协同场景下,别人能看到你的操作痕迹;
- 后续如果要做操作审计,只需要在命令执行器上挂一个监听器,就天然拿到操作日志。
实际写的时候要注意:Univer 的命令对象通常需要包含id和type,不同类型(如插入行、删除列、设置单元格值)对应不同的 handler。命令执行失败时要注意不要吞掉异常,尽量向用户提示。
3.2 公式引擎与计算链路
公式计算这块,Univer 并没有采用 Excel 那种当成宏命令处理的方式,而是实现了一个模块化计算链。单元格里的SUM(A1:A10)这类函数,会在计算引擎中生成对应的依赖关系树,当被依赖的单元格数据变化时,只会触发受影响的计算节点更新,而不是整个工作表重新计算公式。
我们在测试公式性能时,造了一个 100 行 80 列的报表,里面用了大量 SUM、IF、VLOOKUP 混合公式。修改其中几个基础数据后,整体重算时间大约在十几毫秒级别,肉眼感知不到卡顿,和纯前端公式库(比如 Formula.js)每次全量重算的体验差异明显。
不过我要提醒一个注意点:Univer 的公式函数库目前已经相当丰富,覆盖了绝大部分常用函数,但它毕竟不是 Excel,部分边缘函数、数组公式、动态数组公式的行为和 Excel 原生有细微差异。生产环境使用前,建议做一轮函数兼容性清单,把你业务中高频用到的函数逐个在 Univer 中测试一遍,防止上线后才发“这个函数结果和 Excel 不一致”的线上事故。
另外,如果你要接入自定义函数(比如行业特定的复利计算、财务计提规则),可以通过自定义函数插件注册。这就引出了 Univer 又一个很实际的价值点:公式引擎与插件机制协同,意味着你可以在不侵入核心代码的前提下,把企业内部的计算规则沉淀成一套可复用的自定义函数库。
3.3 单元格样式与条件格式
样式处理是表格里最容易出细碎问题的部分。Univer 的样式模型大体分为字体类型、字体大小、加粗、斜体、下划线、删除线、前景色、背景色、边框、对齐方式等维度。在实际使用中,条件格式大多基于自定义规则实现,但 Univer 已经原生支持基础的条件格式能力,可以直接通过 API 配置。
给一个我们实际用得很顺的场景:在库存预警表里,用条件格式把库存量低于安全阈值的单元格背景标红。正常办公场景这需求再普通不过,但放在自研表格里,真要从零写条件格式计算和渲染并不轻松。Univer 里你只需要构造一个条件格式规则对象,指定范围、条件、样式,框架就能帮你做重算和渲染。
要注意的是,条件格式规则的判定时机,要密切关联数据变更事件。如果通过 Univer 的 API 更新数据,条件格式会自动响应;但如果有外部脚本直接修改底层数据模型,可能会绕过触发机制,导致样式不及时刷新。所以我们内部统一约定:所有数据写入必须走 Univer 的 Command API,不允许直接操作内部数据结构,这个经验同样适合你们。
3.4 导入导出:Excel 文件兼容性
需要 Excel 导入导出时,Univer 有自己的插件封装(比如基于 SheetJS 的兼容实现),支持 xlsx 的主流版本导入导出。样式、公式、合并单元格这些基础信息在转换过程中保持完好的概率比较大,但要注意几个大坑:
- 图片、图表、复杂透视表这类对象,转换后通常是丢失或者被降级成静态内容。
- 较为复杂的 Excel 高级功能(如宏、数据验证的复杂公式、数据透视表)在 Univer 中不能还原。
- 如果你做了自定义渲染器或自定义单元格格式,导出 Excel 时这些自定义内容不在标准格式表达范围内,建议导出前做字段提取或者降级为普通文本。
我们目前的解决方案是在产品层面做提示,告知用户“高级功能在在线编辑中不支持”或者采用“下载标准版本”的模式,避免功能支持不到位带来的预期差。稳一点的做法:导入导出对接完成后,准备一批覆盖各种格式陷阱的测试文件,至少包括合并单元格、垂直居中、跨行跨列、公式引用、填充颜色、行列宽等常规场景,回归时跑一遍。
4. 实操:从零到一个可用的表格应用
4.1 基础环境与安装选型
如果只是在一个 React 项目中使用 Univer 表格,最直接的方式是通过官方预设包(Preset)按需加载。执行安装:
npm install @univerjs/presets npm install @univerjs/core @univerjs/sheets @univerjs/ui @univerjs/sheets-formula @univerjs/sheets-import-export依赖版本之间要注意匹配,Univer 目前基础包版本更新迭代比较快,升级主版本时优先查看官方迁移说明。如果项目里同时用 React 和 Vue 这两个框架,Univer 也有对应的框架适配层,不需要自己封装组件桥接。
初始化代码非常简单,核心只有几个动作:创建 Univer 实例、注册相关插件、传入工作表配置。
import { Univer } from '@univerjs/core'; import { SheetPlugin } from '@univerjs/sheets'; import { UIPlugin } from '@univerjs/ui'; const univer = new Univer({ // 全局配置,例如默认工作表数量、主题色等 }); univer.addPlugin(SheetPlugin); univer.addPlugin(UIPlugin); const workbook = univer.createWorkbook('Book1'); const sheet = workbook.addWorksheet('Sheet1'); sheet.setCell(0, 0, 'Hello Univer');如果你只是要一个能用的表格编辑器,到这一步其实已经完成了 70%。后续的公式、协同、导入导出能力,都是在这个基础上增加插件和配置,并不会推翻重来。
4.2 网格结构与数据操作 API
Univer 对数据操作提供了比较统一的 API 风格,大致分成几类:
- 工作表管理:创建、删除、重命名、隐藏工作表;
- 单元格读写:读取指定行列的单元格对象,修改单元格值和样式;
- 行列操作:插入行列、删除行列、设置行高列宽、隐藏行列;
- 选区操作:获取当前选区、设置当前选区、监听选区变化。
代码层面,写数据和读数据大概长这样:
// 写入数据 sheet.setCell(0, 0, { v: '产品名称', s: { bold:true, fill: { rgb: '#f0f0f0' } } }); // 批量写入 sheet.getRange(0, 0, 10, 3).setValues([ ['A1','B1','C1'], ['A2','B2','C2'], // ... ]); // 读取数据 const value = sheet.getCell(0, 0)?.v;批量写入这里踩过一个坑:如果一次 setValues 的行列范围过大,比如一次性写入 5 万行数据,也会出现一两秒的短暂卡顿。解决方式是分批写入,或者利用生命周期让表格异步渲染,同时配合加载遮罩做体验优化。
4.3 集成到 React 项目:一个完整的最小示例
为了让你少花时间翻文档,这里直接给一个我们跑通的 React 最小集成流程。
先安装依赖,然后创建一个组件文件:
import React, { useEffect, useRef } from 'react'; import { Univer } from '@univerjs/core'; import { SheetPlugin } from '@univerjs/sheets'; import { UIPlugin } from '@univerjs/ui'; export const UniverSheet: React.FC = () => { const containerRef = useRef<HTMLDivElement>(null); const univerRef = useRef<Univer | null>(null); useEffect(() => { if (!containerRef.current || univerRef.current) return; const univer = new Univer({}); univer.addPlugin(SheetPlugin); univer.addPlugin(UIPlugin); const workbook = univer.createWorkbook('Demo'); const sheet = workbook.addWorksheet('Sheet1'); sheet.setCell(0, 0, 'Hello from Univer'); univerRef.current = univer; return () => { univerRef.current?.dispose(); univerRef.current = null; }; }, []); return <div ref={containerRef} style={{ width: '100%', height: '600px' }} />; };注意几个细节:容器必须有一个确定的高度,否则 Canvas 计算可视区域高度是 0,画面就空白;组件卸载时一定要调用dispose,否则会产生事件监听器泄漏,路由切换几次后内存占用能上去好几十 MB。
初始化之后,如果你的业务需要把表格数据导出到后端保存,比较通用的方案是遍历工作表,把单元格数据序列化成 JSON。Univer 原生支持内部文档结构导出,不过需要考虑版本演进后数据结构兼容性,建议只导出业务数据字段,不要直接把序列化后的完整工作簿对象存入数据库。
伪代码参考:
const snapshot = workbook.getSnapshot(); // 提取每个 sheet 的有值单元格 const data = snapshot.sheets.map(sheet => ({ name: sheet.name, cells: sheet.cellData, // 实际字段名以当时版本为准 })); // 发送到服务端保存4.4 协同能力接入:不接入前先想清楚这几点
如果你的项目暂时只做单机编辑,可以跳过协同。但如果要做,需要提前评估三件事:协作用户规模、后端基础设施、离线编辑策略。
我们接入协同连接的流程大致是:
- 前端初始化一个 Yjs 文档,把文档绑定到 Univer 数据模型;
- 使用 y-websocket 或其他 Yjs 传输协议连接后端服务;
- 后端负责把文档更新广播给同一协作房间内的所有前端;
- 前端监听远端更新的到达事件,实时更新表格渲染。
这个过程中最核心的一个设计点是房间与文档的映射。每个协同表格对应一个唯一文档 ID,每次打开表格时,服务端根据文档 ID 查询或创建 Yjs 文档,所有连接到同一文档 ID 的客户端自动进入同一协同空间。
我们调试过程中遇到过一个典型的 CRDT 问题:多人同时修改同一单元格时,不同客户端光标位置和操作顺序会互相影响,偶尔出现短暂的重复输入或闪烁。解决方案是在前端操作层做轻量锁,同一单元格如果已有远端正在编辑,就提示只读模式。这种做法牺牲了一点并发自由度,但极大减少了协同时的认知负担,尤其适合内部工具型系统。
5. 常见问题与排查技巧实录
5.1 性能与渲染类问题速查表
| 问题现象 | 可能原因 | 处理方式 |
|---|---|---|
| 大表格滚动掉帧严重 | 脏区域重绘层级过深或行高列宽不一致 | 检查行高列宽,尽量使用整数值;在初始化时设置合理的默认行高列宽 |
| 第一次打开大文件卡顿 | 数据解析与全量渲染同时进行 | 先解析后渲染;大数据场景分页加载或先加载可视区域数据 |
| 输入单元格内容出现明显延迟 | 每输入一个字符都触发了公式全量重算 | 打开公式链依赖检查,避免超大范围绝对引用公式 |
| 初始化后表格空白 | 容器高度没有设置 | 确保外层容器有显式高度 |
| 内存持续增长 | 未调用 dispose 或重复创建实例 | 生命周期结束记得销毁实例,避免依赖全局变量持有容器引用 |
5.2 协同连接中常见的诡异问题
协同连接出问题,最典型的现象就是两个客户端之间内容不一致,或者一边修改另一边半天不刷新。
第一排查点是 WebSocket 连接是否正常,先看 Network 面板里的 WS 连接状态。如果服务端广播功能正常,大概率问题出在前端 Yjs 文档的监听事件没有正确绑定,或者广播消息没有触发 Univer 的数据变更接口。
第二排查点是房间文档 ID 是否一致。两个客户端各自创建了不同的文档 ID 时,表面看都连接到同一个 WebSocket 服务,但实际上各自操作的是不同的数据副本,内容当然不会同步。这个在我们实际开发中就出现过一次,原因是生成的文档 ID 在 URL query 参数里被编码成了不同的格式,导致同一个文档被识别成了两个房间。
5.3 自定义渲染器与扩展开发要点
Univer 的自定义渲染器允许你基于 Canvas 绘制任意内容到单元格区域。写自定义渲染器时,先定义渲染器的 type,再把它挂载到指定位置的单元格上。
我们做的一个实际扩展是给“订单状态”列的单元格画了状态色条和进度条,效果类似网页应用里的可视化状态标签,交互上比普通文本直观很多。开发过程要注意:
- 绘制时要使用渲染器提供的上下文坐标计算,不推荐硬编码像素位置,因为滚动、缩放后坐标会变化。
- 自定义渲染器需要处理单元格选区重叠、事件穿透等问题。如果你在 Canvas 上画的内容要响应点击事件,还需要额外注册事件区域(hotspot)到交互层。
- 完全自定义渲染后,导出 Excel 时这部分内容不会自动映射为原生格式,记得准备降级方案。
5.4 升级版本时的向后兼容经验
Univer 的 API 在 0.x 阶段变化频率偏高,社区也有反馈说“锁版本”是接触这个项目的必修课。我们团队是这样的流程:
- 正常开发锁定在固定的 minor 版本,不做自动升级;
- 新功能先在小范围灰度分支验证,对照官方 changelog 确认没有破坏性变更再合入;
- 如果一次性跨多个版本升级,优先跑一遍代表性的功能回归,比如合并单元格、公式、筛选排序、导入导出、协同开两个浏览器窗口同时编辑,这些场景最容易暴露兼容问题。
6. 关于 Univer 的一些延展思考
Univer 这类开源表格工具的出现,其实反映了前端办公基础设施的一个趋势:办公套件正在从一次性产品交付向可定制的基础能力库转变。你可以基于它快速搭建一个内部驾驶舱、一个数据填报系统、一个运营分析工具台,而不用从零造“轮子”。
但需要提醒的是,开源并不等于免维护。选型时要把持续维护、社区活跃度、团队技术储备综合看进来。Univer 目前的社区和文档都在快速完善,遇到问题可以直接到 GitHub 提 issue,响应速度整体可以。
我个人在实际使用中的体会是:把 Univer 当作一个深度可定制的“表格内核”来用,而不是期待开箱即用的完整办公产品。先梳理清楚业务对表格能力的真实边界(哪些必须、哪些可选、哪些可以降级),再针对性地做插件扩展和功能裁剪,这样整个落地周期和风险会控制得更好。这也是我们团队这次集成下来最值得分享的一条经验。