ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Univer开源表格引擎实战:Canvas架构、插件化设计与React集成指南

Univer开源表格引擎实战:Canvas架构、插件化设计与React集成指南

如果你所在的公司或团队尝试过自己搭一套在线表格系统,大概率会经历这样的循环:先用 DOM 渲染单元格,交互好做,但数据量一上来就卡成 PPT;咬着牙换成 Canvas 重写,刚把格子画出来,发现公式、样式、快捷键、协同编辑全是坑,每一个都能耗掉一个季度。我自己也是踩完这些坑之后,偶然在 GitHub 上翻到了 Univer 这个开源项目,随后把它引入到了实际业务里,前后跑了几个月,今天想把它的核心架构、落地过程和踩坑经验一次性整理出来,给正在考虑自研表格或接入在线编辑能力的同学做个参考。

Univer 是一个面向 Web 的场景化文档引擎,核心能力是电子表格(Sheets),同时也支持文档(Docs)和幻灯片(Slides),而它最吸引我的地方在于:整套东西用 TypeScript 从零实现,渲染层基于 Canvas,交互层走的是命令模式,并且整个功能体系由插件驱动。你可以把它理解成一个“前端界的开源 Office 小程序内核”,可以在几秒钟内嵌入到你现有的 React、Vue 或原生项目中,用来做在线表格、数据录入、报表编辑、甚至多人在线协同。适合谁用呢?主要是四类人:想在企业后台里增加在线表格能力的前端工程师;在评估自研还是集成开源方案的技术负责人;用过 Luckysheet 但发现性能和扩展性不够的老用户;以及单纯想研究现代前端电子表格如何实现的学生和研究者。

1. 项目概述:Univer 到底是什么,解决了什么问题

1.1 一句话理解 Univer

如果你用过 Google Sheets,那 Univer 的体验就很接近那个东西:一个跑在浏览器里的电子表格应用,能输入数据、写公式、合并单元格、设置条件格式、插入图表、筛选排序,也能通过协作能力实现多人同时编辑。但从架构角度,Univer 不是“一个应用”,而是一个 SDK,你可以通过 npm 把它装进自己的项目,给它传入容器和配置,它就变成一个可交互的表格界面。这和那种“直接给你一个现成系统”的开源项目有本质区别,前者是可嵌入、可剪裁的组件,后者是固定的服务。

纯前端实现意味着它不依赖后端软件——你的系统里没有 Java、没有 Go,只有静态资源也能跑起来。而且因为 Univer 暴露了丰富的数据结构和命令接口,你在业务里可以把表格数据当成一个受控组件来使用。比如说用户改了单元格,你能拿到命令对象,就知道谁在哪个位置改了什么值,这种监听能力是企业级场景非常需要的基础能力。

1.2 为什么企业和团队会需要它

我接触过的很多项目里,“在线编辑表格”并不是核心产品功能,而是支撑功能的工具模块。举个例子:后台审批系统,需要让员工作录入指标数据,然后财务部门在线复核;又比如教学管理系统,老师要能把成绩单粘贴到浏览器里保存,还能按规则计算总分和排名。这些场景如果自己从头做,工程量非常大。

Univer 的价值就在于把工程量压缩了一大截:表格渲染、编辑交互、键盘导航、公式计算、格式拷贝、打印导出这些基础设施,已经有人替你做好了。你只需要关心数据和业务逻辑。另外,它还有一个不可忽视的优势:权限和扩展都掌握在自己手里。用在线文档 SaaS 服务,数据要从自己服务器过一遍,而且用户的敏感数据往往不允许传到第三方;而 Univer 部署在你的前端资源里,数据是否上报、上传到哪里,都由你控制,这是很多政企项目中必须要跨过的门槛。

1.3 开放能力与生态现状

不少人对 Univer 的初印象停留在“一个炫酷的 Demo”,真正用起来才发现它的插件体系和命令系统相当完整。官方把能力拆成了多个 npm 包,比如核心包负责数据模型与命令,Sheet 包负责电子表格功能,UI 包负责工具栏和渲染界面,还有单独的公式、数据验证、透视表等扩展包。你可以只引入核心和 Sheet 逻辑,自己写一套高度定制的界面;也可以直接使用官方 UI,快速获得 Excel 风格的操作体验。截止我落地实践的时间点,它在 GitHub 上的活跃度非常高,release 节奏很快,社区里已经有大量第三方插件案例,但也要客观承认:它迭代速度快,不同版本的 API 有差异,使用前需要认真锁定版本并翻阅对应文档。

2. 设计思路拆解:Univer 的架构为什么这样设计

2.1 Canvas 渲染:从 DOM 到 Canvas 的取舍

做在线表格的人首要面对的问题就是渲染层。传统 DOM 方案在 500 行以内的小表格上没有问题,一旦达到几万行、几十列,DOM 节点数量会瞬间爆炸,浏览器在重排和重绘上的开销会让滚动掉帧。Univer 的核心渲染采用 Canvas,整个可视区域通过一个或多个 Canvas 图层来绘制,只渲染视口内的单元格,滚动时动态更新绘制区域。这和游戏引擎的思路比较像:不是“有多少数据就建多少节点”,而是“眼睛能看到多少就画多少”。

这种取舍带来的收益非常明显,我在实际项目中用 10 万行数据做滚动测试,滚动流畅度依然不错,单元格内容和边框都能稳定渲染。代价也有:Canvas 下 DOM 事件的“命中测试”不能天然按单元格响应,Univer 自己实现了坐标计算和事件分发,所以你在使用事件 API 时会觉得它更像是组件层提供的自定义事件,而不是浏览器原生 click。理解了这一点,你就不会在集成到框架时去纠结“为什么单元格里没有 DOM 节点”。

2.2 插件化架构:核心、功能、UI 三层解耦

Univer 的设计灵感借鉴了现代前端应用常见的分层思路,把“数据模型”“业务功能”“界面表现”三者分开。最底层是 @univerjs/core,负责工作簿、工作表、单元格、范围、样式等数据模型的定义,以及命令系统和撤销重做队列。它不依赖 DOM,因此可以在 Node.js 环境甚至 Web Worker 中独立运行,这是做服务端计算和协同逻辑的基础。

往上走是功能层,例如 @univerjs/sheets 提供了电子表格领域的功能逻辑,包括公式注册、选区管理、行列操作、剪贴板处理等。再往外就是 UI 层,工具栏、右键菜单、下拉面板、甚至整个表格的外观区域都放在这里。你可以在注册插件时选择是否挂载 UI,如果你只想做 Headless 的数据处理,完全可以不加载任何界面相关的依赖,直接在浏览器后台运行一个 Univer 实例,接收业务侧传入的命令,然后输出计算结果——这个场景我在后文会详细说明。

2.3 命令模式与前端数据流

如果你写过 Redux,会对 Univer 的命令系统感到亲切。业务上的每一次操作,不管是输入文字、删除行、修改列宽,还是到很远处的某个 Sheet 粘贴数据,都会生成一个命令对象,内部通过 dispatcher 派发,最终落到数据模型上。命令对象是序列化的,意味着你可以轻松实现撤销重做(Univer 默认就是支持快捷键 Ctrl+Z/Ctrl+Y 的),也意味着你可以记录操作日志、做操作回放,甚至把命令发送给其他客户端来完成协同。

这套设计对二次开发者极其友好。你不必自己监听各种 UI 事件再去手动修改数据,而是可以注册一个自定义命令,把它绑定到菜单、工具栏按钮或者快捷键上。自定义命令内部可以组合已有的核心命令,也可以直接修改数据模型。我后来的项目里,很大一部分业务逻辑都变成了“命令的注册与编排”,代码结构非常清晰,测试也方便——不需要打开浏览器就能跑核心逻辑的单元测试。

3. 环境准备与快速上手:从零搭建第一个 Univer 实例

3.1 安装依赖

上手时请先确定你的包管理器是 npm 还是 pnpm,然后以你安装时锁定到的版本为准。就我用的 v0.x 系列而言,最小集合需要这样几个包:

npm install @univerjs/core @univerjs/sheets @univerjs/sheets-ui @univerjs/ui @univerjs/design

其中 @univerjs/design 提供主题变量和基础组件样式,@univerjs/ui 是 UI 插件,@univerjs/sheets-ui 负责把表格组件渲染到页面。如果你还需要公式、查找替换、数据验证等功能,可以再补对应的插件包。需要注意,Univer 的 npm 包是严格按功能拆分的,你不必一次性装全所有东西,安装前最好对照文档确认插件之间的依赖关系。

3.2 初始化实例

创建一个基础实例的代码并不复杂。我提供一个相对保守的写法,它适用于我实际跑通过的一个稳定版本:

import { Univer, UniverInstanceType } from '@univerjs/core'; import { defaultTheme } from '@univerjs/design'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui';

当然,不同版本可能要求你引入@univerjs/ui/zh-CN作为 locale,或者在注册插件时传入更多的初始化配置,这些都是正常的版本差异。下面的核心思路是不会变的:

const univer = new Univer({ theme: defaultTheme, }); univer.createUnit(UniverInstanceType.UNIVER_SHEET, { name: '第一个表格', sheets: { sheet1: { name: 'Sheet1', cellData: { 0: { 0: { v: 'Hello Univer' }, 1: { v: 9527 }, }, }, }, }, }); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsUIPlugin, { container: 'app', });

这段代码里,Univer是全局入口,createUnit创建了一个电子表格单元,里面通过cellData以“行号-列号-单元格对象”的嵌套结构塞入了初始数据。cellData[0][0]表示第 1 行第 1 列的数据,值为字符串“Hello Univer”;cellData[0][1]是第 1 行第 2 列,值为数字 9527。这种数据结构在后续读写单元格时非常常见,建议一开始就把它看熟。

3.3 让表格出现在页面上

上面代码中,container: 'app'其实就是你 HTML 页面上的某个 DOM 元素的 id。只要这个容器有一定高度和宽度,Univer 就会在初始化完成后自动渲染出完整的表格界面。如果你发现页面上一片空白,大概率是容器没有设置尺寸,或者在 DOM 尚未挂载完时就去初始化了。我的习惯是把初始化放在useEffect或DOMContentLoaded之后执行,并且给容器显式设置一个height: 100%之类的样式,避免宽高为 0 的尴尬。

为了确认实例创建成功,你可以在浏览器控制台观察univer.getActiveUnit()的返回值,它对应着你创建的表格单元,后续读写操作都基于它展开。

4. 核心 API 与关键配置详解

4.1 认识数据集:cellData 与工作表结构

Univer 的电子表格数据模型是:workbook(工作簿)包含多个worksheet(工作表),每个工作表又包含若干行列的单元格。在创建时传入的sheets就是工作表集合,sheet1是内部标识,name是展示在工作表标签上的名称。cellData是一个以行索引为键、再以列索引为键的嵌套对象,单元格对象里的v字段代表 value,也就是单元格显示和存储的值;此外还有s(style)、t(type)、f(formula)、m(raw value)等字段。

举个例子,如果我想在第一行前两列分别写入文本和数字,第三列写一个公式:

cellData: { 0: { 0: { v: '姓名', s: { b: { b: true } } }, // 第1行第1列,加粗 1: { v: '张三' }, 2: { f: 'SUM(C3:C5)' }, // 只丢公式,Univer 会自动计算 }, }

要注意的是,不同版本里公式字段的写法可能不完全一样。旧版本用f表示公式字符串,新版本可能要求你显式指定v或通过t来区分数据类型。我在实际使用中通常会先小范围测试公式,确认当前版本的字段约定再批量写入数据,避免一次写太多又猜错结构。

4.2 实时操作数据:命令方式与 API 方式

Univer 提供了两种操作数据的路径。一种是走命令系统,比如在工具栏里操作 Excel 一样,编程侧也可通过触发命令来模拟用户行为;另一种是直接调用数据 API 修改数据模型。命令方式的好处是会自动纳入撤销重做记录,适合需要用户操作痕迹的场景。而 API 方式更快,适合批量导入、程序化初始化数据的场景。

以“在某单元格写入数据”为例,命令方式大致是:

const workbook = univer.getActiveUnit(); const worksheet = workbook.getActiveSheet(); const command = { type: 'set-range-values', payload: { range: { sheetId: worksheet.getSheetId(), startRow: 1, startColumn: 1, endRow: 1, endColumn: 1 }, values: [[{ v: '新值' }]], }, }; univer.getCommandService().execute(command);

如果你只是想快速改数据、不关心撤销记录,也可以直接拿到单元格对象来改:

worksheet.getCellMatrix().getValue(0, 0)?.v // 读取 worksheet.getCellMatrix().setValue(1, 1, { v: '你好' }) // 写入

不过,直接改数据模型的后果是 UI 不一定能自动同步,因为 Univer 的数据变更要经过内部事件系统广播。我在实践中,如果只是初始数据导入,就调用专门的数据变更接口或直接传 cellData;如果是用户操作产生的业务联动,一律走命令系统,这样界面和撤销栈都能保持正常。

4.3 样式、合并单元格与行列操作

表格不能只会写字,样式和结构操作用得非常多。样式字段s本质上是一个样式对象,包含b(边框)、f(字体)、bg(背景色)、fs(字号)、ht(水平对齐)、vt(垂直对齐)等,键名在不同版本里有些差异,但大方向不变。合并单元格一般通过定义merge字段来声明,或者在 UI 上选中若干单元格后点击合并按钮,对应数据模型里会生成一条 merge 配置。

如果你要在某个位置插入一行,可以用工作表的插入行列命令;删除则对应 remove row/col。这些操作用命令实现后天然支持撤销。我在做报表模块时遇到最多的需求是“隔行变色”“条件格式高亮”这类视觉处理,Univer 的条件格式插件可以直接覆盖,但如果没有额外引入,也可以自己在数据生成阶段把样式字段算好写入cellData,这个方式是兜底方案,缺点是无法响应用户编辑后的实时变化,需要监听编辑事件再重算。

4.4 公式引擎与外置计算

Univer 内置了一套公式引擎,支持常见的数学、统计、文本、日期、逻辑函数以及跨表引用,这对业务开发来说是一个意外惊喜。你不需要把公式计算抛给服务端,纯前端即可算出结果。比如单元格里写入SUM(A1:A100),Univer 会立即计算并渲染结果,数据变化时自动重算。如果你做的是一个“离线可用”的表格工具,这个能力几乎可以替代一个小型后端计算服务。

不过要留意复杂表格的性能。当一个 Sheet 里公式数量达到成千上万,而且公式之间互相引用时,修改一个单元格可能触发全链路重算。Univer 本身做了增量计算优化,但业务侧最好尽量控制公式范围,避免在单个工作表里灌入数十万条公式。另外我建议在创建公式时使用明确的边界范围,少写整列引用,因为这不仅是性能问题,还会让表格文件在导出时体积膨胀。

5. 进阶实操:在 React 项目中深度集成 Univer

5.1 封装一个可复用的 Univer 组件

真实项目中,你不会裸调 Univer,而是会封装成框架组件。以 React 为例,我的做法是用useRef保存 Univer 实例,在useEffect中完成创建和销毁,避免重复初始化导致多个实例叠加:

import React, { useRef, useEffect } from 'react'; const ExcelView = ({ data, onChange }: { data: any; onChange?: (data: any) => void }) => { const containerRef = useRef<HTMLDivElement>(null); const univerRef = useRef<any>(null); useEffect(() => { if (!containerRef.current || univerRef.current) return; const univer = createUniver({ container: containerRef.current, data }); univerRef.current = univer; return () => { univer.dispose?.(); univerRef.current = null; }; }, []); return <div style={{ width: '100%', height: '500px' }} ref={containerRef} />; };

需要注意的是,data只作为初始值传入。如果你在组件外部用状态管理数据,不要天真地以为每次data变化就能同步给表格,因为 Univer 内部有自己的数据模型。正确做法是监听表格的编辑事件,把最新的单元格数据传给业务层,由业务层决定是否需要持久化;当业务层要强制刷新数据时,再调用 Univer 的数据导入接口覆盖当前内容。

5.2 监听编辑事件并同步业务数据

官方提供了监听单元格变化的 API,核心思路是订阅命令执行结果或等价的变更事件。例如我可以这样写:

univer.on('command:executed', (command) => { if (command?.id === 'sheet.command.set-range-values') { const values = extractValues(command); onChange?.({ type: 'cell-change', values }); } });

不同版本的事件名可能有差异,建议直接用univer.getCommandService().onCommandExecuted()这样的回调去过滤命令,然后解析命令里的range和values。这里有个小技巧:不要每个命令都直接上报整张表的数据,那样数据量大且没有意义。我一般只解析用户实际影响到的行列范围,组装成一个小对象增量上报,这样后端存储的压力会小很多。

5.3 自定义工具栏按钮与业务功能注入

Univer 的 UI 是插件化的,你可以通过工具栏按钮配置来增加自己的入口。以我做的“保存为 PDF”按钮为例,大致思路是注册一个自定义命令,再把它添加到 toolbar:

const commandId = 'business.export-pdf'; univer.getCommandService().registerCommand({ id: commandId, type: 0, // 根据版本定义 handler: async (accessor) => { const activeSheet = accessor.get(UniverInstanceType.UNIVER_SHEET); const snapshot = activeSheet.getSnapshot(); // 这里走业务侧导出逻辑 await exportToPdf(snapshot); }, }); univer.registerPlugin(UniverSheetsUIPlugin, { container: 'app', toolbar: [ 'exportBtn' ], });

自定义按钮需要配合 UI 扩展的配置项来定义图标和点击行为。这部分 API 在不同版本中变化比较明显,我的建议是把工具栏配置集中在一个常量文件里,升级版本时只需改这一处,不用满项目去找。业务功能通过命令注册以后还有一个好处,就是撤销栈里你也能看到自己命令留下记录,用户按 Ctrl+Z 还能把你导出 PDF 这类业务操作撤掉——如果不想这样,就必须在命令定义时剔除撤销记录,这点要看具体的配置选项。

5.4 数据导入导出:处理 Excel 文件

Univer 本身偏重数据模型和交互,它不完全等同于完整的 Excel 解析器,但项目生态里已经提供了针对 Excel 文件的导入导出能力。常见方式是使用 @univerjs/excel 这类包,或在服务端用成熟库(如 SheetJS)完成格式转换后,再以 Univer 的数据结构导入。我在实际项目里的做法是:

  1. 用户上传.xlsx文件,前端先把文件二进制交给解析库,转换为 JSON 数据。
  2. 将 JSON 按 Univer 的cellData、styles、merges等结构整理到工作簿内。
  3. 调用 Univer 的数据导入接口或重新执行createUnit来渲染。
  4. 导出时反向操作:从 Univer 拿数据快照,交给服务端生成 Excel 文件。

这个链路最麻烦的不是 Univer,而是 Excel 文件里千奇百怪的样式和函数。Univer 支持的能力有限,遇到一些特殊的图表、宏、图片,可能出现轻微错位或缺失,这是目前所有纯前端表格组件都不可避免的,取舍上要早点和产品对齐。

6. 常见问题与避坑指南

6.1 初始化后一片空白怎么办

遇到空白,先按下面顺序排查:

  • 容器是否有非零宽高。设置宽高后刷新看效果。
  • 是否在 DOM 尚未挂载时执行了初始化。React 的话要放在useEffect里,不要直接放在 render 阶段。
  • 插件是否注册完整。只注册了核心和 Sheet 插件,没有注册 UI 插件,是不会有界面的。
  • 是否引入样式。部分版本要求你引入@univerjs/sheets-ui/lib/index.css或对应主题样式,漏掉也会导致空白。

我曾经在这上面卡了半小时,最后发现是container的 id 写错,容器根本没找到。建议初始化前先用document.getElementById验证一下。

6.2 单元格数据更新但界面不刷新

这个问题通常发生在“直接修改数据模型”的场景中。Univer 内部的数据模型不一定和渲染层实时联动,因为修改绕过命令系统等于绕过了事件总线。解决办法就是我前面强调的:统一走命令系统修改数据,或者调用官方提供的数据更新 API。千万不要图快直接改内部对象,否则你看到的 UI 状态和数据状态会打架。

6.3 大数据量下的性能优化

虽然 Canvas 渲染已经解决了大部分性能问题,但业务代码如果写得粗糙,依然会拖垮表格。我的经验:

  • 初始化时不要一次性给几十万个单元格都塞对象,只塞有数据的单元格,空单元格不需要显式定义。
  • 监听编辑事件时不要全量序列化工作簿,只解析变化区域。
  • 公式尽量控制数量,尤其是带复杂引用的公式。
  • 如果确实需要超大数据量,考虑把大部分数据放在后端,表格只展示视口内的数据分页。
  • 遇到极端情况,Web Worker 中跑核心计算,把最终结果同步回 UI 层,避免阻塞主线程。

6.4 不同版本 API 不兼容的应对

Univer 迭代很快,在我写作的时间点,项目仍处于高频开发阶段。你看到最新版文档和几个月前博客里的示例可能对不上。应对方法就三条:

  • 安装时锁定精确版本号,不要用latest或^放任更新。
  • 以官方文档和仓库内的示例代码为准,第三方教程只能作为思路参考。
  • 升级版本时跑一遍核心用例,比如初始化、写入数据、公式计算三条链路,有问题第一时间看 changelog。

6.5 协同编辑的注意点

Univer 提供了协同编辑相关的能力,但真正接入时你需要自己想清楚后端方案。两种常见的做法:一种是基于命令的 Operation Transform(OT)思路,把每次命令广播给其他客户端并对命令进行转换;另一种是房间制,也就是操作者拿到锁进行互斥编辑,实现简单但实时性弱。Univer 本身提供了命令同步的基础设施,业务侧需要自己实现 WebSocket 服务和冲突处理。这块一旦数据量大、用户并发高,复杂度会明显上升,建议评估团队是否有足够经验再动工。

7. 最后的经验补充:什么场景适合大胆用,什么场景要谨慎

用 Univer 做常见的企业后台内嵌表格,基本没有问题。我自己的体验是:渲染性能、基础编辑体验、公式计算、样式处理都达到了可直接交付的水平。尤其是它把核心和 UI 分离的设计,让我能在一个无头环境里跑数据计算,在另一个带 UI 的环境里给用户操作,这种灵活性在自研方案里很难实现。

需要谨慎的场景主要有三类:一是对 Excel 格式兼容性要求极高的商业场景,例如金融报表、审计底稿,这类需要保真度极高的文件来回转换,纯前端组件仍然存在细节缺失;二是需要离线安装、离线使用的桌面级应用,目前 Univer 的包装体验还不够成熟;三是协同编辑复杂度和用户规模都特别大的场景,虽然具备基础能力,但距离大型商业协同表格的稳定性还有一定差距。

如果你正准备在自己的项目里尝试 Univer,我的建议是:不要一开始就追求完整复刻 Excel,先把业务里最常用到的 20% 功能跑通,用命令和数据 API 封装出自己的业务层,再逐步扩展到更复杂的能力。这样既能让项目快速交付,又能给后续功能留出充足空间。最后再分享一个小技巧:把 Univer 的实例封装成一个全局单例,业务模块之间共享同一个表格实例,而不要在每个弹窗里都 new 一个——否则浏览器内存和事件监听都会出现肉眼可见的飙升,这个坑我踩过两次才反应过来。

RELATED READING

延伸阅读

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