Univer:基于Canvas渲染与插件化架构的在线电子表格引擎
如果你和我一样被“在内部系统里嵌入一个在线电子表格”这种需求缠过一个月大概率会在调研时翻到univer这个名字。它出现在 GitHub 趋势榜上也出现在一些技术社区的讨论区里但资料零散官方文档更新又快很多人第一眼根本不知道它到底能做什么、和直接用 Excel 有什么区别。这篇文章就围绕 univer 展开。我会先说说它诞生的背景再把它的架构逻辑拆开讲清楚然后给出一套能跑通的接入步骤和实测数据最后聊一聊协同编辑、插件化、导入导出这几个生产环境里绕不开的话题。适合正在选型的小团队、想给产品加表格能力的开发者以及对开源办公套件架构感兴趣的同学。1. 从 Luckysheet 到 Univer为什么同一个团队选择推翻重做1.1 我最初是怎么接触 univer 的最开始我是在一个数据中台项目里被表格功能逼到头秃。业务方提的需求很简单把运营报表搬到网页上除了能看还要能改、能算、能导出。开发组第一反应是找开源方案于是顺藤摸瓜找到 Luckysheet。Luckysheet 在当时确实惊艳社区活跃界面也像模像样复制粘贴、筛选、公式这些基础能力都齐全。我们很快集成进去但越用越发现不对劲。数据量一旦撑到十几万行滚动开始掉帧公式多了以后每次编辑一个单元格整张表都要等一会儿才反应过来更麻烦的是想对外暴露自定义函数和按钮Luckysheet 的插件机制比较脆弱改来改去容易碰坏内部结构。后来偶然看到 univer 的文档页面第一感觉是界面比 Luckysheet 精致了一大截。再往后了解到univer 正是 Luckysheet 原团队的新项目而且不是小修小补是从架构层面全部推翻重写。那一刻我基本确定这方向对了。1.2 Luckysheet 的局限性在哪里说句公道话Luckysheet 完成度很高但在工程架构上带着明显的“历史包袱”。早期版本用 DOM 表格模拟电子表格一棵虚拟 DOM 树挂满成千上万个单元格节点数据量一大浏览器渲染线程直接被拖垮。后续 Luckysheet 也引入了 Canvas 绘制方案但核心数据流没有彻底重构。状态分散在多个全局对象里撤销重做依赖快照一旦操作连续且数据量大内存占用会急剧膨胀。对于插件机制Luckysheet 早期提供的是“扩展 修改源码”的模式社区贡献的代码质量参差不齐很多功能只能靠 fork 自己的分支维护。这种模式在个人项目里没问题但在团队协作和企业交付里维护成本高得吓人。1.3 univer 在理念上做了哪些改变univer 的核心变化是把整个项目从“一个大而全的表格组件”拆成了“一套可组合的基础设施”。它内部大概分成三个层面底层是核心引擎负责数据结构、命令调度和插件生命周期中间是各种业务模块比如工作表、文档、幻灯片上层是 UI 和渲染引擎专门处理画布绘制、组件交互。每个模块独立成包按需引入。它不是让你直接拿一个Sheet /组件塞进页面而是让你把“电子表格能力”作为一套服务构建到自己的应用里。这点对开发者的影响是巨大的。过去你是在“使用软件”现在更像是在“定制一套软件”。虽然学习曲线更陡但上限完全不同。2. univer 凭什么能撑住百万级单元格架构层面的几个关键设计2.1 渲染管线Canvas 绘制取代 DOM 表格电子表格核心痛点就是单元格数量。普通页面渲染三五千个 DOM 节点就要小心了而一张表格可能动辄几十万个单元格。如果每个单元格都对应一个节点浏览器会直接崩溃。univer 的渲染引擎基于 Canvas 做整体绘制只渲染视口范围内的内容。滚动时会根据视口偏移量重新计算绘制区域而不是去更新成千上万个 DOM 属性。这个思路和地图应用的瓦片渲染很像屏幕上永远只保留你看到的那部分看不见的区域只存在于数据模型里。实际体验下来滚动流畅度比纯 DOM 方案好一个量级。不过要注意Canvas 渲染也有代价单元格上的输入框、下拉框、右键菜单这些交互控件仍然需要 DOM 层配合所以 univer 实际是“Canvas 画内容DOM 浮在画布上做交互控件”的混合架构两者之间的坐标换算和事件分发是这一类项目最容易出 bug 的地方。2.2 命令系统与撤销重做如何统一管理状态这是 univer 架构里我最欣赏的部分。它没有把修改单元格、改动行高、插入图表这些操作散落在不同模块里而是统一抽象成“命令”。一个命令包括执行操作的execute逻辑、反向操作的revert逻辑、用于判断操作合法性的canExecute逻辑。任何一次用户操作都会先经过命令系统派发再落到数据模型上。好处是撤销重做变得极其简单系统只需要维护一个命令执行栈撤销就执行反向命令重做就重新执行正向命令。更关键的是命令系统天然适合协同编辑。多个用户的操作都会被转换成命令发送到服务端再由服务端合并广播。状态变更的来源变得统一排查问题就只盯命令流不用去追“是哪个函数改了哪个对象”。我在接入时感受最深的是想给某个按钮绑定业务逻辑不需要去翻源码找内部 API只需要注册一个自定义命令然后在命令里完成业务调用再提交一次标准单元格写入命令即可。这比改内部状态变量安全得多。2.3 插件化不是可选项而是基础能力很多项目嘴上说自己支持插件化实际只是提供了几个生命周期钩子。univer 的插件化是刻在骨架里的核心包裹器只负责管理插件注册、初始化、销毁以及命令路由具体的功能无论官方内置的公式、条件格式还是自定义的数据校验按钮一律以插件形式存在。插件机制对业务方最大的好处是“更新依赖不慌”。我们可以在不修改核心代码的前提下把自己定制的能力封装成独立包。业务模块之间通过 univer 提供的事件总线和命令队列通信互不直接引用大大减少了耦合。实践中还有一个体感插件化之后按需加载才能真正落地。首屏只加载核心和基础表格插件高级功能等用户打开对话框时再动态 import打包体积和首屏时间都能明显优化。2.4 公式引擎的独立化设计公式是电子表格的灵魂但在工程上也是最难搞的一部分。Luckysheet 时代公式计算和渲染耦合得比较紧公式一变整张表经常要重新计算再重新绘制。univer 把公式引擎独立成engine-formula模块维护自己的计算依赖图。它把每个单元格的公式解析成依赖树某个单元格变化时引擎只重算受影响的分支而不是全表扫描。举个例子A1 引用 B1B1 引用 C1那么改 C1 时只重算 B1 和 A1旁边一千万个无关单元格完全不参与。还有个细节很打动我公式引擎可以被插件借用。你可以注册一个自定义函数然后在单元格里直接写MY_FUNC(A1:B5)函数体内调用 JavaScript。用这种机制我们能轻松把业务算法暴露到表格里业务人员可以直接在报表里调整参数不用找开发改代码。3. 跑通 univer从 npm 安装到页面渲染出一张带公式的表3.1 最小化接入代码与依赖选择univer 的包管理结构比较碎初看会有点晕。这里给一份我当时跑通的最小依赖清单以目前 npm 上主流版本为准后续包名可能有变化但思路一致univerjs/core核心包负责构造 Univer 实例、插件注册和数据结构univerjs/design设计变量和默认主题控制按钮、字体、颜色的基础样式univerjs/engine-render渲染引擎负责 Canvas 绘制univerjs/engine-formula公式引擎univerjs/sheets电子表格业务模块的核心逻辑univerjs/uiUI 框架层提供工具栏、右键菜单等组件容器univerjs/sheets-ui电子表格的 UI 具体实现把上面两部分衔接起来安装我直接用的 npm 指定版本安装没有用 Git 仓库编译。需要注意版本对齐核心包和插件包如果大版本不一致很容易出现“插件注册成功但界面不渲染”的怪问题。3.2 最小化接入代码创建一个独立目录建一个index.html引入打包器编译后的 JS 文件或者直接在 React/Vue 项目里写。核心代码如下import { Univer } from univerjs/core; import { defaultTheme } from univerjs/design; import { UniverRenderEngine } from univerjs/engine-render; import { UniverFormulaEngine } from univerjs/engine-formula; import { UniverUIPlugin } from univerjs/ui; import { UniverSheetsPlugin } from univerjs/sheets; import { UniverSheetsUIPlugin } from univerjs/sheets-ui; const univer new Univer({ theme: defaultTheme }); univer.registerPlugin(UniverRenderEngine); univer.registerPlugin(UniverFormulaEngine); univer.registerPlugin(UniverUIPlugin, { container: app }); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsUIPlugin);注意这里的执行顺序先注册引擎再注册 UI最后注册业务模块和它的 UI。虽然 univer 内部有初始化顺序控制但插件之间有隐式依赖顺序乱了很容易报“找不到 container 或 canvas”的错误。创建一个带数据的工作簿import { UniverInstanceType } from univerjs/core; univer.createUnit(UniverInstanceType.UNIVER_SHEET, { name: 销售报表, sheetData: { sheet-01: { name: 1月, celldata: [ { r: 0, c: 0, v: 城市 }, { r: 0, c: 1, v: 销售额 }, { r: 1, c: 0, v: 上海 }, { r: 1, c: 1, v: 1000 }, { r: 2, c: 0, v: 北京 }, { r: 2, c: 1, v: 2000 }, { r: 3, c: 1, v: { f: SUM(B2:B3) } } ] } } });s代表行号c代表列号都是从 0 开始。v的值可以是数字、字符串也可以是一个带f字段的公式对象。单元格数据格式在不同版本里略有调整如果渲染后所有格子都是空的先检查是不是把celldata写成了cellData。3.3 关键配置项容器、工具栏和样式UI 插件的container参数接收一个 DOM 节点或节点 id。univer 会在这个容器内部创建全套 UI包括顶部工具栏、工作表标签栏、右上角菜单等。如果你只想嵌入一张“光秃秃”的表格不带工具栏可以不注册UniverSheetsUIPlugin只保留UniverSheetsPlugin和渲染引擎然后在container里自行实现头部按钮。还有一个容易被忽略的点默认主题。不设置theme也是能跑的但默认样式会偏基础。defaultTheme提供了一套设计令牌颜色、间距、字体统一按设计系统走后续想改成品牌色直接覆盖主题变量即可。3.4 在线体验与本地调试技巧很多人第一次找 univer 的在线体验入口会去 GitHub 仓库找 Demo或者去官网 playground。网页在线版可以完整体验新建表格、输入公式、切换主题、拖拽行列这些操作适合先做功能验证。本地调试时我的习惯是先把数据规模缩小确认基础渲染没问题再逐步加大数据量。加了大量数据后可以打开浏览器性能面板看 Canvas 绘制耗时和 JS 脚本耗时重点观察滚动时的帧率曲线。如果帧率明显不稳优先检查是不是有隐藏的 DOM 节点在更新或者公式引擎在滚动时触发了多余的重算。4. 实测记录大批量数据下的渲染与交互表现4.1 测试方法我从哪里拿到的数据我用 Python 写了个生成器造了一张 30 列、20 万行的销售明细表包含日期、区域、产品、单价、销量、金额、成本、毛利等字段其中“毛利”列全部写成公式引用前面的金额和成本列。生成后的数据量大约是 600 万个单元格做的第一项测试就是直接把这批数据塞进 univer。插入方式不是逐格赋值而是构造好celldata数组后一次性写入。univer 对大数据量导入做了批量处理逐格赋值的话性能会下降至少一个数量级。如果你要对接后端数据建议先在后端把数据整理成批量 JSON再一次性喂给 univer。4.2 性能数据与直观体验先看直观体感表格初次渲染耗时大概在 3 到 4 秒左右这个阶段浏览器基本被占满但渲染完成后滚动、缩放、单击选中这些交互都很跟手。快速拖动滚动条到表格末尾并不会卡到让人抓狂。公式重算的表现符合预期修改“单价”列的任意一个单元格只有对应行的金额、成本、毛利被重算整表的“一键重算”按钮点了之后才会全量刷新。我用的测试机器是普通 Windows 笔记本Chrome 浏览器16G 内存。在 600 万单元格场景下内存占用峰值大约在 1.5G 到 2G 之间长期使用后会慢慢降下来。这个表现在同类开源方案里属于第一梯队比纯 DOM 方案强很多但和商业闭源表格产品相比仍有差距。4.3 内存与 GCCanvas 不是万能药Canvas 渲染解决了 DOM 节点数量的问题但没有解决数据本身占内存的问题。20 万行 × 30 列哪怕只是数字和字符串也会给浏览器内存带来不小的压力。univer 在数据结构的二进制编码和增量存储上做了不少优化但如果你要处理千万级单元格建议配合服务端分页或按需加载不要指望前端全量渲染。还有一个容易被忽略的问题CtrlC/CtrlV 大范围复制时内存波动会非常明显。我实测复制 5 万行区域内存瞬时涨了 300M 左右通过后又开始频繁 GC。这种场景下至少要保证用户机器有足够冗余否则可能出现浏览器标签页崩溃。5. 插件化扩展实战给自己加一个自定义函数面板5.1 插件注册的完整流程univer 插件本质上是一个继承Plugin基类的对象需要实现onStarting、onReady、onDestroy等生命周期方法。注册插件时可以通过配置对象向插件传入参数。我写过一个“客单价计算”插件需求是把选中区域的平均值、中位数、最大值直接显示在状态栏并支持复制结果。实现上先创建插件类在onReady里订阅选中区域变化事件读取单元格值计算统计结果再更新状态栏里的 DOM 文本。注册插件只需在univer.registerPlugin(CustomPlugin, options)里指定即可不需要修改任何核心包代码。这是 univer 最让我舒服的地方核心稳定业务全在插件层。5.2 命令的全链路从点击按钮到单元格写值插件里通常会定义自己的工具栏按钮。用户点击按钮后插件向命令队列发送一个自定义命令。这个命令的execute函数可以执行任意 JavaScript 逻辑最后如果需要把结果写回单元格再提交一个内置的SetRangeValueCommand。完整链路是按钮点击事件 - 插件监听器 - 命令队列 - 命令 execute - 业务逻辑 - 提交单元格写入命令 - 命令系统通知渲染引擎更新画布 - 公式引擎同步重算。每一步都能被拦截和追踪这也是 univer 适合嵌入复杂业务系统的原因。5.3 遇到的一个真实扩展场景我们的实际需求是“允许业务人员在报表里直接调用内部查询接口”。传统做法是让业务人员把查询参数粘到表格里然后由后端跑批任务再回填结果。用 univer 后我在工具栏加了个“查询”按钮弹出一个表单表单提交后插件调用接口拿到数据直接写入指定区域并在第一行附上查询时间。整个过程业务人员完全感知不到底层技术只觉得“网页表格能自动填数了”。这个功能开发只花了一天半主要时间花在熟悉插件 API 上真正写业务逻辑的代码不到两百行。放在以前用 Luckysheet 改源码至少得折腾两三天还要担心升级后被覆盖。6. 在线协同与导入导出univer 在生产环境里最需要关注的地方6.1 协同引擎用 OT 做了什么热搜里经常出现“univer 在线”说明很多人关心的不是单机表格而是多人同时编辑。univer 官方提供的协作方案基于操作变换算法即多个用户的编辑操作先经过转换再合并到同一份文档状态上每个人看到的最终结果是一致的。理想状态下两个人同时编辑同一张表A 改 B2B 改 C2两个操作不会互相覆盖A 改 B2 的同时B 改了 B3系统会对操作做变换保证最终状态包含两个操作的结果。这个机制在工程上并不简单尤其是“拖拽公式跨区域”这类的批量操作操作粒度大冲突率偏高。实际接入时不要觉得打开协同开关就万事大吉。协同服务需要处理操作日志、回放、断线重连、权限控制。univer 开源版提供了协同数据结构和算法基础但完整可用的服务器方案更多是商业化能力。如果是自建我的建议是先做“伪协同”比如把文件级别的锁和版本合并做好再考虑真正的 OT 实时协同风险会低很多。6.2 数据格式转换的坑内部系统用 univer 存储数据最终还是要和 xlsx 文件打交道。univer 的数据结构是自有的 JSON 工作簿模型要导入 xlsx需要把二进制 Excel 文件解析成工作簿 JSON要导出 xlsx则要把 JSON 转回二进制。如果团队里已经用 SheetJS 这类库做过 Excel 处理可以在 univer 外层做一层转换适配器。我踩过的坑主要是这三类日期字段Excel 里的日期本质是序列号转换时如果不统一时区页面和文件中会出现“差一天”的情况。合并单元格univer 用mergeData描述合并区域转换时要注意起点和终点是否越界处理不当会导致导出后 Excel 自己修复文档结构。自定义样式部分条件格式和数字格式在两个格式体系之间不是一一对应导出前要做降级策略否则会出现“网页里好好的打开 Excel 全乱了”。6.3 生产部署时的取舍univer 是纯前端渲染静态资源可以直接放进 CDN 或服务器。前端无状态每个人打开都是空白表格还是带数据取决于后端在初始化时下发的工作簿 JSON。这种部署模式很舒服因为表格的读取、存储、权限全部由自己的服务控制不必被任何在线表格平台绑定。但要注意涉及大量公式或大文件时让浏览器直接承担全部计算压力并不合适。可以在后端做前置计算把部分结果先算好再下发也可以在公式引擎里配置最大重算范围超过一定单元格数量后给用户提示避免浏览器卡死。真实生产环境永远是企业业务优先技术要服从业务节奏。7. 一些踩过的坑和更推荐的做法7.1 版本升级带来的破坏性变化univer 项目迭代速度很快API 也处在调整期。我在接入后几个月内就遇到了插件注册方式改变、容器参数顺序调整、单元格数据字段命名变化等多次升级。每次 npm 包一更新就得花时间读 changelog 和迁移文档。建议从一开始就把 univer 相关依赖锁定精确版本不要用默认的^前缀自动升级小版本。升级时先在测试环境完整跑一遍核心用例比如导入导出、大表格渲染、公式计算再考虑放到生产环境。7.2 数据量大之后UI 插件反而成了瓶颈Canvas 渲染本身性能不错但 univer 的 UI 插件层在数据量极大时偶尔会出现工具栏按钮点击响应变慢的情况。原因不难猜全局状态更新触发了 UI 插件的多级联动比如选中区域变化时工具栏的状态按钮、单元格地址栏、状态栏统计信息都要同步刷新。这部分我没有什么完美的解决方案只能建议把高频变化的状态用局部更新控制住减少不必要的全局广播。另外如果不需要工具栏就大胆去掉对应的 UI 插件裸表格反而更稳定。7.3 推荐的上手路径如果你刚接触 univer我不建议一上来就啃源码或搭协同环境。先用在线 demo 玩明白功能边界再按官方的 example 仓库把最小 demo 跑起来接着尝试替换成自己的数据。熟练之后再考虑插件化和协同。如果只是需要一个简单的在线表格交互univer 可能有点重但如果你要做的是让表格深度嵌入业务流让它能算、能协同、能被扩展成业务工具univer 是我目前看到的最合适的开源选择。就我个人实际体验而言它最值钱的地方不是某几个特效功能而是整个架构把“电子表格”从产品变成了可编程的基础设施。未来如果团队需要更强的数据分析能力、更复杂的权限模型univer 的插件边界都能兜住这也是我愿意持续在这个方向上投入的最主要原因。