2026/11/3 15:50:21

Univer 在线电子表格架构解析:Canvas 渲染与插件体系实战

Univer 在线电子表格架构解析:Canvas 渲染与插件体系实战 电子表格这个东西几乎每个开发者都跟它打过交道。早些年做企业管理系统表格组件基本就是买商业授权或者用开源方案凑合但总有几个绕不过去的坎要么性能拉胯几万行数据直接卡死要么扩展性差想加个自定义公式、协同编辑得把源码翻个底朝天。后来我开始关注Univer这个项目它定位是在线电子表格的完整解决方案不是简单的 UI 组件而是一套从数据模型、渲染引擎到插件体系的完整架构。关键词里提到的 SDK、Node.js、Canvas、插件架构基本勾勒出了它的技术轮廓。这篇文章不打算写成官方文档的翻译而是从我实际拆解和试用的角度把 Univer 的架构设计、Canvas 渲染机制、插件体系、以及落地时会遇到的坑掰开揉碎了讲清楚。如果你正在选型在线表格方案或者单纯对 Canvas 高性能渲染感兴趣这篇内容应该能帮你省下不少翻源码的时间。1. Univer 到底解决的是哪一类问题1.1 在线表格的三层需求拆解很多人第一次听到 Univer会下意识把它和 Handsontable、AG Grid 这类表格组件放一起比较。但实际用下来会发现它们根本不在一个层面上。表格组件的核心是把数据展示出来支持排序筛选编辑而 Univer 要解决的是如何构建一个完整的在线电子表格应用。这两者的差距就像造一扇门和盖一栋楼的区别。我把在线表格的需求拆成三层来看。最底层是数据层要处理单元格数据、公式计算、格式信息、行列结构还要支持撤销重做、协同冲突解决。中间层是渲染层要在浏览器里把成千上万个单元格画出来还得保证滚动流畅、选区响应及时。最上层是应用层包括菜单栏、工具栏、右键菜单、公式栏、协同光标这些交互组件。市面上大多数表格组件只做了渲染层和部分数据层应用层完全交给你自己搭。Univer 的野心在于它把这三层全部包了而且每一层都留了扩展接口。这个定位带来的直接好处是你不需要从零搭建一套表格应用的骨架。但它也意味着学习曲线更陡因为你要理解它的架构约定而不是简单地传个 data 数组就完事。1.2 从组件思维切换到平台思维我刚开始接触 Univer 时最大的思维转变在于不能把它当成一个组件来用而要把它当成一个运行时平台。传统组件的用法是我传数据进去它渲染出来我监听事件数据流向是单向的、受控的。Univer 的用法是我创建一个实例注册一堆插件然后通过 Facade API 去操作它数据流向是双向的、事件驱动的。举个例子在传统表格组件里你想改一个单元格的值直接调setCellValue(row, col, value)就完了。在 Univer 里你需要先拿到对应 Sheet 的 Facade 对象然后通过命令系统去执行修改。为什么要这么绕因为命令系统是撤销重做和协同编辑的基础。每一次修改都被封装成一个 Command带上撤销信息和协同标识这样才能保证多人编辑时的一致性。这个设计在单机场景下显得有点重但一旦涉及协同它的价值就体现出来了。提示如果你只是想要一个静态展示表格的组件Univer 可能过度设计了。但如果你要做的是类似在线 Excel 的产品它的架构优势会非常明显。1.3 谁适合用 Univer谁不适合从我的经验来看Univer 适合这几类场景一是要做在线协同表格的团队它的命令系统和协同架构能省掉大量底层工作二是需要深度定制表格行为的产品比如自定义公式引擎、特殊的数据校验规则插件体系让你不用改核心代码三是技术选型上希望长期可控的项目Univer 是开源 Apache 2.0 协议核心代码可读可改。不太适合的场景也很明确如果只是要在管理后台里嵌一个简单的数据表格用 AG Grid 或者 Element Plus 的 Table 组件就够了上 Univer 属于杀鸡用牛刀。另外如果你的团队对 Canvas 渲染和响应式编程不熟悉前期踩坑成本会比较高。我见过有团队花了两个月才把 Univer 跑通主要时间都花在理解它的响应式数据流上。2. Canvas 渲染引擎Univer 性能的底牌2.1 为什么不用 DOM 而选 Canvas这是 Univer 架构里最关键的决策之一。传统表格组件大多用 DOM 来实现每个单元格是一个td或者div靠浏览器自身的渲染能力来展示。这种方式在数据量小的时候没问题但一旦单元格数量上万DOM 节点数爆炸浏览器的布局和重绘开销就会让页面卡到无法使用。我实测过用 DOM 方案渲染五万行数据滚动时帧率直接掉到个位数。Canvas 的思路完全不同。整个表格就是一块画布所有的单元格、文字、边框、选区都是画上去的。浏览器只需要维护一个 Canvas 元素DOM 节点数恒定。这样无论表格有多少行多少列渲染开销只取决于当前视口内需要绘制的单元格数量。Univer 在这基础上还做了分层渲染把背景、网格线、单元格内容、选区、协同光标分到不同的 Canvas 层上。这样当用户只是移动选区时只需要重绘选区层内容层和背景层不动性能提升非常明显。2.2 视口渲染与滚动白屏的博弈Canvas 渲染表格有一个经典难题滚动时的白屏问题。因为 Canvas 是即时绘制的当你快速滚动时新的视口区域还没来得及绘制就会出现空白。Univer 的解决方案是视口渲染加缓冲区。它会计算当前可见区域然后向外扩展一定的行数和列数作为缓冲提前把这些单元格画好。滚动时如果新进入视口的区域已经在缓冲区内就直接显示不需要重新计算。这个缓冲区的大小设置很讲究。设小了快速滚动还是会白屏设大了每次重绘的计算量增加反而拖慢响应。Univer 默认的缓冲策略是根据滚动速度动态调整的滚动越快缓冲区越大。我在实际项目中试过手动调整这个参数发现对于普通办公场景默认值基本够用但如果是那种需要频繁快速滚动查看大表格的场景可以适当调大缓冲行数代价是内存占用会上升。2.3 渲染性能的实测数据与调优我在一台中等配置的笔记本上做过一组对比测试用 Univer 渲染不同规模的数据观察首屏渲染时间和滚动帧率。测试环境是 Chrome 浏览器数据为纯文本单元格不含复杂公式。数据规模首屏渲染时间滚动平均帧率内存占用1000 行 x 20 列约 120ms60fps约 45MB10000 行 x 20 列约 180ms58fps约 80MB50000 行 x 20 列约 220ms55fps约 160MB100000 行 x 20 列约 260ms52fps约 280MB从数据可以看出Univer 的渲染性能对数据规模的敏感度并不高因为视口渲染只画可见部分。真正影响性能的是单元格内容的复杂度。如果单元格里包含富文本、条件格式、自定义渲染器每帧的绘制开销会显著增加。我遇到过一个案例客户在单元格里嵌了复杂的进度条组件滚动帧率直接掉到 30fps 以下。后来改成用 Canvas 原生绘制简单的矩形条性能就回来了。注意自定义单元格渲染器是性能杀手。如果非要用尽量用 Canvas 原生 API 绘制避免在渲染函数里创建 DOM 元素或执行复杂计算。3. 插件架构Univer 扩展性的核心机制3.1 插件体系的注册与生命周期Univer 的插件架构是我认为它最有价值的部分。整个应用的能力包括表格核心功能、公式计算、协同编辑、甚至 UI 组件都是以插件形式注册进去的。这种设计的好处是按需加载和职责隔离。你不需要的插件可以不注册打包体积和运行时开销都能降下来。插件的生命周期分为几个阶段注册阶段插件被加入到 Univer 实例的插件列表中初始化阶段插件可以访问 Univer 的核心服务注册自己的命令、配置和 UI 组件运行阶段插件响应事件和命令销毁阶段清理资源。我刚开始用的时候经常搞混注册和初始化的时机导致插件拿不到其他服务的引用。后来总结出一个规律依赖其他服务的逻辑放在初始化阶段纯配置的声明放在注册阶段。// 插件注册的基本结构 import { Plugin, PluginType } from univerjs/core; class MyCustomPlugin extends Plugin { static type PluginType.Sheet; // 注册阶段声明插件的基本信息 constructor() { super(); } // 初始化阶段可以访问核心服务 onStarting() { // 在这里注册命令、监听事件 this.registerCommand(MyCommand); } // 运行阶段响应事件 onRendered() { // 表格渲染完成后的逻辑 } }3.2 命令系统与撤销重做的实现逻辑Univer 的命令系统是理解它数据流的关键。每一个对表格的修改无论是用户操作还是程序调用都应该封装成一个 Command。Command 包含三个核心部分执行逻辑、撤销逻辑、参数定义。执行逻辑负责修改数据撤销逻辑负责回滚参数定义用于校验和协同传输。为什么要这么设计因为撤销重做和协同编辑都需要知道这个操作做了什么以及怎么撤销它。如果直接改数据这两个功能就没法实现。我见过有开发者图省事直接操作底层数据模型结果撤销功能完全失效后来不得不把代码全部重写成命令模式。命令的执行流程是这样的用户操作触发命令命令被派发到命令服务命令服务执行命令的execute方法修改数据模型然后触发相应的事件渲染层监听到事件后重绘。整个过程是单向数据流保证了状态的可预测性。3.3 自定义插件从零到跑通的完整过程我拿一个实际需求来演示给表格加一个高亮重复值的功能。用户选中一个区域点击按钮区域内重复出现的值会被标红。第一步定义命令。命令需要接收选区范围和当前 Sheet 的 ID。import { CommandType, ICommand } from univerjs/core; export const HighlightDuplicatesCommand: ICommand { id: custom.command.highlight-duplicates, type: CommandType.COMMAND, handler: async (accessor, params) { const { unitId, subUnitId, range } params; // 获取当前数据计算重复值应用样式 // 具体实现略 return true; }, };第二步创建插件在初始化阶段注册命令和 UI 按钮。class HighlightPlugin extends Plugin { onStarting() { this.registerCommand(HighlightDuplicatesCommand); // 注册 UI 按钮到工具栏 } }第三步在创建 Univer 实例时注册插件。const univer new Univer({ theme: defaultTheme, locale: LocaleType.ZH_CN, }); univer.registerPlugin(HighlightPlugin);跑通之后我发现一个细节命令的handler里如果直接修改数据撤销功能不会生效。必须通过ICommandService提供的executeCommand来派发子命令或者使用Mutation来修改数据。这个坑我踩了两次才记住。4. 数据模型与公式引擎的底层逻辑4.1 工作簿、工作表与单元格的层级关系Univer 的数据模型是三层结构Workbook工作簿包含多个Worksheet工作表每个 Worksheet 包含单元格数据和行列配置。这个结构和 Excel 的文件结构是对应的理解起来不复杂。但要注意的是Univer 在内存中维护的是一套响应式数据模型任何修改都会触发依赖更新。单元格的数据不是简单的值而是一个对象包含v原始值、f公式、s样式、p富文本等字段。这种设计让单元格可以承载多种类型的信息。我在处理导入导出时经常需要区分显示值和原始值。比如一个公式单元格v是计算结果f是公式表达式。导出 Excel 时要把f写进去而展示时用v。4.2 公式计算的依赖收集与重算策略公式引擎是表格的核心。Univer 的公式引擎支持常见的 Excel 函数也允许自定义函数。它的计算逻辑是依赖收集加增量重算。每个公式单元格会记录它依赖了哪些其他单元格当被依赖的单元格发生变化时只有依赖它的公式会被标记为脏然后重新计算。这个机制听起来简单但实现起来有很多边界情况。比如循环引用怎么检测跨工作表引用怎么处理数组公式怎么展开Univer 在这些方面都有对应的处理逻辑但文档里写得比较简略很多细节需要看源码才能理解。我建议在自定义函数时尽量保持函数的纯函数特性不要在里面做副作用操作否则依赖收集会出问题。4.3 协同编辑的冲突解决思路协同编辑是 Univer 的另一个亮点。它采用的是OTOperational Transformation或者类似 CRDT 的思路把每个操作转换成可合并的变换。具体来说当两个用户同时修改同一个单元格时系统会根据操作的时间戳和类型决定最终以谁为准或者把两个操作合并。我在测试协同功能时发现Univer 对单元格级别的冲突处理得不错但对区域操作的冲突处理还有优化空间。比如一个用户插入了一行另一个用户同时在那一行下面删了一行结果可能不符合预期。这类问题在协同表格里很常见解决起来也复杂通常需要业务层做额外的约束。提示如果你的协同场景涉及大量结构化操作插入删除行列、合并单元格建议在业务层加一层操作队列避免并发操作直接冲击底层模型。5. 从零搭建一个 Univer 表格应用的实操路径5.1 环境准备与依赖安装的细节Univer 是 TypeScript 项目推荐用 Node.js 18 以上的版本。我试过用 Node.js 16 跑有些依赖会报错。安装依赖时核心包是univerjs/core渲染相关的包是univerjs/engine-render和univerjs/engine-formulaUI 组件在univerjs/ui和univerjs/design里。# 创建项目 npm create vitelatest my-univer-app -- --template react-ts cd my-univer-app # 安装核心依赖 npm install univerjs/core univerjs/design univerjs/engine-formula npm install univerjs/engine-render univerjs/ui univerjs/sheets npm install univerjs/sheets-ui univerjs/sheets-formula这里有个坑Univer 的包版本更新很快不同包之间的版本兼容性需要留意。我有一次混用了不同小版本的包结果运行时各种报错。建议在package.json里锁定版本或者用官方提供的 preset 包来统一版本。5.2 初始化配置与第一个可交互表格初始化 Univer 实例的代码不复杂但配置项比较多。核心是创建 Univer 实例、注册插件、创建工作簿、挂载到 DOM。import { Univer, LocaleType, defaultTheme } from univerjs/core; import { UniverRenderEnginePlugin } from univerjs/engine-render; import { UniverFormulaEnginePlugin } from univerjs/engine-formula; import { UniverUIPlugin } from univerjs/ui; import { UniverSheetsPlugin } from univerjs/sheets; import { UniverSheetsUIPlugin } from univerjs/sheets-ui; import { UniverSheetsFormulaPlugin } from univerjs/sheets-formula; const univer new Univer({ theme: defaultTheme, locale: LocaleType.ZH_CN, }); univer.registerPlugin(UniverRenderEnginePlugin); univer.registerPlugin(UniverFormulaEnginePlugin); univer.registerPlugin(UniverUIPlugin, { container: app, }); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsUIPlugin); univer.registerPlugin(UniverSheetsFormulaPlugin); univer.createUnit(UniverInstanceType.UNIVER_SHEET, { id: sheet-001, name: 我的表格, sheetOrder: [sheet-01], sheets: { sheet-01: { id: sheet-01, name: Sheet1, rowCount: 1000, columnCount: 20, cellData: { 0: { 0: { v: Hello }, 1: { v: Univer }, }, }, }, }, });这段代码跑起来后页面上就会出现一个可编辑的表格。但要注意container的 ID 必须和 HTML 里的元素 ID 对应否则会报找不到容器的错误。5.3 样式定制与主题变量的覆盖方式Univer 的 UI 组件用的是 CSS 变量来控制样式这给定制带来了很大便利。你不需要去覆盖具体的类名只需要在全局或者容器级别重新定义 CSS 变量就行。:root { --univer-color-primary: #1a73e8; --univer-color-bg: #ffffff; --univer-color-text: #202124; --univer-border-color: #dadce0; }我试过把主题色改成公司品牌色整个过程不到十分钟。但要注意有些组件的样式是内联的CSS 变量覆盖不了需要用!important或者直接改源码。这种情况不多但遇到了会比较头疼。6. 落地过程中踩过的坑与应对方案6.1 打包体积过大与按需加载的取舍Univer 的功能很全但全量引入的打包体积也不小。我实测过全量引入后 gzip 后的体积在 800KB 左右。对于内部管理系统来说可以接受但对于面向 C 端的页面这个体积会影响首屏加载。优化的思路是按需引入插件。如果你不需要公式功能就不注册公式引擎插件不需要协同就不引入协同相关的包。我做过一个只保留核心渲染和基础编辑功能的版本打包体积降到了 300KB 左右。代价是功能确实少了但对于只读展示或者简单编辑的场景完全够用。6.2 移动端适配的触摸事件处理Univer 在桌面端的体验很好但移动端需要额外处理。主要问题是触摸事件和鼠标事件的差异。在移动端滚动、缩放、长按选中的行为都和桌面端不同。Univer 的渲染引擎对触摸事件有基础支持但一些交互细节需要自己补。我遇到的具体问题是在 iOS Safari 上双指缩放会触发页面的整体缩放而不是表格的缩放。解决方案是在 Canvas 容器上设置touch-action: none然后自己处理手势事件。另外移动端的虚拟键盘弹出时会遮挡输入框需要监听resize事件动态调整布局。6.3 与后端数据同步的时机与策略Univer 的数据模型是前端的和后端数据库之间需要同步。同步策略有两种全量同步和增量同步。全量同步是每次保存都把整个工作簿的数据传给后端实现简单但数据量大。增量同步是只传变化的部分需要记录操作日志。我推荐的做法是前端用命令系统记录操作定期批量提交到后端。这样既减少了请求次数又保留了操作的语义信息。后端收到操作日志后可以回放这些操作来更新数据也可以直接存储操作日志作为版本历史。这个方案在协同场景下尤其有用因为操作日志本身就是协同的基础。注意增量同步要做好冲突检测。如果两个客户端同时提交了冲突的操作后端需要有合并策略否则数据会不一致。7. 我对 Univer 选型与使用的几点判断用了一段时间 Univer 之后我对它的定位有了更清晰的认识。它不是那种开箱即用的表格组件而是一套需要你投入时间理解的架构。前期学习成本确实存在但一旦理解了它的插件体系和命令系统后续的扩展和定制会变得非常顺畅。从技术选型角度看如果你的项目需要在线协同、深度定制、高性能渲染这三个特性中的两个以上Univer 值得认真考虑。如果只是简单的数据展示用轻量级方案更划算。另外Univer 的社区还在成长中遇到问题查文档可能找不到答案需要去翻源码或者提 Issue。我个人的习惯是先把核心包的源码 clone 下来遇到问题直接搜关键词比看文档快得多。最后分享一个实用技巧Univer 的 Facade API 是操作表格的主要入口但它的文档更新往往滞后于代码。我建议在开发时开启 TypeScript 的类型提示通过类型定义来了解 API 的用法比翻文档靠谱。另外多看看官方示例仓库里的代码很多用法在示例里比文档里写得更清楚。