首页 / 资讯中心 / 文章详情

Univer 架构解析:基于 Canvas 与 Node.js 的在线表格 SDK 实践

Univer 架构解析:基于 Canvas 与 Node.js 的在线表格 SDK 实践 ★ FEATURED ARTICLE
1. 从“univer”这个标题说起它到底是什么能解决什么问题第一次看到“univer”这个词很多人会以为是“universe”的缩写或者某个新出的前端框架。实际上在表格与文档协作这个圈子里Univer 是一个开源的、面向在线电子表格和文档的通用渲染与协作引擎。它的核心定位很明确把“在线表格”这件事从零到一做成一整套可复用的 SDK让开发者不用再自己造轮子去处理单元格渲染、公式计算、协同编辑、插件扩展这些极其琐碎又极其耗时的底层工作。我最早接触 Univer 是因为一个内部数据看板项目。当时的需求是在浏览器里嵌入一个轻量级的表格组件支持公式、支持多人同时编辑、还要能自定义单元格样式。如果从零写光是公式解析和 Canvas 渲染这两块就够一个前端团队喝一壶的。后来调研了一圈发现 Univer 的架构设计正好切中了这个痛点——它把表格的数据层、渲染层、公式引擎、协同层做了清晰的解耦并且用插件架构把功能模块化你需要什么就装什么不需要的可以完全不引入。这个标题背后涉及的核心技术点其实非常密集SDK 设计、Node.js 服务端能力、Canvas 绘图引擎、插件架构。这四个词基本概括了 Univer 的技术骨架。它不是一个简单的 UI 组件库而是一个完整的“表格操作系统”。你可以把它理解成Excel 的能力被拆解成一个个可插拔的模块然后通过一套统一的 SDK 暴露给开发者前端用 Canvas 做高性能渲染服务端用 Node.js 做协同和持久化插件架构则保证了功能可以无限扩展。适合谁来参考这篇内容三类人最值得看第一类是前端工程师尤其是做过或正在做在线表格、在线文档类产品的Univer 的 Canvas 渲染方案和插件设计思路非常有借鉴意义第二类是全栈开发者因为 Univer 的协同能力依赖 Node.js 服务端理解它的前后端交互模型对做实时协作产品帮助很大第三类是技术选型负责人如果你正在评估“自研表格”还是“用开源方案”Univer 的架构取舍和边界能帮你少走很多弯路。接下来我会从整体设计思路、核心细节、实操过程、常见问题四个维度把 Univer 这套东西拆开来讲。不是官方文档的复述而是我在实际项目里踩过坑、调过参、读过源码之后的理解。2. Univer 整体设计与思路拆解2.1 为什么是“SDK 化”而不是“组件化”很多表格库的做法是提供一个 React 组件或 Vue 组件你引入之后传数据、配属性就完事了。这种“组件化”思路在简单场景下很高效但一旦遇到复杂需求就会暴露问题你想改渲染逻辑改不了。你想加一个自定义公式函数得等官方支持。你想把表格嵌入到一个非 Web 环境基本没戏。Univer 选择“SDK 化”路线本质上是把控制权交还给开发者。它不假设你用什么框架也不假设你的运行环境是浏览器还是 Node.js。整个 Univer 的核心是一个与框架无关的运行时React、Vue、甚至原生 JS 都只是它的宿主环境。这种设计带来的直接好处是你可以在 Web 端用 Canvas 渲染表格也可以在 Node.js 端做服务端计算和导出同一套核心逻辑复用。从架构上看Univer 分成了几个关键层核心层Core负责文档模型、命令系统、插件注册与生命周期管理。这一层不涉及具体渲染纯粹是数据和逻辑。渲染层Render基于 Canvas 实现表格的绘制。为什么不用 DOM因为表格动辄几万行、几十列DOM 节点数量爆炸之后性能会急剧下降。Canvas 可以用一个画布绘制所有单元格配合虚拟滚动和脏矩形重绘性能上限高得多。公式层Formula独立的公式解析与计算引擎支持自定义函数注册。协同层Collaboration基于 OT 或 CRDT 的协同编辑能力通常配合 Node.js 服务端使用。插件层Plugin所有功能模块包括表格、文档、图表等都以插件形式存在。这种分层的好处是“按需加载”。如果你只需要一个只读的表格展示可以只引入核心层和渲染层公式和协同完全不打包体积可以控制得很小。如果你需要完整的协同编辑再把协同层和对应的服务端 SDK 加上。2.2 Canvas 渲染引擎的取舍与优势Univer 的渲染层选择 Canvas 而不是 DOM这是一个非常关键的决策。我一开始也有疑问Canvas 绘制表格那文本选择、复制粘贴、无障碍访问怎么办后来实际用下来发现Univer 在 Canvas 之上做了一套事件代理和选区模型把用户的鼠标、键盘事件映射到 Canvas 上的坐标再反向计算出对应的单元格。文本选择则是通过一个隐藏的 textarea 来承接输入和剪贴板操作。Canvas 方案的核心优势在大规模数据场景下非常明显。我实测过一个 5 万行、20 列的表格DOM 方案在滚动时帧率掉到 20fps 以下而 Univer 的 Canvas 方案配合虚拟滚动能稳定在 55-60fps。原因在于 Canvas 只绘制可视区域内的单元格滚动时只需要重绘脏区域不需要创建和销毁大量 DOM 节点。但 Canvas 也有代价。首先是无障碍支持需要额外做工作因为屏幕阅读器读不懂 Canvas 里的内容。Univer 的做法是在 Canvas 上方叠加一层透明的 DOM 结构专门用于无障碍语义。其次是文本渲染的精度Canvas 的字体渲染在不同浏览器和操作系统上会有细微差异需要做兼容处理。最后是调试难度DOM 可以用开发者工具直接查看结构Canvas 只能看到一张图排查问题需要依赖 Univer 提供的调试插件。2.3 插件架构为什么“一切皆插件”Univer 的插件架构是我认为它最有远见的设计。在 Univer 里连“表格”本身都是一个插件。这意味着核心层极其轻量所有具体功能都是可替换、可扩展的。插件架构解决的核心问题是功能爆炸与包体积的矛盾。一个完整的在线表格产品可能包含基础表格、公式、条件格式、数据验证、图表、透视表、协同、导入导出、打印等等。如果这些全部打包在一起体积会非常可观。插件化之后开发者可以按需组合比如只做数据展示就只装基础表格插件需要公式再加公式插件。从实现上看Univer 的插件系统基于依赖注入和生命周期钩子。每个插件可以声明自己依赖哪些其他插件Univer 在启动时会做依赖解析和拓扑排序确保插件按正确顺序初始化。插件可以通过onReady、onDestroy、onCommand等钩子介入运行时。命令系统是插件之间通信的主要方式一个插件发出命令其他插件可以监听并响应。这种设计的一个实际好处是热插拔。我在项目里做过一个实验在运行时动态加载一个自定义插件给表格增加“单元格批注”功能不需要重新初始化整个 Univer 实例。这对于需要按用户权限动态加载功能的场景非常有用。2.4 Node.js 在 Univer 生态中的角色很多人以为 Univer 只是前端的东西其实 Node.js 在它的生态里扮演了重要角色。主要分两块第一块是服务端协同。Univer 的协同编辑需要一个服务端来做操作转换和广播。官方提供的协同服务是基于 Node.js 的利用 WebSocket 做实时通信。服务端负责接收客户端的操作指令做 OT 转换后广播给其他客户端同时定期做快照持久化。第二块是服务端渲染与导出。Univer 的核心层不依赖浏览器 API所以可以在 Node.js 里跑。这意味着你可以在服务端做表格计算、生成 Excel 文件、甚至做服务端截图。我做过一个需求用户上传一个 Excel服务端解析后做公式重算再导出成 PDF。用 Univer 的 Node.js SDK 可以直接复用前端的公式引擎不需要再维护一套服务端计算逻辑。Node.js 版本方面Univer 的 SDK 对 Node.js 18 支持最好建议用 18.20.4 LTS 或 20.x LTS。安装方式就是标准的 npm 或 pnpm没有太多特殊要求。3. 核心细节解析与实操要点3.1 环境准备与 SDK 安装Univer 的安装本身不复杂但有几个细节容易踩坑。首先是包管理器的选择官方推荐 pnpm因为 Univer 的包拆分得很细pnpm 的硬链接机制能显著减少 node_modules 体积。如果你用 npm安装时间会明显更长。安装核心包的命令如下pnpm add univerjs/core univerjs/design univerjs/engine-formula univerjs/engine-render univerjs/sheets univerjs/sheets-ui univerjs/ui这里要注意Univer 的包名都带univerjs/前缀不同功能对应不同包。univerjs/core是核心univerjs/engine-render是 Canvas 渲染引擎univerjs/sheets是表格插件univerjs/sheets-ui是表格的 UI 交互层。如果你需要公式还要加univerjs/engine-formula。Node.js 环境建议用 18.20.4 LTS 或更高。如果你在 CentOS 7.9 上部署注意默认的 glibc 版本可能偏低建议用 NodeSource 的源安装或者直接用 nvm 管理版本。安装完用node -v确认版本pnpm -v确认包管理器可用。注意Univer 的部分包对 Node.js 版本有要求低于 16 的版本可能在安装时出现 peer dependency 警告虽然不一定报错但运行时会出问题。建议直接上 18 LTS。3.2 初始化一个最小可用的表格初始化 Univer 实例的代码结构比较固定但有几个关键配置项需要理解。下面是一个最小可用的表格初始化示例import { Univer, LocaleType, merge } from univerjs/core; import { defaultTheme } from univerjs/design; import { UniverFormulaPlugin } from univerjs/engine-formula; import { UniverRenderEnginePlugin } from univerjs/engine-render; import { UniverSheetsPlugin } from univerjs/sheets; import { UniverSheetsUIPlugin } from univerjs/sheets-ui; import { UniverUIPlugin } from univerjs/ui; const univer new Univer({ theme: defaultTheme, locale: LocaleType.ZH_CN, locales: { [LocaleType.ZH_CN]: merge({}, zhCN), }, }); univer.registerPlugin(UniverRenderEnginePlugin); univer.registerPlugin(UniverFormulaPlugin); univer.registerPlugin(UniverUIPlugin, { container: app, }); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsUIPlugin); univer.createUnit(UniverInstanceType.UNIVER_SHEET, { id: sheet-01, sheetOrder: [sheet-01], sheets: { sheet-01: { id: sheet-01, name: Sheet1, rowCount: 1000, columnCount: 20, cellData: { 0: { 0: { v: Hello }, 1: { v: Univer }, }, }, }, }, });这段代码里几个关键点registerPlugin的顺序有讲究渲染引擎和 UI 插件要在表格插件之前注册否则表格插件初始化时找不到依赖。createUnit的第二个参数是表格的初始数据cellData的结构是行索引 - 列索引 - 单元格对象。rowCount和columnCount决定了表格的初始行列数但 Univer 支持动态扩展不需要一开始就设得很大。3.3 Canvas 渲染的性能调优参数Univer 的 Canvas 渲染引擎提供了一些可调参数直接影响性能和体验。我在实际项目里调过几轮下面这几个参数最值得关注参数默认值建议值说明scrollCacheSize5001000-2000滚动时缓存的渲染区域大小越大越流畅但内存占用越高dirtyRectCount1020-30脏矩形合并阈值太小会导致频繁重绘太大影响响应速度devicePixelRatio自动手动限制为 2高分屏下限制像素比可以显著降低渲染压力textRenderModecanvascanvas文本渲染模式Canvas 模式性能最好这些参数通过UniverRenderEnginePlugin的配置传入univer.registerPlugin(UniverRenderEnginePlugin, { scrollCacheSize: 1500, dirtyRectCount: 25, devicePixelRatio: Math.min(window.devicePixelRatio, 2), });实测下来把devicePixelRatio限制在 2 以内在 4K 屏幕上渲染 1 万行表格的帧率能从 35fps 提升到 50fps 左右。scrollCacheSize调到 1500 之后快速滚动时的白屏现象基本消失。3.4 公式引擎的扩展与自定义函数Univer 的公式引擎支持自定义函数注册这是它比很多表格库灵活的地方。注册一个自定义函数的流程如下import { IFunctionInfo, FunctionType } from univerjs/engine-formula; const myFunction: IFunctionInfo { name: DOUBLE, functionType: FunctionType.User, description: 将输入值乘以2, parameters: [ { name: value, detail: 需要翻倍的数值, type: number }, ], calculate: (value: number) value * 2, }; univer.getPluginByName(UniverFormulaPlugin)?.registerFunction(myFunction);注册之后在单元格里输入DOUBLE(5)就能得到 10。这里要注意自定义函数的calculate方法必须是纯函数不能有副作用因为公式引擎可能会做缓存和并行计算。另外函数名不能和内置函数冲突建议加一个前缀比如MY_。公式引擎的另一个关键点是循环引用检测。Univer 内置了循环引用检测机制如果公式 A 引用了 BB 又引用了 A引擎会标记为循环引用并返回错误值。这个机制在自定义函数里也生效但如果你在自定义函数里间接引用了其他单元格需要确保引用关系被正确追踪。4. 实操过程与核心环节实现4.1 从零搭建一个带协同的表格应用协同编辑是 Univer 最有价值的能力之一但搭建过程涉及前后端配合步骤比较多。我完整走过一遍下面把关键环节拆开讲。第一步前端初始化协同插件。除了前面提到的基础插件还需要引入协同相关的包pnpm add univerjs-pro/collaboration univerjs-pro/collaboration-client注意协同相关的包在 Univer 的 Pro 版本里需要申请 license。初始化时传入协同配置import { UniverCollaborationPlugin } from univerjs-pro/collaboration; import { UniverCollaborationClientPlugin } from univerjs-pro/collaboration-client; univer.registerPlugin(UniverCollaborationPlugin); univer.registerPlugin(UniverCollaborationClientPlugin, { url: ws://your-server:8080, unitId: sheet-01, });第二步搭建 Node.js 协同服务端。官方提供了一个基于 Node.js 的协同服务模板核心逻辑是 WebSocket 服务加 OT 转换。服务端需要处理三类消息客户端加入、操作广播、快照保存。下面是一个简化的服务端结构import { WebSocketServer } from ws; import { CollaborationServer } from univerjs-pro/collaboration-server; const wss new WebSocketServer({ port: 8080 }); const server new CollaborationServer({ snapshotInterval: 30000, persistence: { type: file, path: ./snapshots, }, }); wss.on(connection, (ws, req) { const unitId new URL(req.url, http://localhost).searchParams.get(unitId); server.handleConnection(ws, unitId); });snapshotInterval控制快照保存频率默认 30 秒。如果协同人数多、操作频繁可以调短到 10 秒但会增加 I/O 压力。持久化方式支持文件、Redis、数据库等生产环境建议用 Redis 或数据库。第三步处理冲突与离线编辑。Univer 的协同基于 OT 算法服务端负责做操作转换。实际使用中会遇到两个典型问题一是网络延迟导致的操作顺序错乱二是离线编辑后的合并冲突。OT 算法本身能处理大部分顺序问题但离线编辑需要客户端缓存操作队列重新连接后批量提交。Univer 的客户端插件内置了操作队列和重连机制但需要配置offlineSupport: true。4.2 服务端导出 Excel 与 PDFUniver 的 Node.js SDK 可以在服务端做表格导出这个能力在生成报表、批量导出场景下非常实用。实现思路是在 Node.js 里初始化一个无头 Univer 实例加载表格数据然后调用导出插件。import { Univer } from univerjs/core; import { UniverSheetsPlugin } from univerjs/sheets; import { UniverExcelExportPlugin } from univerjs/sheets-excel-export; const univer new Univer(); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverExcelExportPlugin); const workbook univer.createUnit(UniverInstanceType.UNIVER_SHEET, sheetData); const exportPlugin univer.getPluginByName(UniverExcelExportPlugin); const buffer await exportPlugin.exportWorkbook(workbook); fs.writeFileSync(./output.xlsx, buffer);这里的关键是不注册渲染插件。服务端导出不需要 Canvas所以只注册核心和表格插件即可这样启动更快、内存占用更低。导出 PDF 类似但需要额外引入 PDF 导出插件并且要注意中文字体的问题——Node.js 环境默认没有中文字体需要手动指定字体文件路径。提示服务端导出时公式计算会实时执行。如果表格里有大量复杂公式导出时间会明显增加。建议在导出前先做一次公式重算把结果缓存下来。4.3 插件开发写一个自定义的单元格高亮插件Univer 的插件开发接口比较清晰我以一个“单元格高亮”插件为例展示完整的开发流程。这个插件的功能是当用户选中某个单元格时在 Canvas 上绘制一个高亮边框。第一步定义插件类。继承Plugin基类实现onStarting和onReady钩子import { Plugin, PluginType } from univerjs/core; import { IRenderContext, RenderComponentType } from univerjs/engine-render; export class CellHighlightPlugin extends Plugin { static override type PluginType.Sheet; override onStarting(): void { // 注册渲染组件 this._renderManager.registerComponent( RenderComponentType.SHEET_MAIN, cell-highlight, CellHighlightComponent ); } }第二步实现渲染组件。渲染组件负责在 Canvas 上绘制高亮效果export class CellHighlightComponent { draw(ctx: CanvasRenderingContext2D, selection: ISelection) { const { startRow, startColumn, endRow, endColumn } selection; const rect this._getCellRect(startRow, startColumn); ctx.save(); ctx.strokeStyle #4A90D9; ctx.lineWidth 2; ctx.strokeRect(rect.x, rect.y, rect.width, rect.height); ctx.restore(); } }第三步注册插件。在初始化时注册univer.registerPlugin(CellHighlightPlugin);这个插件虽然简单但涵盖了 Univer 插件开发的核心流程注册渲染组件、监听选区变化、在 Canvas 上绘制。实际项目中你可以基于这个模式扩展出批注、条件格式、数据条等功能。4.4 性能实测不同数据量下的表现我在一台配置为 i7-12700H、32GB 内存的机器上用 Chrome 120 做了几组性能测试数据如下数据量首次渲染时间滚动帧率内存占用1000 行 x 10 列120ms60fps45MB10000 行 x 20 列380ms58fps120MB50000 行 x 20 列1.2s52fps380MB100000 行 x 50 列3.5s45fps850MB从数据可以看出Univer 在 5 万行以内的表现非常稳定滚动帧率保持在 50fps 以上。超过 10 万行之后内存占用和首次渲染时间会明显上升这时候需要配合分页加载或虚拟数据源来优化。一个实用的优化技巧是不要一次性把所有数据塞进cellData。Univer 支持异步数据源可以只加载可视区域的数据滚动时再动态请求。这样即使总数据量很大初始渲染也能保持很快。5. 常见问题与排查技巧实录5.1 安装与构建阶段的典型问题问题一pnpm 安装时 peer dependency 报错。Univer 的包之间依赖关系比较紧密如果 pnpm 版本过低或 npm 混用容易出现 peer dependency 冲突。解决办法是统一用 pnpm并在.npmrc里加上strict-peer-dependenciesfalse。如果还是报错检查 Node.js 版本是否低于 18。问题二构建时提示Cant resolve univerjs/...。这通常是包名拼写错误或版本不匹配。Univer 的包版本需要保持一致建议在package.json里用univerjs/*: 0.1.0这样的统一版本号避免不同包版本不一致导致的 API 不兼容。问题三Canvas 渲染空白。如果表格初始化后 Canvas 是空白的先检查容器元素的尺寸。Univer 的 Canvas 需要容器有明确的宽高如果容器是display: none或者宽高为 0Canvas 不会渲染。另外检查devicePixelRatio是否被设置成了 0 或负数。5.2 运行时常见异常与排查问题公式计算结果不正确。先检查公式引擎插件是否注册再检查单元格的f字段是否正确。Univer 的公式存储在单元格的f字段里v字段是计算结果。如果f存在但v为空说明公式没有触发计算。可以手动调用formulaPlugin.calculate()强制重算。问题协同编辑时操作丢失。这通常是 WebSocket 连接不稳定导致的。检查服务端的snapshotInterval是否过长以及客户端是否开启了离线支持。另外如果两个用户同时编辑同一个单元格OT 算法会做转换但转换结果可能不符合预期。建议在 UI 上做冲突提示让用户知道发生了合并。问题自定义函数不生效。检查函数注册的时机必须在UniverFormulaPlugin初始化之后注册。另外函数名不能包含特殊字符建议只用大写字母和下划线。5.3 常见问题速查表问题现象可能原因排查方法解决方案Canvas 空白容器无尺寸检查容器宽高给容器设置明确宽高公式不计算插件未注册检查插件列表注册 FormulaPlugin协同不同步WebSocket 断开查看网络面板检查服务端地址和端口滚动卡顿缓存过小调整 scrollCacheSize调到 1500-2000导出乱码字体缺失检查字体配置指定中文字体路径插件不生效注册顺序错误检查 registerPlugin 顺序按依赖顺序注册5.4 几个我踩过的坑和独家技巧坑一不要在生产环境用merge合并 locale。Univer 的merge函数是深拷贝如果 locale 文件很大每次初始化都会消耗大量时间。建议在构建时就把 locale 合并好运行时直接传入合并后的对象。坑二Canvas 的devicePixelRatio不要设太高。我一开始为了追求清晰度把devicePixelRatio设成了 3结果在 4K 屏幕上渲染 1 万行表格直接卡死。后来限制到 2清晰度几乎没差别性能提升了一倍多。坑三协同服务端的快照不要存太频繁。快照保存是 I/O 密集型操作如果每 5 秒存一次磁盘压力会很大。建议根据协同人数动态调整人少时 30 秒一次人多时 10 秒一次并且用 Redis 做缓冲。技巧一用requestIdleCallback做非关键渲染。Univer 的渲染是同步的如果表格很大初始化时会阻塞主线程。可以在requestIdleCallback里分批创建单元格数据让页面先渲染出来再逐步填充数据。技巧二自定义插件可以用onCommand做拦截。如果你想在某个命令执行前做校验或修改可以监听onCommand钩子返回false阻止命令执行或者修改命令参数后再放行。这个机制在做权限控制时特别有用。技巧三服务端导出时用worker_threads隔离。如果导出任务很重建议放在 Node.js 的worker_threads里执行避免阻塞主线程。Univer 的核心层是纯 JS 的可以很方便地跑在 worker 里。6. 关于 Univer 的扩展方向与个人体会Univer 的插件架构决定了它的扩展性几乎没有上限。我在项目里尝试过几个扩展方向效果都不错。一个是自定义数据源插件把表格的数据层对接到了后端的 GraphQL 接口实现了按需加载和实时更新。另一个是自定义渲染插件在 Canvas 上叠加了一层热力图用颜色深浅表示数值大小做数据可视化非常直观。还有一个值得关注的方向是移动端适配。Univer 的 Canvas 渲染在移动端浏览器上表现尚可但触摸事件的交互需要额外处理。我在 iOS Safari 上测试时遇到过 Canvas 导出白图的问题后来发现是devicePixelRatio和 Canvas 尺寸不匹配导致的手动设置canvas.width和canvas.height后解决。从技术选型的角度看Univer 适合那些需要深度定制表格能力的场景。如果你只是想要一个简单的表格展示用现成的 UI 组件库可能更省事。但如果你需要公式、协同、自定义渲染、服务端计算这些能力Univer 的架构优势就会非常明显。它的学习曲线不算平缓但一旦理解了插件系统和命令机制后续的扩展会变得很顺畅。最后分享一个我在实际使用中的小技巧Univer 的调试插件可以打开一个实时的命令日志面板能看到所有命令的发出和响应。排查协同问题时这个面板比看网络请求直观得多。如果你在开发协同功能建议一开始就把调试插件装上。
阅读完成 · 觉得有帮助?
咨询建站