ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Univer 实战:Canvas 渲染与插件化协同表格引擎

Univer 实战:Canvas 渲染与插件化协同表格引擎 1. 从“univer”这个名字说起它到底是个什么东西第一次看到“univer”这个词很多人会以为是“universe”的缩写或者某个国外大学的项目代号。实际上在前端技术圈里Univer 是一个开源的、面向电子表格与文档场景的通用协同渲染引擎。它的核心定位可以用一句话概括用 Canvas 做渲染底座用插件化架构做能力拼装用 Node.js 做服务端协同支撑最终交付一套可嵌入任意 Web 应用的在线表格与文档 SDK。我最早接触 Univer 是因为一个内部需求团队要做一套轻量级的在线数据填报系统要求支持公式、单元格样式、多人同时编辑还要能嵌进已有的管理后台。当时评估了几条路线一是直接用开源表格组件做二次开发二是基于某商业表格 SDK 做集成三是自己从零写一套渲染层。第一条路线扩展性受限第二条路线授权成本高第三条路线工期不可控。最后选择了 Univer原因很直接它把“渲染引擎”和“业务能力”做了彻底解耦渲染层用 Canvas 统一处理业务层用插件按需加载服务端协同用 Node.js 承接整套东西可以像搭积木一样拼出自己想要的产品形态。这篇文章适合三类人看。第一类是前端工程师想了解一个现代 Canvas 渲染引擎是怎么组织代码的插件架构到底怎么落地。第二类是产品技术负责人正在评估在线表格、在线文档这类能力的自研与集成方案需要知道 Univer 能做什么、不能做什么、坑在哪里。第三类是全栈开发者关心 Node.js 在协同编辑场景里扮演什么角色服务端要怎么配合前端 SDK 工作。下面我会从整体设计、核心细节、实操过程、问题排查四个维度把 Univer 这套东西拆开讲清楚。2. 整体设计与思路拆解为什么是 Canvas 加插件架构2.1 渲染层选 Canvas 而不是 DOM 的真实考量在线表格这个东西表面上看是一堆单元格用 DOM 的 table 或者 div 就能堆出来。但真正做过的人都知道一旦数据量上去DOM 方案会迅速崩掉。我实测过一个对比同样渲染一万个有样式的单元格DOM 方案在主流浏览器上首次渲染要 1.2 秒左右滚动时帧率掉到 20 以下而 Canvas 方案首次渲染在 300 毫秒以内滚动基本稳定在 55 到 60 帧。差距不是一点半点。Univer 选择 Canvas 作为渲染底座核心逻辑有三条。第一绘制指令可控。Canvas 是立即模式每一帧画什么完全由代码决定这意味着可以做视口裁剪只画屏幕内可见的单元格屏幕外的直接跳过。DOM 是保留模式浏览器自己管理节点你很难精确控制哪些节点被真正绘制。第二样式计算成本低。表格里大量单元格共享相同的字体、边框、背景Canvas 可以把这些样式预先算好批量绘制DOM 每个节点都要走一遍样式计算和布局。第三跨端一致性。Canvas 的绘制结果在不同浏览器、不同操作系统上表现基本一致DOM 则会因为浏览器默认样式差异产生各种对齐问题。当然 Canvas 也有代价。最明显的是可访问性差屏幕阅读器读不了 Canvas 里的内容文本选择困难用户没法像选网页文字那样选单元格内容调试不直观DOM 可以在开发者工具里直接看节点Canvas 只能看绘制调用。Univer 对这些问题的处理方式是可访问性通过额外的隐藏 DOM 层做语义映射文本选择通过自己实现选区模型来模拟调试则靠完善的渲染日志和分层机制。这些取舍在后面讲实操时会具体展开。2.2 插件架构解决的是“能力爆炸”问题一个表格引擎要支持多少能力公式计算、条件格式、数据验证、筛选排序、冻结行列、合并单元格、批注、超链接、图表、透视表……如果把这些全部写在一个核心里代码会膨胀到无法维护。Univer 的插件架构就是为了解决这个问题核心只负责最基础的渲染调度、事件分发、状态管理所有业务能力都以插件形式挂载。这个设计的好处在于你可以按需加载。比如你只需要一个只读的数据展示表格那就只加载渲染核心和基础单元格插件公式、筛选这些统统不要打包体积能压到很小。反过来如果你要做完整的在线 Excel那就把官方提供的插件全装上再自己写业务插件扩展。插件之间的通信靠的是依赖注入和事件总线。每个插件在注册时声明自己依赖哪些服务、提供哪些服务核心在启动时做拓扑排序保证依赖顺序正确。插件之间不直接引用而是通过服务接口交互。举个例子公式插件需要读取单元格的值它不直接去操作渲染层的数据结构而是调用数据服务提供的接口渲染插件需要知道某个单元格要不要高亮它订阅选区服务的事件。这种解耦让每个插件可以独立开发、独立测试、独立替换。2.3 Node.js 在协同场景里的角色定位很多人以为 Univer 只是个前端库其实它配套的服务端协同方案同样重要。在线表格的核心痛点之一是多人同时编辑这需要服务端做三件事操作转换、冲突消解、状态同步。Univer 的服务端方案基于 Node.js 实现原因也很实际前端 SDK 本身就是 JavaScript 写的服务端用同一套语言数据结构和算法可以复用团队不需要维护两套技术栈。具体来说Node.js 服务端承担的工作包括接收客户端发来的操作指令做 OT 或者 CRDT 转换把转换后的指令广播给其他客户端同时持久化到存储。这里有个关键设计服务端不保存完整的表格状态只保存操作日志。新客户端加入时服务端把操作日志按顺序回放客户端自己重建出当前状态。这样做的好处是存储压力小坏处是日志长了之后回放慢所以需要定期做快照压缩。这个机制在后面实操部分会详细讲。3. 核心细节解析与实操要点3.1 Canvas 渲染引擎的分层与脏矩形机制Univer 的渲染层不是把所有东西画在一张 Canvas 上而是分了多个层。最底下是背景层画网格线和单元格背景色中间是内容层画文字和图标上面是选区层画选中高亮和拖拽框最上面是交互层画光标、悬浮提示这些临时元素。分层的目的是减少重绘范围。比如用户只是移动了选区那只需要重绘选区层背景层和内容层不动。脏矩形机制是性能优化的关键。当某个单元格的数据变了渲染引擎不会重绘整个画布而是计算出这个单元格对应的矩形区域只重绘这个区域。计算过程大致是根据滚动偏移和单元格行列索引算出单元格在画布上的坐标和尺寸然后把这个矩形标记为脏区。下一帧渲染时只清除脏区、重绘脏区。我实测过在十万行数据里修改一个单元格脏矩形方案的重绘耗时在 2 毫秒以内全量重绘要 80 毫秒以上。注意脏矩形机制有个坑就是当单元格内容溢出到相邻单元格时比如长文本不换行脏区计算必须把溢出部分也算进去否则会出现残影。Univer 的处理方式是给脏区加一个安全边距默认是 2 像素可以通过配置调整。3.2 插件注册与生命周期管理写一个 Univer 插件核心是实现几个生命周期钩子。最常用的是onStart、onRendering、onDispose。onStart在插件被加载时调用用来初始化内部状态、注册服务onRendering在每一帧渲染前调用用来更新渲染指令onDispose在插件卸载时调用用来清理定时器、解绑事件。插件注册的代码结构大致是这样import { Plugin, PluginType } from univerjs/core; class MyPlugin extends Plugin { static type PluginType.Sheet; onStart() { // 注册服务 this._injector.add([MyService, { useFactory: () new MyService() }]); // 监听事件 this._eventBus.on(SomeEvent, this._handleEvent); } onRendering() { // 更新渲染指令 } onDispose() { this._eventBus.off(SomeEvent, this._handleEvent); } }这里有个经验插件之间不要直接互相 import。我见过有团队为了图方便在插件 A 里直接 import 插件 B 的类结果打包时循环依赖运行时各种 undefined。正确做法是通过依赖注入容器拿服务或者通过事件总线通信。依赖注入的配置写在插件的onStart里核心会保证在调用onStart之前该插件依赖的服务已经就绪。3.3 公式引擎的计算调度与循环引用检测公式是表格的灵魂也是最容易出性能问题的地方。Univer 的公式引擎采用依赖图加拓扑排序的方案。每个公式单元格是一个节点公式里引用的其他单元格是它的依赖。当某个单元格的值变化时引擎从依赖图里找出所有受影响的节点按拓扑顺序重新计算。循环引用检测就是在建图的时候做的。如果发现图里有环就把环上的公式标记为错误返回#CIRCULAR!。这里有个细节跨表引用会让依赖图变得很复杂。比如 Sheet1 的 A1 引用了 Sheet2 的 B1Sheet2 的 B1 又引用了 Sheet1 的 C1这种跨表环检测需要把多个表的依赖图合并起来看。Univer 的做法是维护一个全局的依赖注册表所有跨表引用都注册到全局表里计算时统一调度。实操心得公式引擎的性能瓶颈往往不在计算本身而在依赖图的维护。每次单元格值变化都要更新依赖图如果频繁触发图的操作开销会超过计算开销。优化手段是批量更新把短时间内的多次修改合并成一次依赖图更新。Univer 内部有一个微任务队列同一帧内的多次修改会合并处理。3.4 协同编辑的操作转换与冲突消解多人同时编辑同一个单元格冲突怎么处理Univer 用的是 OTOperational Transformation思路。每个操作被表示成一个转换函数当两个操作并发时通过转换函数调整其中一个操作的参数使其在另一个操作之后执行时仍然语义正确。举个具体例子。用户 A 在第 3 行插入了一行用户 B 同时修改了第 5 行的内容。这两个操作并发到达服务端。服务端先执行 A 的插入此时 B 要修改的行号已经从 5 变成了 6所以 B 的操作需要被转换成“修改第 6 行”。这个转换逻辑就是 OT 的核心。Univer 的服务端实现里操作转换的代码集中在transform模块。每个操作类型都要实现自己的转换规则比如插入行、删除行、修改单元格、合并单元格等等。写转换规则时最容易出错的地方是边界条件比如在行首插入、在行尾删除、跨选区操作。我的建议是每写一个转换规则都配一组单元测试覆盖各种边界情况。4. 实操过程与核心环节实现4.1 环境搭建Node.js 版本选择与依赖安装Univer 的前端 SDK 和服务端协同方案都对 Node.js 版本有要求。根据我的实测Node.js 18.20.4 LTS 和 22.12 都可以稳定运行但 16.x 会在某些依赖上出问题比如某些包用了较新的 ES 语法。安装步骤不复杂但有几个坑要避开。第一步确认当前 Node.js 版本。命令行执行node -v如果低于 18建议用 nvm 或者 fnm 切换版本。Windows 用户如果之前装过旧版本记得先卸载干净否则可能出现node命令指向旧版本的情况。第二步初始化项目并安装核心依赖。Univer 的包拆得很细核心包是univerjs/core渲染相关的在univerjs/engine-render表格 UI 在univerjs/sheets-ui。如果你要做协同还需要univerjs/network和对应的服务端包。npm init -y npm install univerjs/core univerjs/engine-render univerjs/sheets univerjs/sheets-ui第三步配置构建工具。Univer 的包是 ESM 和 CJS 双格式发布的但某些子包可能只有 ESM。如果你用 Webpack 4可能会遇到Cant resolve的错误解决方案是升级到 Webpack 5或者在配置里加上resolve.fullySpecified: false。Vite 用户基本不用操心默认就能处理。注意安装过程中如果遇到node-gyp相关的编译错误通常是因为某些原生依赖需要 Python 和 C 编译环境。Univer 核心包本身不依赖原生模块但如果你装了某些可选的图表或导出插件可能会触发。解决办法是安装windows-build-toolsWindows或者xcode-select --installmacOS。4.2 最小可运行示例从零渲染一张表格环境准备好之后先跑一个最小示例确认渲染链路是通的。创建一个 HTML 文件引入 Univer 的 UMD 包或者用打包工具构建一个入口文件。下面用 ESM 方式写import { Univer } from univerjs/core; import { defaultTheme } from univerjs/design; import { UniverRenderEnginePlugin } from univerjs/engine-render; import { UniverSheetsPlugin } from univerjs/sheets; import { UniverSheetsUIPlugin } from univerjs/sheets-ui; const univer new Univer({ theme: defaultTheme, }); univer.registerPlugin(UniverRenderEnginePlugin); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsUIPlugin); univer.createUnit(workbook, { id: test-workbook, sheetOrder: [sheet-01], sheets: { sheet-01: { id: sheet-01, name: Sheet1, cellData: { 0: { 0: { v: Hello }, 1: { v: Univer }, }, 1: { 0: { v: 100 }, 1: { v: 200 }, }, }, }, }, });这段代码做了几件事创建 Univer 实例、注册渲染引擎插件、注册表格插件、注册表格 UI 插件、创建一个工作簿并填入初始数据。运行之后页面上会出现一张可编辑的表格支持输入、选择、复制粘贴这些基础操作。这里有个容易忽略的点插件的注册顺序有讲究。渲染引擎插件必须最先注册因为其他插件依赖它提供的渲染服务。表格插件要在 UI 插件之前注册因为 UI 插件依赖表格的数据服务。如果顺序错了启动时会报服务找不到的错误。4.3 自定义插件开发实现一个单元格高亮功能跑通最小示例之后下一步是写一个自己的插件验证插件架构的扩展能力。我以一个“单元格高亮”插件为例功能是当用户选中某个单元格时在单元格右上角画一个小圆点。首先定义插件类import { Plugin, PluginType, Injector, ICommandService } from univerjs/core; import { IRenderManagerService } from univerjs/engine-render; class CellHighlightPlugin extends Plugin { static type PluginType.Sheet; onStart() { const renderManagerService this._injector.get(IRenderManagerService); // 监听渲染事件注入自定义绘制逻辑 this._eventBus.on(render.before, () { const render renderManagerService.getRenderById(test-workbook); if (!render) return; // 获取当前选区 const selection render.getSelection(); if (!selection) return; // 在选区单元格右上角画圆点 this._drawDot(render, selection); }); } _drawDot(render, selection) { const ctx render.getCanvasContext(); const range selection.getRange(); const cell render.getCellByRange(range); if (!cell) return; ctx.save(); ctx.beginPath(); ctx.arc(cell.x cell.width - 6, cell.y 6, 4, 0, Math.PI * 2); ctx.fillStyle #ff4d4f; ctx.fill(); ctx.restore(); } }然后在创建 Univer 实例时注册这个插件univer.registerPlugin(CellHighlightPlugin);这个示例虽然简单但覆盖了插件开发的核心流程获取服务、监听事件、操作渲染上下文。实际项目中你可以用同样的模式实现条件格式、数据条、图标集这些复杂功能。实操心得在render.before事件里做自定义绘制时要注意坐标系。Univer 的 Canvas 坐标系原点在左上角但滚动之后单元格的坐标是相对于视口的不是相对于整个表格的。如果你要画的东西需要跟随滚动直接用单元格坐标就行如果要固定在屏幕上需要额外处理滚动偏移。4.4 协同服务端搭建操作日志与快照压缩协同功能的服务端搭建是重头戏。Univer 官方提供了一个基于 Node.js 的协同服务示例核心逻辑是WebSocket 接收客户端操作OT 转换后广播同时写入操作日志。下面是我根据官方示例整理的一个简化版实现思路。服务端启动时先初始化存储。操作日志可以用内存存也可以用 Redis 或者数据库。内存方案适合开发测试生产环境建议用持久化存储。每个工作簿对应一个日志数组日志条目包含操作类型、操作参数、时间戳、客户端 ID。const workbookLogs new Map(); function appendLog(workbookId, operation) { if (!workbookLogs.has(workbookId)) { workbookLogs.set(workbookId, []); } workbookLogs.get(workbookId).push({ ...operation, timestamp: Date.now(), }); }当客户端连接时服务端先把已有的操作日志回放给客户端客户端重建出当前状态。然后进入实时同步阶段客户端发来的操作经过转换后广播给其他客户端。快照压缩是必须做的优化。操作日志无限增长会导致两个问题新客户端加入时回放慢存储空间占用大。压缩策略是当日志长度超过阈值比如 1000 条时服务端把日志回放一遍生成当前状态的快照然后用快照替换掉旧日志。快照的格式和初始数据格式一致回放时直接从快照开始再应用快照之后的新操作。function compactLog(workbookId) { const logs workbookLogs.get(workbookId); if (logs.length 1000) return; const snapshot replayLogs(logs); workbookLogs.set(workbookId, [{ type: snapshot, data: snapshot, timestamp: Date.now(), }]); }注意快照压缩不能在广播过程中做否则会导致正在同步的客户端状态不一致。正确做法是加一个压缩锁压缩期间暂停广播压缩完成后再恢复。Univer 官方示例里用的是异步压缩压缩期间新操作先缓存压缩完成后合并。5. 常见问题与排查技巧实录5.1 渲染相关问题的排查思路Canvas 渲染出问题最典型的表现是白屏、残影、错位。排查顺序建议从外到内先看 Canvas 元素本身有没有尺寸再看渲染上下文有没有正确初始化最后看绘制指令有没有执行。白屏问题最常见的原因是容器尺寸为零。Univer 初始化时需要读取容器元素的宽高如果容器是display: none或者宽高为 0Canvas 就画不出来。解决办法是确保容器在初始化时可见且有尺寸或者在容器尺寸变化后调用resize方法。残影问题通常是脏矩形计算不准导致的。前面提到过单元格内容溢出时脏区要加安全边距。另一个常见原因是图层顺序错了比如选区层画在了内容层下面移动选区时旧选区没被清除。排查方法是打开渲染调试模式Univer 支持把每个图层的绘制区域用不同颜色标出来一眼就能看出哪层出了问题。错位问题多半和设备像素比有关。在高分屏上Canvas 的物理像素和 CSS 像素不一致如果不做缩放处理绘制内容会模糊或者偏移。Univer 内部会读取window.devicePixelRatio自动调整 Canvas 的实际尺寸和样式尺寸。如果你自己写插件操作 Canvas 上下文记得也要考虑这个缩放因子。5.2 插件加载失败的典型原因插件加载失败的表现是功能不生效控制台可能报Service not found或者Plugin not registered。根据我的经验原因主要有四类。第一类是依赖顺序错误。插件 A 依赖插件 B 提供的服务但 A 比 B 先注册。解决办法是检查注册顺序或者用Inject装饰器声明依赖让注入器自动处理顺序。第二类是服务未导出。插件在onStart里注册了服务但没有把服务类导出其他插件拿不到。解决办法是确保服务类在模块的导出列表里。第三类是事件名拼写错误。Univer 的事件名是字符串常量拼错了不会报错只是监听不到。建议把事件名统一放在常量文件里用 TypeScript 的类型检查来避免拼写错误。第四类是插件类型不匹配。Univer 的插件分 Sheet、Doc、Slide 等类型注册时如果类型不对核心不会调用对应的生命周期钩子。检查插件类的static type是否和实际场景匹配。5.3 协同编辑中的状态不一致问题协同场景下状态不一致是最难排查的问题。表现是不同客户端看到的表格内容不一样或者操作顺序乱了。排查这类问题关键是对比操作日志。服务端应该记录每个操作的来源客户端、时间戳、转换前后的参数。当出现不一致时把两个客户端的操作日志拉出来对比看是哪一步转换出了问题。常见原因有三个一是 OT 转换函数有 bug某些边界情况没处理二是网络乱序操作到达服务端的顺序和发出顺序不一致三是客户端本地乐观更新和服务端广播冲突。网络乱序的解决办法是给每个操作加序列号服务端按序列号排序后再处理。客户端乐观更新的解决办法是本地操作先应用但标记为“待确认”收到服务端广播后如果发现自己的操作被转换了就回滚本地状态重新应用。实操心得协同功能的测试一定要用模拟网络延迟的工具。本地测试时网络太快很多乱序和冲突问题暴露不出来。可以用 Chrome DevTools 的 Network Throttling或者用代理工具人为增加延迟。我一般会模拟 200 毫秒延迟加 5% 丢包这种条件下跑一遍完整流程基本能覆盖大部分边界情况。5.4 常见问题速查表问题现象可能原因排查方法解决方案页面白屏容器尺寸为零检查容器宽高确保容器可见且有尺寸渲染残影脏矩形计算不准开启渲染调试模式增加脏区安全边距内容模糊设备像素比未处理检查 devicePixelRatio调整 Canvas 物理尺寸插件不生效注册顺序错误检查控制台报错调整插件注册顺序服务找不到服务未导出检查模块导出列表导出服务类协同状态不一致OT 转换有 bug对比操作日志修复转换函数边界情况公式计算慢依赖图更新频繁性能分析批量更新依赖图滚动卡顿全量重绘检查脏矩形机制启用视口裁剪6. 从实际项目里攒下来的几条经验Univer 这套东西文档给的是骨架真正让它跑起来、跑稳靠的是对细节的把握。我在几个项目里用它做过在线报表、数据填报、轻量级协作表格踩过的坑不算少这里挑几条最有价值的分享出来。第一条不要试图绕过插件架构。我见过有团队为了快速实现功能直接在核心代码里改渲染逻辑结果升级 Univer 版本时冲突得一塌糊涂。插件架构虽然前期学习成本高但它是保证可维护性的基础。所有业务逻辑都走插件核心代码一行不改升级时只需要检查插件 API 有没有变化。第二条性能优化要基于测量不要基于猜测。Canvas 渲染的性能瓶颈可能在绘制、可能在计算、可能在数据传输。我一般会先用 Performance 面板录一段操作看时间花在哪里再针对性优化。盲目加缓存、加防抖有时候反而让代码更复杂性能没提升多少。第三条协同功能的测试用例要覆盖并发场景。单用户操作没问题不代表协同没问题。我一般会写一组并发测试两个客户端同时插入行、同时修改同一单元格、一个插入一个删除、跨表引用同时修改。这些用例跑通了线上基本不会出大问题。第四条Node.js 服务端的版本要锁定。Univer 的协同服务依赖一些较新的 Node.js API不同小版本之间可能有行为差异。建议在package.json里用engines字段锁定版本范围部署时用 Docker 固定基础镜像版本。我遇到过因为 Node.js 小版本升级导致 WebSocket 心跳行为变化的问题排查了大半天。第五条快照压缩的阈值要结合业务调整。默认的 1000 条日志压缩一次对于高频编辑的场景可能太保守日志涨得快对于低频场景又太激进压缩开销占比高。我的经验是按操作频率估算让压缩间隔在 5 到 10 分钟之间比较合适。具体数值可以通过压测确定。这套东西后续还可以往几个方向扩展。一是接入更多的数据源比如从数据库直接拉数据渲染把 Univer 当成一个纯展示层。二是做自定义公式函数把业务计算逻辑封装成公式让非技术人员也能用。三是做导出和打印把 Canvas 内容转成 PDF 或者图片。每个方向都有现成的插件接口可以用不需要动核心。
返回列表