ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Univer 开源表格引擎:Canvas 渲染与插件架构实战解析

Univer 开源表格引擎:Canvas 渲染与插件架构实战解析

1. 从“univer”这个标题说起:它到底是什么

第一次看到“univer”这个词,很多人会以为是“universe”的缩写,或者某个开源社区的花名。实际上,在表格与文档协作这个圈子里,Univer 是一个挺有代表性的开源项目——它想做的事情,是把电子表格、文档、幻灯片这类“办公套件”的能力,做成一套可以嵌入到任意 Web 应用里的 SDK。你可以把它理解成“一个可以装进你自己产品里的在线 Excel”。

我最早接触它,是因为团队要在一个内部管理系统里加一块“可编辑的表格”,要求支持公式、单元格样式、多人协同,还得能自己扩展业务字段。当时评估了几条路:一是直接用现成的在线表格产品做 iframe 嵌入,二是基于某商业表格控件二次开发,三是找一个开源内核自己搭。第一条路受制于人,第二条路授权成本高,第三条路就撞上了 Univer。

它解决的核心问题很明确:把“表格内核”和“渲染层”解耦,通过插件架构让你按需拼装能力。底层用 Canvas 做高性能绘制,上层用一套插件体系管理公式、选区、协同、导入导出等模块。对前端开发者来说,这意味着你不需要从零实现一个表格引擎,只需要在它的内核上写业务插件;对 Node.js 后端来说,它也能跑在服务端做公式计算、文件解析这类重活。

这篇文章适合三类人看:一是正在选型“嵌入式表格/文档”方案的前端或全栈工程师;二是想了解 Canvas 绘图引擎与插件架构怎么配合的中高级开发者;三是单纯对“一个在线表格是怎么被造出来的”感兴趣的技术爱好者。我会尽量把原理讲透,把踩过的坑摊开,让你看完能自己动手跑起来一个最小可用的 Univer 实例。

2. 整体架构与设计思路拆解

2.1 为什么是“内核 + 插件”而不是“大单体”

传统在线表格的实现,往往是一个庞大的单体应用:渲染、公式、选区、协同、导入导出全揉在一起。这种结构在功能少的时候跑得挺快,但一旦要裁剪功能或者替换某个模块,就会牵一发动全身。Univer 的设计思路正好相反,它把整个系统拆成两层:核心层(Core)负责最基础的数据模型、命令总线和生命周期管理,插件层(Plugin)负责具体能力。

这么设计的好处,我实际用下来感受最深的有三点。第一是可裁剪,比如你只想要一个只读的表格展示,那公式插件、协同插件都可以不加载,包体积能小一大截。第二是可替换,渲染层理论上可以换,今天用 Canvas,明天想换 WebGL 做超大规模渲染,只要实现对应的渲染接口就行。第三是可扩展,业务方要加一个“审批状态列”,写个插件注册进去即可,不用改内核代码。

代价也有,就是心智负担变高。你得理解命令(Command)、领域模型(Domain)、服务(Service)这几层的关系,不然写插件的时候会不知道某个逻辑该放哪。我的经验是:凡是会改变文档状态的操作,都走命令;凡是纯计算、无副作用的,放服务;凡是需要持久化的数据结构,归领域模型。

2.2 Canvas 渲染引擎的取舍逻辑

Univer 选择 Canvas 而不是 DOM 来渲染表格,这个决定背后是有明确计算的。一个中等规模的表格,假设 100 列 × 1000 行,那就是 10 万个单元格。如果用 DOM,就是 10 万个节点,浏览器的布局和重绘压力会非常大,滚动时掉帧是常态。而 Canvas 是一张画布,所有单元格都是画上去的像素,节点数量恒定为 1,性能瓶颈从“DOM 数量”转移到了“绘制指令数量”。

但 Canvas 也有它的麻烦。最直接的就是事件处理:DOM 里每个单元格都能绑 click,Canvas 里只有一张画布,你得自己根据鼠标坐标反算它落在哪个单元格上。Univer 内部维护了一套坐标映射,把像素坐标转换成行列索引,再派发给对应的选区或编辑器。另一个麻烦是可访问性,屏幕阅读器读不了 Canvas 内容,这块目前开源版本支持有限,如果你的产品有无障碍硬性要求,得提前评估。

还有一个容易被忽略的点:高分屏适配。Canvas 在 Retina 屏上如果不做 devicePixelRatio 缩放,画出来的字会发虚。Univer 的渲染层处理了这个问题,但如果你自己写自定义渲染插件,记得手动乘上 DPR,否则你的自定义图形会和内置内容“两个清晰度”。

2.3 插件架构的注册与通信机制

插件之间怎么通信,是插件架构最容易出问题的地方。Univer 用的是依赖注入 + 命令总线的组合。每个插件在注册时声明自己依赖哪些服务,内核负责把服务实例注入进去。插件之间不直接互相调用,而是通过命令总线发消息。

举个例子,公式插件需要知道当前选区在哪,它不会直接去读选区插件的内部变量,而是订阅“选区变化”这个事件。这样做的好处是解耦:选区插件可以随便重构内部实现,只要事件契约不变,公式插件就不受影响。坏处是调试变难,一个命令发出去,谁响应了、响应顺序是什么,不看日志很难追踪。我一般会在开发阶段打开命令日志,把关键命令的收发都打出来,排查问题时省很多事。

提示:写自定义插件时,尽量把“状态变更”和“副作用”分开。状态变更走命令,副作用(比如发网络请求、写 localStorage)放在命令的 handler 里,这样回放和撤销才好做。

3. 核心细节解析与实操要点

3.1 环境准备:Node.js 与包管理器的选择

Univer 的开发环境对 Node.js 版本有要求,官方推荐 18 LTS 及以上。我实测下来,18.20.4 LTS 和 20.x、22.x 都能跑,但如果你用的是比较老的 16.x,某些依赖会报错。安装步骤不复杂,但有几个细节值得说。

首先是包管理器。Univer 的仓库用的是 pnpm 的 workspace 结构,如果你用 npm 或 yarn 去装,可能会遇到依赖提升(hoisting)导致的“找不到模块”问题。我的建议是直接用 pnpm,版本 8 以上。装完之后先跑pnpm install,再跑构建命令,不要跳过构建直接起 dev server,因为部分包之间有构建顺序依赖。

其次是镜像源。国内网络环境下,装依赖时如果卡在某个包上,可以临时切到国内镜像。但要注意,切镜像后 lockfile 里的 resolved 字段可能会变,团队协作时最好统一,别一个人用镜像一个人用官方源,否则 lockfile 会反复冲突。

# 检查 Node 版本 node -v # 建议输出 v18.20.4 或更高 # 全局装 pnpm npm install -g pnpm@8 # 克隆后安装依赖 pnpm install # 构建核心包(首次必须) pnpm build

3.2 最小可运行实例:把表格嵌进页面

跑通官方示例之后,我建议自己从零写一个最小实例,这样能真正理解各个模块的作用。核心步骤是:创建 Univer 实例、注册需要的插件、挂载到 DOM 容器、准备初始数据。

这里有个容易踩的坑:插件的注册顺序。有些插件依赖另一些插件提供的服务,顺序错了会在初始化时报“service not found”。我的做法是先把基础插件(渲染、选区、UI)注册完,再注册业务插件(公式、协同)。如果实在拿不准顺序,就参考官方 playground 里的注册列表,那个顺序是验证过的。

import { Univer, UniverInstanceType } from '@univerjs/core'; import { defaultTheme } from '@univerjs/design'; import { UniverDocsPlugin } from '@univerjs/docs'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui'; // 创建实例 const univer = new Univer({ theme: defaultTheme, }); // 按顺序注册插件 univer.registerPlugin(UniverDocsPlugin); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsUIPlugin); // 创建表格并挂载 univer.createUnit(UniverInstanceType.UNIVER_SHEET, { id: 'my-sheet', sheetOrder: ['sheet-01'], sheets: { 'sheet-01': { id: 'sheet-01', name: 'Sheet1', cellData: { 0: { 0: { v: 'Hello' }, 1: { v: 'Univer' } }, }, }, }, });

上面这段代码里,cellData的结构是“行索引 → 列索引 → 单元格对象”,这个结构在 Univer 里叫ICellData。v是原始值,f是公式,s是样式引用。理解这个结构很重要,因为后面做数据导入导出、协同同步,都是围绕它来的。

3.3 公式与数据模型的联动细节

公式是表格的灵魂,也是 Univer 里比较复杂的部分。它的公式引擎是独立的一个包,叫@univerjs/sheets-formula。当你注册了这个插件,单元格里的f字段才会被解析和计算。

这里有个设计上的细节值得说:公式的计算结果不会直接覆盖v字段。Univer 把“公式”和“值”分开存,公式存在f里,计算出来的值存在一个单独的缓存里。这样做的好处是,当你修改了被引用的单元格,公式能重新计算,而不是把原来的值冲掉。但这也意味着,如果你直接读cellData拿到的可能是旧值,得通过公式服务去取实时值。

我踩过的一个坑是循环引用。A1 引用 B1,B1 又引用 A1,如果公式引擎没做好检测,会直接死循环卡死页面。Univer 内部有循环检测,但如果你自己写公式函数,记得在依赖收集阶段就做好环检测,别等到计算时才处理。

注意:自定义公式函数时,参数类型校验一定要做。我见过有人写了个MYSUM,传进来一个字符串没做转换,结果整个表格计算崩掉,排查了半天。

4. 实操过程与核心环节实现

4.1 从零搭建一个带公式的表格页面

这一节我把完整流程走一遍,你可以跟着做。假设你已经有一个基于 Vite 的前端项目,Node.js 环境也配好了。

第一步,装依赖。除了核心包,还要装 UI 相关的包,因为表格的工具栏、右键菜单这些都在 UI 包里。

pnpm add @univerjs/core @univerjs/design @univerjs/docs \ @univerjs/sheets @univerjs/sheets-ui @univerjs/sheets-formula \ @univerjs/ui @univerjs/facade

第二步,准备一个容器。HTML 里放一个 div,给它明确的宽高,因为 Canvas 需要知道画多大。

<div id="univer-container" style="width: 100%; height: 600px;"></div>

第三步,初始化。这里我把公式插件也注册进去,并且准备一份带公式的初始数据。

import { Univer, UniverInstanceType, LocaleType } from '@univerjs/core'; import { defaultTheme } from '@univerjs/design'; import { UniverDocsPlugin } from '@univerjs/docs'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui'; import { UniverSheetsFormulaPlugin } from '@univerjs/sheets-formula'; import { UniverUIPlugin } from '@univerjs/ui'; import { FUniver } from '@univerjs/facade'; const univer = new Univer({ theme: defaultTheme, locale: LocaleType.ZH_CN, }); univer.registerPlugin(UniverUIPlugin, { container: 'univer-container', }); univer.registerPlugin(UniverDocsPlugin); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsUIPlugin); univer.registerPlugin(UniverSheetsFormulaPlugin); univer.createUnit(UniverInstanceType.UNIVER_SHEET, { id: 'demo-sheet', sheetOrder: ['sheet-01'], sheets: { 'sheet-01': { id: 'sheet-01', name: '销售数据', cellData: { 0: { 0: { v: '月份' }, 1: { v: '销售额' }, 2: { v: '成本' }, 3: { v: '利润' } }, 1: { 0: { v: '一月' }, 1: { v: 12000 }, 2: { v: 8000 }, 3: { f: '=B2-C2' } }, 2: { 0: { v: '二月' }, 1: { v: 15000 }, 2: { v: 9000 }, 3: { f: '=B3-C3' } }, 3: { 0: { v: '三月' }, 1: { v: 18000 }, 2: { v: 11000 }, 3: { f: '=B4-C4' } }, 4: { 0: { v: '合计' }, 1: { f: '=SUM(B2:B4)' }, 2: { f: '=SUM(C2:C4)' }, 3: { f: '=SUM(D2:D4)' } }, }, }, }, }); // 通过 Facade API 操作表格 const fUniver = FUniver.newAPI(univer);

跑起来之后,你应该能看到一个带工具栏的表格,D 列的利润会自动算出来,第五行的合计也会自动汇总。如果公式没生效,先检查UniverSheetsFormulaPlugin有没有注册,再检查公式字符串是不是以=开头。

4.2 自定义插件:给表格加一个“业务状态列”

光用内置功能还不够,实际项目里总得加点自己的东西。我拿一个真实需求举例:给表格加一列“审批状态”,用不同颜色标记,并且点击时能切换状态。

这个需求拆解下来有三块:一是数据存储,状态存在哪;二是渲染,怎么把状态画成带颜色的标签;三是交互,点击怎么响应。Univer 的插件体系里,渲染可以通过自定义渲染器实现,交互可以通过命令实现。

先定义数据结构。我选择把状态存在单元格的custom字段里,这样不会污染v和f。

// 状态定义 const STATUS = { PENDING: { text: '待审批', color: '#faad14' }, APPROVED: { text: '已通过', color: '#52c41a' }, REJECTED: { text: '已驳回', color: '#ff4d4f' }, };

然后是渲染。Univer 的渲染层允许你注册自定义的单元格渲染器,根据单元格数据决定画什么。这里的关键是拿到 Canvas 的绘制上下文,然后按坐标画矩形和文字。

class StatusCellRenderer { draw(ctx, cellData, style, position) { const status = cellData?.custom?.status; if (!status) return false; const config = STATUS[status]; const { x, y, width, height } = position; // 画背景 ctx.fillStyle = config.color + '20'; ctx.fillRect(x + 2, y + 2, width - 4, height - 4); // 画文字 ctx.fillStyle = config.color; ctx.font = '12px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(config.text, x + width / 2, y + height / 2); return true; // 表示已处理,不再走默认渲染 } }

交互部分,监听单元格点击事件,判断点的是不是状态列,是的话就切换状态并发送命令更新数据。这里要注意,更新数据一定要走命令,直接改cellData不会触发重绘,也不会进撤销栈。

univer.onCellClick((params) => { const { row, col } = params; if (col !== STATUS_COLUMN_INDEX) return; const current = getStatus(row); const next = current === 'PENDING' ? 'APPROVED' : 'PENDING'; // 通过命令更新 univer.executeCommand('sheet.command.set-cell-custom', { row, col, custom: { status: next }, }); });

4.3 数据导入导出与 Node.js 端计算

实际项目里,表格数据往往要和后端交互。Univer 支持导入导出 Excel 文件,这块能力在@univerjs/sheets-import之类的包里。但我想说的是另一个场景:在 Node.js 服务端做公式计算。

有些业务需要在服务端校验用户填的公式是否正确,或者批量计算报表。Univer 的核心包是可以在 Node.js 里跑的,因为它不依赖 DOM。你可以在服务端创建一个 Univer 实例,加载数据,执行计算,再把结果返回。

// Node.js 端示例 const { Univer, UniverInstanceType } = require('@univerjs/core'); const { UniverSheetsPlugin } = require('@univerjs/sheets'); const { UniverSheetsFormulaPlugin } = require('@univerjs/sheets-formula'); const univer = new Univer(); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsFormulaPlugin); univer.createUnit(UniverInstanceType.UNIVER_SHEET, sheetData); // 触发计算并读取结果 const sheet = univer.getActiveSheet(); const value = sheet.getCellValue(4, 3); // 读取 D5 的计算结果 console.log(value);

这个能力挺实用,但有个坑:服务端和客户端的公式引擎版本要一致。如果客户端用的是新版本,服务端用的是旧版本,某些新函数可能算不出来。我的做法是把版本号写进 package.json 的 engines 字段,部署时校验。

5. 常见问题与排查技巧实录

5.1 表格不显示或白屏

这是新手最常遇到的问题。排查顺序我总结成一张表:

现象可能原因排查方法
完全白屏容器没有宽高检查 div 的 style,必须有明确高度
有工具栏无表格渲染插件没注册确认 UniverSheetsUIPlugin 已注册
表格显示但无内容数据格式不对检查 cellData 的行列索引是否为数字
控制台报 service not found插件顺序错调整注册顺序,基础插件在前
高分屏文字模糊未处理 DPR检查渲染配置,或升级到最新版

我遇到过一次白屏,排查了半天发现是容器 div 被父元素的display: none影响了,初始化时拿不到尺寸。后来改成先显示再初始化,问题解决。所以初始化时机很重要,确保容器已经渲染且有尺寸。

5.2 公式计算不生效或结果错误

公式问题一般分三类:一是公式根本没被解析,二是解析了但算错,三是算对了但没刷新。

第一类,检查公式插件是否注册,以及公式字符串是否以=开头。Univer 不会把SUM(A1:A3)当公式,必须是=SUM(A1:A3)。

第二类,检查单元格引用格式。Univer 用的是 A1 表示法,但行列索引是从 0 开始的。如果你在代码里手动构造公式,注意别把索引和表示法搞混。

第三类,检查是否触发了重算。修改被引用单元格后,公式应该自动重算。如果没重算,可能是命令没有正确发出,或者公式依赖图没更新。可以手动调一下sheet.calculate()强制重算。

提示:调试公式时,可以先把公式简化,比如把=SUM(B2:B4)改成=B2,确认基础引用没问题,再逐步加复杂度。

5.3 性能问题:大数据量下的卡顿

Univer 用 Canvas 渲染,理论上能扛比较大的数据量,但也不是无限的。我实测下来,几千行以内体验流畅,上万行开始有感知,十万行以上就需要做虚拟滚动了。

性能优化有几个方向。一是减少不必要的重绘,Univer 内部有脏矩形机制,但如果你自定义渲染器每次都全量画,就会拖慢。二是公式计算优化,大量公式的依赖图维护是有成本的,可以考虑把不常变的公式结果缓存起来。三是按需加载,只加载可视区域的数据,滚动时再拉取。

还有一个容易被忽略的点:事件监听的数量。如果你给每个单元格都绑了监听,那性能肯定崩。正确做法是在画布级别绑一个监听,然后根据坐标派发。

5.4 协同场景下的冲突处理

多人协同是表格的进阶需求。Univer 的协同方案基于操作变换(OT)或 CRDT,具体取决于你用的协同插件。这块的坑主要在冲突解决策略上。

举个实际例子:A 用户把 B2 改成 100,B 用户同时把 B2 改成 200。如果没有冲突处理,后到的会覆盖先到的。Univer 的协同层会记录操作的时间戳和来源,按策略合并。但如果你自己实现协同,一定要想清楚“后写覆盖”还是“先写优先”,这直接影响用户体验。

我的建议是,协同场景下尽量用命令而不是直接改数据,因为命令带有完整的上下文信息,协同层才能正确合并。直接改数据的话,协同层不知道你改了什么,只能全量同步,效率低还容易冲突。

6. 一些实操心得与后续扩展方向

用 Univer 做项目这段时间,我最大的体会是:它的学习曲线不在 API 上,而在架构理解上。API 看文档就能会,但插件之间怎么协作、命令怎么流转、渲染怎么触发,这些得自己动手写几个插件才能摸清楚。我的建议是,别一上来就啃源码,先照着官方示例改,改着改着就通了。

另一个心得是版本管理。Univer 还在快速迭代,不同版本之间 API 可能有 breaking change。我吃过一次亏,升级了一个小版本,结果某个插件的注册方式变了,页面直接崩。后来我养成了习惯:升级前先看 changelog,升级后在测试环境跑一遍核心流程,没问题再上生产。

后续如果要扩展,我觉得有几个方向值得试。一是自定义渲染器做数据可视化,比如在单元格里画迷你折线图、进度条,这个用 Canvas 做比 DOM 灵活得多。二是服务端计算集群,把重公式计算放到 Node.js 集群里,前端只负责展示。三是和低代码平台结合,把 Univer 当成一个“表格组件”注册到低代码引擎里,用户拖拽就能用。

最后分享一个小技巧:调试渲染问题时,可以临时把 Canvas 的globalAlpha调低,看看底层绘制顺序,有时候图层叠错了,一眼就能看出来。这个法子帮我省了不少排查时间。

RELATED READING

延伸阅读

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