2026/11/5 4:56:29

Univer实践:Canvas自绘+CRDT协同,把Excel级表格引擎嵌入前端

Univer实践:Canvas自绘+CRDT协同,把Excel级表格引擎嵌入前端 做了几年前端最烦的就是听到“这个表格需求很简单”这句话。普通表格展示确实不难难的是当业务方把Excel里的功能一个个搬出来合并单元格、条件格式、筛选排序、公式联动、多人同时编辑同一份表格。早年我用DOM表格硬扛数据量过万页面就开始卡虚拟滚动、懒渲染、按需计算各种优化手段加了一遍最后还是败给了浏览器DOM节点数的物理极限。后来在技术社区刷到Univer第一反应是“又一个在线表格Demo”。但把源码和架构文档翻了一遍之后我改变判断了——这是一个从头到尾用Canvas自绘渲染、把协同编辑和公式引擎内建在底层的开源办公文档引擎。它不是做一个“能看”的表格页面而是想把Excel、Google Sheets级别的能力做成前端基础设施直接嵌进你自己的产品。这篇文章不打算复述官方文档。我会从实际接入和改造的角度出发把Univer的核心设计思路、项目定位、快速上手方式、二次开发要点以及我自己踩过的几个真实的大坑一层一层说清楚。如果你正在做在线表格、数据填报、ERP系统、协同办公产品或者单纯好奇纯前端团队怎么把“重文档应用”塞进浏览器这篇应该对你有参考价值。1. 先搞清楚Univer到底是个什么东西1.1 它不是表格组件而是一个“文档内核”多数人第一次接触Univer是从GitHub上的Star数和README开始的看到“Sheet”模块就以为它跟Handsontable、Luckysheet一样是个表格类库。这个理解偏差会直接影响你用它的方式。Univer的官方定位其实是“统一办公文档引擎”底层拆成了多个独立模块Sheet电子表格、Doc文档、Slide演示文稿再加上一套统管渲染、命令、协同、公式、插件的内核。表格只是它目前最成熟的一个场景。这就像一个团队先集中火力把Excel能力做好了但底层的地基是按“office全家桶”的标准来打的后续再接其他文档类型时不需要推翻重来。这个定位意味着什么你拿到手的不是一组封装好的Spreadsheet标签而是一套可以拆开组合的“零件库”渲染引擎、命令系统、公式引擎、协同算法是独立的。你可以只用它的表格渲染部分也可以把协同、公式、菜单全部接上或者顺手挂一个自己写的插件进去扩充能力。1.2 技术底座Canvas渲染 Web Worker CRDT协同Univer在技术选型上和传统表格库拉开差距的核心有三点。第一渲染层不用DOM网格。所有单元格、选区、边框、数据条全部通过Canvas绘制底层是自研的一套渲染引擎。这让它在大数据量场景下拥有天然优势表格渲染不再受DOM节点数的约束。你可以把Canvas理解成一个画师画面里的元素再多画师也只是一张画布上不断重画而不是像DOM那样为每个单元格创建一个真实物体。第二计算和渲染分离。Univer在多线程设计上做了不少文章把公式计算、数据处理这类“重体力活”放到Web Worker线程里UI线程只管渲染和交互。这样即使公式计算量巨大界面也不会出现长时间无响应。第三协同编辑走了CRDT路线。多人同时打开一张表改同一个单元格不同客户端之间的操作怎么合并、怎么保持最终一致这是协同办公最硬核的部分。Univer在这块直接采用CRDT无冲突复制数据类型作为底层同步数据结构比传统的OT操作转换方案在跨域、离线场景下更容易做收敛。这部分官方文档目前描述得不算细但如果你的产品需要做多人协作这个选型方向是对的跟主流在线文档的思路是一致的。1.3 谁适合用Univer谁不适合结合我这段时间的实践下面两类场景适合选Univer业务系统里有一块“复杂表格区域”不是单纯展示数据而是要支持公式、条件格式、单元格合并、历史操作撤销、甚至多人协同例如财务系统、ERP、数据分析后台。团队有前端研发能力并且愿意花精力二次开发Univer提供引擎和插件机制但UI细节、权限逻辑、后端存储对接都要自己实现。没有开发人员期望拿来即用目前还做不到。反之如果只是需要一个轻量的表格编辑或者只在移动端简单展示数据那用Handsontable甚至直接写个二维数组渲染都更轻便。Univer本身带有比较明显的“重引擎”属性引入成本和学习曲线都偏高杀鸡用牛刀没有必要。2. 架构选型拆解为什么说它走了一条“难而正确”的路2.1 自研Canvas渲染的取舍与底气做过大数据量表格的人都知道DOM方案再怎么优化到了几万行就接近天花板。给每个单元格生成一个DOM节点内存和Layout计算都会被拖垮用虚拟滚动只渲染可视区滚动时又容易出现白屏和闪烁。Univer选择全部自绘确实是从根上解题。但“全部自绘”不是没有代价。第一实现成本极高一个成熟的Canvas表格引擎底层要处理字体测量、边框绘制、选区选中态、滚动重绘、事件坐标命中检测这些全是体力活。第二无障碍访问更困难Canvas里的文字对读屏软件来说是不可见的如果产品有严格的a11y合规需求需要额外做补充方案。第三文本编辑要自己模拟光标、选区、输入法合成这些都是Excel体验里最细枝末节又最容易出Bug的地方。Univer能走通这条路说明团队在自研渲染上是有底气的。它把渲染拆成了“场景树”和“图层”两层结构类似游戏引擎的概念所有可视化控件都挂在场景树里渲染器按需绘制图层。这样做的好处是局部更新时只需要重绘某个区域性能更容易控制。2.2 插件化架构与跨框架设计Univer包结构拆得非常细univerjs/core、univerjs/sheets、univerjs/ui、univerjs/formula、univerjs/network每个功能都是一等公民。这种组织方式有几个直接的好处。首先不绑前端框架。Univer核心层与框架无关React项目里能用Vue项目里能用原生JS也能用。它只是向外暴露实例和APIUI层通过适配器对接。这一点对团队技术栈不统一的公司来说很友好一套表格引擎可以同时服务多个不同框架的老项目。其次功能可以按需加载。不需要用协同协同模块就不打进主包不需要公式引擎公式模块也可以去掉。首包体积能控制在一个合理范围内。Univer后续还提供了univerjs/presets这类聚合包面向只想快速跑通的场景直接引入预设即可。这种“既要灵活又要省事”的思路本质上是在降低使用门槛。再看插件机制。Univer的菜单、工具栏、右键菜单、快捷键都是可以注册的。一个按钮从点击到执行命令走的是“UI事件 - Command - 数据变更 - 重绘”的命令链路。这种设计借鉴了Redux和领域驱动设计的思想所有变更都收敛到统一路径天然支持撤销重做和历史记录。2.3 主流表格库横向对比Univer、Luckysheet、Handsontable、SpreadJS我把自己实际评估过的几个方案放在一起对比方案渲染方式协同能力公式引擎开源协议扩展成本适合场景UniverCanvas自绘CRDT可用在多场景深度集成内建支持自定义Apache 2.0中高需要理解内核和插件机制复杂表格、协同办公、长期产品化LuckysheetCanvas DOM混合服务端需自行实现内建MIT较低上手快中小数据量的在线表格功能嵌入HandsontableDOM 虚拟滚动需额外方案有限商业授权非商业免费低快速搭建表格编辑数据量中等SpreadJSCanvas DOM混合商业方案配套强大商业授权低成熟度高企业级表格应用预算充足这里想特别提一句Luckysheet。它早年是开源的在线表格方案里成熟度比较高的一个但从维护状态和架构演进看Univer更像是它的“下一代”产品——同一批人在技术路线上做了彻底升级。如果你的项目面向未来三到五年重视协同和扩展Univer更合适如果只要一个稳定且轻量的表格页面Luckysheet或Handsontable仍可一战。协议方面Univer采用Apache 2.0对商用集成比较友好二次开发时不需要像纯GPL项目那样担心传染性开源问题。但商业友好不等于免费服务社区支持、文档、案例沉淀目前还是它的短板这需要自己有源码阅读能力。3. 实操上手10分钟跑起一个Univer表格3.1 环境准备与依赖安装我下面的示例以当前主版本为准Univer迭代很快API细节可能已经微调但整体接入思路是稳定的动手前先检查一下你安装版本的官方变更日志。一个最简单的Univer表格项目只需要Node环境和包管理器。它的基础依赖分为三块核心模块univerjs/core表格功能模块univerjs/sheetsUI渲染模块univerjs/ui。早期版本还有一个聚合入口univerjs/presets新手可以用它一条命令拉齐配置。npm install univerjs/core univerjs/sheets univerjs/ui如果网络环境不太好或者公司内网用私有npm源这几个包都有不少传递依赖装的时候建议直接装最新稳定版不要锁定太老的版本否则容易碰到模块间版本不匹配的问题。3.2 初始化一个最小实例安装完成后创建一个index.js核心代码如下import { Univer, UniverInstanceType } from univerjs/core; import { UniverSheetsPlugin } from univerjs/sheets; import { UniverUIPlugin } from univerjs/ui; const univer new Univer(); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverUIPlugin); const sheet univer.createUniverSheet();这段代码做了三件事创建Univer内核实例、注册表格插件和UI插件、创建一个Sheet实例。Univer的依赖注入做得比较重所有能力都通过registerPlugin挂到核心上。如果你发现某个默认功能没生效优先检查是不是对应的插件没注册。UI插件注册后会在指定的DOM容器里挂载整个表格视图。通常你需要在HTML里放一个占位容器然后通过配置把容器传给创建方法类似这样univer.createUniverSheet({ container: document.getElementById(app), sheet: { id: sheet-001, name: 第一个表格, rowCount: 1000, columnCount: 100 } });首次跑起来的时候我反而建议把行列数设小一点比如1000行100列先确认交互流畅度再逐步调大。因为Canvas首次全量绘制时行列数直接决定场景树的加载量一次性开到Excel级别的百万行首屏会有一段明显的构建时间。3.3 数据填充与基础配置表格跑起来之后下一步是塞数据。Univer的数据操作接口设计成了命令模式直接改cellValue是最快的填充方式const workbook univer.getUniverInstance(UniverInstanceType.UNIVER_SHEET); const worksheet workbook.getActiveSheet(); worksheet.getRange(0, 0, 10, 10).setValues( Array.from({ length: 10 }, (_, row) Array.from({ length: 10 }, (_, col) ({ v: 第${row 1}行-第${col 1}列, s: { bl: 1 } // 加粗 })) ) );有的同学会问为什么单元格值要包一层{ v: ... }对象而不是直接传字符串因为Univer的单元格模型里除了显示值v还有样式S、公式F、富文本p、备注等多个字段。统一的CellValue结构是为了让每个单元格都具备“被公式引用、被样式装饰、被协同同步”的能力。直接塞字符串看起来方便后续改样式和加公式时反而要多一步转换。配置层面有两个常用项主题和语言。Univer默认支持中文但要在入口处声明univer.registerPlugin(UniverUIPlugin, { container: document.getElementById(app), locale: zhCN, skins: { modern: { primaryColor: #316dca } } });颜色主题、字体、功能区布局都可以通过配置覆盖这部分跟CSS变量类似改起来不费劲但如果你的产品有完整的设计规范建议尽早把主题配置梳理成一套公司内部的设计Token而不是散落在各个页面里硬编码。4. 二次开发实践把Univer揉进自己的产品线4.1 自定义菜单与命令注册Univer默认的功能区已经自带了一系列操作求和、筛选、排序、合并单元格等。但真实业务中几乎一定会遇到“增加一个自定义按钮”的需求比如把选中区域的数据一键提交到后端。官方推荐的做法是走命令链路。先声明一个Commandimport { CommandType } from univerjs/core; const SAVE_SELECTION_COMMAND sheet.command.save-selection; univer.registerCommand({ id: SAVE_SELECTION_COMMAND, type: CommandType.OPERATION, handler: async (context, params) { const worksheet context.getActiveSheet(); const selection worksheet.getSelection(); const values selection.getValues(); await fetch(/api/save, { method: POST, body: JSON.stringify(values) }); return true; } });然后在工具栏注册按钮并绑定这个命令。这套设计的好处是命令本身不依赖UI实现你可以同时从菜单、右键菜单、快捷键、甚至外部脚本触发同一段逻辑避免“重新写一遍业务逻辑”在多个入口之间复制粘贴。回调函数的返回值也很有讲究返回true代表命令已消费返回false则会被判定为失败可以用在撤销栈和状态同步上。4.2 自定义函数公式是Univer的重头戏。它内置了日常办公高频使用的大部分函数但业务场景中经常会有“按公司规则计算”的特定函数比如把一段数值按阶梯提成比例拆算。Univer暴露了函数注册接口大致的注册结构是import { FunctionType, registerFunction } from univerjs/formula; registerFunction({ id: COMMISSION, functionType: FunctionType.NORMAL, minParams: 2, maxParams: 2, calculate: (base, rate) { if (base 0) return 0; if (base 5000) return base * rate; return base * rate (base - 5000) * 0.1; } });注册完成后单元格里就能直接写COMMISSION(A2, B2)了。这里要特别提醒自定义函数里的计算逻辑要尽量够纯不依赖外部状态、不产生副作用因为Univer的公式引擎可能在其他线程或离线环境下执行计算。如果你在函数里直接访问window或操作DOM很容易在协同场景或Web Worker模式下炸掉。4.3 与后端数据双向绑定表格不是孤立存在的几乎每个业务方都要求“加载服务端数据用户改完再保存回服务端”。这一块Univer没有强行规定接口协议你需要自己做映射。我的做法是分三层全量数据加载时用worksheet.getRange().setValues()一次性绘制这比逐单元格赋值快一个数量级。用户编辑产生变更时监听onCellChange事件收集变更范围拖一段时间后批量提交。服务端返回结果后用worksheet.getRange().setValues()回填服务端修正后的值。worksheet.on(cellChanged, (params) { const { row, column, value } params; changeQueue.push({ row, column, value }); });批量提交可以用简单的防抖策略新增编辑进队列后启动一个200ms的定时器如果队列里有数据就整体发送。这样做既能减少请求次数又不会让用户感到明显的等待。千万不要每次改一个单元格就发一个请求在协同系统里这会把后端打垮。4.4 协同编辑接入注意事项Univer的协同能力虽然内建在底层数据模型中但默认并不是开箱即用的服务端同步、操作分发的通道需要自己实现。我见过不少团队以为“用了Univer就有协同”结果集成时才发现还要自己搭一套WebSocket接口。接入协同前有几件事必须先想清楚房间概念。多个表格实例如何组织成一个协同房间谁有权邀请成员离开后怎么清理状态。权限热切换。普通成员和编辑者同时在房间里权限变化时需要通过命令实时调整。冲突提示。CRDT能避免数据冲突但没法避免业务冲突比如一个成员锁定了某列其他人又改这列。业务层的锁需要你们自己设计。离线编辑。如果产品有移动端离线场景CRDT同步后的合并策略需要在接入前做充分测试。协同是Univer最核心的卖点之一但也是最需要产品规划投入的部分。建议第一步先不做真正的实时同步而是基于命令链路做“操作广播全量状态回传”跑通链路后再上CRDT。5. 踩坑实录那些让我想换项目的地方5.1 常见问题速查表现象原因解决思路首次渲染白屏控制台报Canvas相关错误容器高度为0或Canvas初始化时DOM未挂载确保容器有明确高度初始化前检查DOM是否ready中文输入法下拼音上屏合成输入事件未正确处理升级到新版UI插件或检查自带输入法兼容补丁大数据量下滚动卡顿性能配置未开启频繁全量重绘调整渲染节流参数开启局部重绘模式页面里引入组件库弹层被表格遮挡Canvas层级高于普通DOM弹层给弹层设置更高z-index或放到独立渲染容器自定义命令点击后没有反应命令ID未注册或注册顺序晚于UI触发检查注册顺序把registerCommand放到创建Sheet之前公式数据更新但界面不变公式引擎未开启动态计算检查公式模块配置项避免使用了纯静态快照模式5.2 性能调优三板斧针对大数据量场景我个人实践下来最有效的优化是三件事。第一降低首屏渲染数据量。不要在初始化时就把几百MB的历史数据一次性set进表格页按需加载、分页加载、只加载可视区域附近的数据这些策略在Univer下依然需要你做。表格引擎解决的是“渲染一万行数据不卡”不是“把一百万行数据塞进内存还很快”。数据源优化永远是第一位的。第二关闭非必要的动画和实时特效。Univer支持单元格高亮、条件格式、数据条等一系列视觉效果这些在Excel里很常见但在浏览器里每一项都意味着额外的绘制开销。后台数据频繁更新时临时把这些视觉配置关掉或降级能明显提升滚动流畅度。第三控制协同消息的碰撞频率。协同模式下每个操作消息都会触发一次状态变更和重绘。如果多人同时编辑一个区域每秒可能产生几十条CRDT消息这时候要在服务端做消息合并和节流而不是让前端硬扛。5.3 与其他组件库混用的细节坑实际业务里Univer很少单独存在身边总有一堆antd、Element Plus之类的组件库。Canvas和普通DOM混用时的层级问题是最常见的坑。Univer的表格区域是Canvas普通组件库的弹窗是DOM。如果画布区域创建了一个高z-index的DOM节点它永远显示在弹层下面因为Canvas自身是同一个层级的元素内部再高也没用。解决办法是给弹层组件使用body级别的Portal并且显式设置z-index大于表格域的叠加层。反过来如果你需要做“点击表格单元格之外的空白区域收起弹窗”监听的事件不能挂在Canvas内部而是挂在Canvas外层的容器上否则eas点击坐标会被Canvas绘制事件吞掉。还有一个不起眼的点右键菜单。Univer自带的右键菜单也是Canvas绘制的如果产品自定义了右键菜单要注意两套菜单的触发入口是否冲突。我自己的做法是直接禁用Univer自带的ContextMenu插件全部用业务自定义菜单替换这样交互统一维护成本也低。6. 写在最后一点使用心得如果让我给还在观望的人一句话Univer现在还不够“傻瓜化”文档和API还在快速迭代集成过程中你大概率会遇到依赖版本不一致、API变更、官方示例跑不起来的尴尬。但它的技术路线确实把“表格引擎”中最难啃的部分做完了——Canvas渲染性能、公式引擎、协同数据模型、跨框架设计这些东西自己从头造轮子一个五人团队干半年都不一定够。我的经验是先跑通最小Demo不要一上来就追求把所有功能都接上。把表格塞进页面塞几条数据跑一下自定义命令再逐步往里面加公式、加协同。每加一块就带着具体业务场景去翻一遍源码比对着API文档猜要高效得多。后续等它的Doc模块和协同服务端方案更稳定之后我还会继续跟进到时拿到新的实测结论再回来补一篇。如果你已经在用Univer做项目或者正在调研选型阶段欢迎多交流各自踩到的坑——这类项目还是太新了社区里多一份真实经验后面的人就能少走一段弯路。