ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Canvas渲染与命令架构驱动:Univer开源在线表格实测解析

Canvas渲染与命令架构驱动:Univer开源在线表格实测解析

三个多月前的某个深夜,我在逛 GitHub 趋势榜时注意到一个叫 univer 的项目。Star 增长曲线陡得像K线图的放量突破。当时项目还挂在 0.x 版本,文档里“roadmap”的野心却让我印象很深:不止做一个 Web Excel 的替代品,而是要做一套融合电子表格、文档、幻灯片的办公基础设施。我的第一反应是——又开始造轮子了,还是又一个“屠龙者”式的前端玩具?

直到最近一个内部项目需要重新选型表格方案,我才认真把它拉出来测了一遍。几天实测下来,我必须说:这是一个值得所有做协同办公、做低代码平台、做数据中台的团队认真研究的技术储备。这篇文章,我就以一位实际用它做过原型开发的工程师视角,把 univer 的真实底细拆给大家看。包括它的架构设计、核心能力边界、集成方式、性能表现,以及那些官方文档里没有明说、但你在实际落地中一定绕不开的坑。

1. 为什么 univer 值得被放进你的技术雷达

1.1 它到底解决了什么“旧问题”

先说痛点。国内做在线表格的团队,早期绕不开三个坎:一是核心渲染能力被商业产品锁定,想改一个单元格的绘制逻辑都得求爷爷告奶奶;二是公式引擎和交互体验完全绑定,想要离线缓存、协同编辑、插件扩展,几乎要推翻重来;三是整个前端生态被 React/Vue 等框架绑架,换了框架等于换了产品。

过去两三年,大家的选择其实并不多。LuckySheet 在开源表格里算能打的,但它的架构重心偏“能用”,而不是“可持续演进”。Handsontable 是商业授权,技术侧灵活,但成本不低。真正想要一套从渲染层、公式层到协同层都能自己掌控的体系,基本得自己从零开始造——而造一套表格引擎的难度,做过的人都知道那是“前端地狱”的入口。

univer 的切入思路很有意思:它把整个产品拆成了 engine(引擎内核)、render(渲染器)、ui(交互界面)、formula(公式引擎)、collaboration(协同)等独立模块。每个模块可以单独替换、单独扩展。这不是一次简单的开源,而是一次面向“长期演进”的架构选择。它默认你会在上面做二次开发,默认你要集成到自己的低代码平台或业务系统里,默认你的业务逻辑跟 Excel 一样复杂。

1.2 和传统开源表格方案的定位差异

为了让大家有个直观的空间感,我用一张表来比较 univer 和另外两类常见方案的区别:

对比维度univerLuckySheet / Handson-table使用 Excel 内嵌或第三方商业 SDK
架构开放性极高,渲染、公式、协同均可替换中等,可扩展但核心是封闭的低,黑盒交付,定制受限
渲染层自研 Canvas 引擎,不依赖 DOMDOM + Canvas 混合平台控件或 WebGL 封装
协同能力原生支持,接入 Yjs 生态需自研或集成外部 CRDT依赖厂商方案,扩展受限
公式引擎自研且逐步兼容 Excel部分兼容,函数列表有限强大但不可修改
适用场景长期投入、深度集成、产品化快速上线、功能要求单一内部工具、原型验证、非核心业务

简单说,如果你要做的是“demo”或“内部报表工具”,univer 的初期成本反而是高的;但如果你要做的是“产品”,那这套架构长期下来会非常省心。我这次用它搭的原型,从表格渲染到协同编辑跑通,只花了一个周末,最后提交的代码量明显少于我初判的预期。

2. 核心设计拆解:备云渲染、命令架构和插件体系

2.1 自研 Canvas 渲染引擎:它为什么不依赖 DOM

打开 univer 的页面,你会注意到它的滚动、缩放、单元格选中响应都非常“跟手”,完全没有传统 DOM 表格那种卡顿感。这背后是它自研的 Canvas 渲染引擎在工作。

很多表格项目用 DOM 渲染单元格,因为开发快、调试方便,但一旦碰到“几万个单元格 + 高频交互 + 冻结行列 + 缩放”这种真实场景,DOM 的性能天花板是肉眼可见的。univer 的做法是用 Canvas 整体绘制,渲染层直接操作绘制指令,只在必要的地方(比如输入框、浮层)使用 DOM。这样一个屏幕内哪怕展示数万个单元格,重绘压力也远小于同等规模的 DOM 节点操作。

从实际测试来看,我在本地用 univer 渲染了一份 5 万行、20 列、包含滚动和冻结首行的数据,交互流畅度比同等数据下的 DOM 方案有明显优势。当然,Canvas 渲染也带来一个代价:文本的“原生可选中、可复制”能力没有了。univer 的做法是通过内部 selection 模型来管理选区,需要复制时再由 JS 处理剪贴板。这里有个小坑后面我会讲到,直接复制单元格内容到系统剪贴板,需要额外处理。

2.2 命令驱动架构:逻辑与视图的彻底解耦

如果只看 API 层面,你会觉得 univer 像一个普通的表格库:创建 workbook、操作 worksheet、读单元格值。但一旦深入它的核心,你会发现所有用户的鼠标键盘操作,最终都会转化为一条一条的 Command(命令),由命令中心去执行、撤销、重做,再同步更新状态。

这是一个典型的“状态 + 命令”架构,跟 Redux 的 Action 有异曲同工之妙。好处非常实际:第一,撤销和重做不再需要保存整张表的快照,只需要把命令列表倒序回放;第二,协同编辑变的简单可控,每条命令在网络中传输后,只要在另外的客户端上重放,就能保证多端状态一致;第三,审计日志天然形成,谁在哪个时间点做了什么操作,都有迹可循。

我这里写了一个简单的自定义命令示例,可以看到这套机制的简洁之处:

// 注册一条“将选区背景色设为红色”的自定义命令 import { CommandRegistry } from '@univerjs/core'; const SetCellRedCommand = { id: 'sheet.command.set-cell-red', type: 'command', handler: (context) => { const { unitId, subUnitId } = context; const workbook = univerAPI.getWorkbook(unitId); const worksheet = workbook?.getSheetBySubUnitId(subUnitId); if (!worksheet) return false; worksheet.getSelection().forEach((range) => { worksheet.setRangeBackground(range, '#ff0000'); }); return true; }, }; CommandRegistry.registerCommand({ id: SetCellRedCommand.id, type: 'command', handler: SetCellRedCommand.handler, });

这样设计之后,任何业务上想要增加“批量操作”“宏录制”“脚本联动”都非常自然。因为你在改的不是某一个按钮的事件函数,而是向命令注册中心加一条规则。整套系统成了一整条可回放、可观察、可拦截的指令流,这是后续做自动化审计和权限控制的良好基础。

2.3 插件体系:它如何做到“像个平台”

univer 的核心包本身并不内置所有功能,它更像一个“办公操作系统”。从官方仓库的结构能看到,功能能力被拆成了很多插件包:@univerjs/sheets-ui、@univerjs/sheets-formula、@univerjs/collaboration、@univerjs/sheets-import-xlsx等等。你不需要的模块完全可以不引入,需要什么功能就装什么包,这对包体积控制非常友好。

插件系统设计上,每个插件可以拥有自己的 UI 组件、Command、业务逻辑和生命周期钩子。比如我想在右键菜单里加一个“统计选中区域平均值并弹出通知”,就可以做一个独立插件,直接监听 selection change 事件,并把结果通过 Notification API 展示出来。插件可以拿到统一的Univer实例和univerAPI,不用关心内部 CSS 作用域问题,也不用担心污染全局命名空间。

插件化的另一个好处是团队可以并行分工。一个团队维护表格渲染,一个团队负责公式扩展,一个团队做协同适配,彼此之间不会产生大量代码冲突。这一点在多人协作的项目里价值非常高。

3. 集成实操:把一个功能完整的表格嵌进你的项目

3.1 最快方式:从 React/Vue 组件接入

官方最推荐的接入方式是用来处理模块拼接的预设包@univerjs/preset-sheets。它一次性帮你组装好表格编辑器需要的最小功能集合,省去分模块配置的体力活。我用 Vite + React 跑了一个 demo,接入代码非常短:

npm install @univerjs/preset-sheets
import { Univer, UniverSheet } from '@univerjs/preset-sheets'; import '@univerjs/preset-sheets/index.css'; const univer = new Univer({ locale: 'zhCN', }); univer.createSheet({ entry: document.getElementById('univer-mount'), workbook: { id: 'workbook-1', sheets: [ { id: 'sheet-1', name: '订单数据', cellData: { 0: { 0: { v: '订单号' }, 1: { v: '金额' } }, 1: { 0: { v: 'A001' }, 1: { v: 120.5 } }, }, }, ], }, });

这里有一个容易踩的坑:如果你看到的是import { Univer } from '@univerjs/core'的方式,那走的是底层自定义组装路线,你需要手动加载一大堆插件。其实项目演进过程中官方 API 做过调整,我在跑 demo 时发现网上有很多古早版本教程,直接 copy 下来是跑不起来的。比较稳妥的判断依据是:preset 系列包是否在你的依赖里,以及univer.createSheet的调用方式是否以完整的 workbook 配置传入。

3.2 用数据流快速接入业务数据

实际业务中,很少人会只用静态单元格数据。univer 提供了worksheet.setRangeValues这类接口来批量写入数据。我测试时发现,用setRangeValues一次性写入 1 万行数据,性能表现很不错,明显优于逐格循环写入。另一个经验是,如果业务场景是“纯展示型的大数据表格”,可以把交互动效关掉(比如canvasZoom设为 false),渲染性能和内存占用会有肉眼可见的改善。

读取数据也比想象中简单。很多人会下意识用 DOM 去抓取单元格的文本,但 univer 数据的入口是它的存储模型。你可以这样拿到当前全表的数据:

const workbook = univerAPI.getActiveWorkbook(); const worksheet = workbook?.getActiveSheet(); for (let row = 0; row < 100; row++) { const cell = worksheet?.getCell(row, 0); if (cell) { console.log('第', row, '行,第 0 列的值是:', cell.v); } }

这比直接操作 DOM 优雅得多,也更符合“数据与视图分离”的架构初衷。在实际写业务逻辑时,我建议团队内部规范好“读写单元格必须走 univerAPI,绝不直接操作 DOM”,否则很容易在后期协同和序列化成版本时产生脏数据。

3.3 与后端接口和异步数据的配合

univer 的数据交互是异步友好的。比如你在cellData里放了一个 Promise,它并不会阻塞渲染,而是等数据 resolve 后自动刷新对应的区域。这个能力对于“表格 + 实时数据刷新”的需求非常实用。

我做了这样一个测试:后端接口每两秒推送一次股票价格,前端订阅后通过worksheet.setRangeValues更新单元格中的最新值。因为我只更新显示中的 10 行数据,所以渲染压力很小,交互完全不受影响。实际生产环境里,如果更新频率很高、数据量很大,建议做个节流,例如每 300ms 合并一次写操作,防止高频刷新导致表格重绘抖动。

4. 协同编辑与公式引擎:把“在线 Excel”做到什么程度

4.1 协同编辑方案的内外之别

univer 的协同能力分为“本地协同”和“服务端协同”两层。本地协同,也就是多个浏览器标签页操作同一个套件实例,是开箱即用的;而跨设备、跨用户的实时协同,目前更推荐的方式是接入 Yjs 生态。

我试过将表格状态同步到 Yjs 的 document 中,再通过一个简单的 WebSocket 频道转发 Yjs update。实际操作下来,这个方案的接入成本并不算高,因为 univer 内部状态已经天然是命令序列化的,每个操作都可以转换为一个 Yjs update 的增量数据。但需要注意的是,它不像某些专为协同设计的编辑器那样“配置即完成”。你需要自行处理 Yjs 的持久化、断线重连、冲突后状态校准。

这里有一个容易被忽略的点:服务端负责合并更新和最终存储,尽量避免在客户端直接做复杂的事务操作。建议把 univer 的状态变更事件流化后交给后端统一处理,后端再把合并结果广播回各个客户端。这样更符合“单一数据源”原则,排查问题时也会轻松很多。

4.2 公式引擎的真实兼容水平

公式是办公套件的灵魂,也是判断一个开源项目“成年”与否的关键标准。univer 自研了一个@univerjs/engine-formula引擎,我实际测试了 VLOOKUP、SUMIFS、INDEX、MATCH、IFERROR 这些常用函数,计算结果与 Excel 一致。并且它支持跨表引用、跨单元格范围计算,这在很多开源表格项目里都是可望不可及的能力。

不过我还是要提醒大家一个现实:它的公式兼容性仍处于快速追赶阶段,某些数组公式、动态数组溢出、跨工作簿引用、以及非常冷门的统计函数,可能还没有完全对齐 Excel 的行为。遇到这类边界情况,我的建议是先在测试用例里覆盖,确认可用后再正式排期。风险最大的不是“函数不存在”,而是某个函数在特定参数组合下返回了与 Excel 不一致的结果——这种 bug 在用户进行数字核对时才暴露,往往最致命。

4.3 导入导出与数据安全边界

XLSX 的导入导出能力,官方包已经提供。我从不同来源的 Excel 文件进行了测试:基础的单元格值、合并单元格、基础样式都能正常迁移,复杂的图表、透视表、数据验证规则的丢失概率较高。做产品决策时,如果“能打开 Excel”是硬指标,要对这一点的边界有清醒认知。

还有一个财务审计常问的问题:数据到底存在哪里?univer 默认是不落盘的,数据在你的状态树里,在全套件实例销毁后就没了。生产环境中你需要自己把 workbook 数据结构序列化到数据库,或者用持久化插件对接对象存储。千万别默认它自带“云存储”或自动保存能力。这些基础工程,必须由自己落地方能成为产品能力。

5. 常见问题与排查技巧实录

5.1 整合环境下的真实采坑记录

我在集成过程中,把遇到的高频问题按“症状、原因、解法”三列整理成了一张速查表,方便大家快速对号入座:

症状常见原因解决方案
打开页面白屏且控制台有Uncaught TypeError插件包版本不匹配,core API 与 UI 组件版本不同步统一使用同一 tag/版本,lock 全部@univerjs/*包版本
滚动表格有明显锯齿感、重影Canvas 渲染的 DPR 缩放未适配缩放屏监听 resize 事件并触发 re-render,或强制设定高 DPI 渲染
复制单元格数据后粘贴到 Excel 变成纯文本内容剪贴板写入仅支持文本格式,缺少 HTML 格式自行监听复制事件,组装 Excel 可识别的 HTML Clipboard 数据
自定义菜单/插件挂载后不显示插件注册时机早于 UI 挂载在createSheet完成后再注册插件,或使用官方提供的plugin生命周期钩子
协同编辑时一方断开,恢复后数据不一致缺少状态快照机制,重连后只能增量同步服务端定期生成 Yjs 快照,客户端重连时先拉快照再应用增量
公式计算结果为#NAME?未启用公式引擎,或函数名写法不兼容检查是否引入@univerjs/sheets-formula;确认公式中函数名是否全大写

5.2 性能调优的几条硬建议

如果你的表格数据常在 10 万行以上,有几点建议值得一试。

第一,永远不要一次性把所有数据放进一个cellData然后对外渲染。可以采用分区渲染,或者只渲染可视区域内的数据。univer 的 Canvas 渲染本身已经做了视口裁剪,但如果你在构造cellData时就一次性塞入全量数据,初始化时间和内存占用都很可观。

第二,关闭非必要的工具和 UI 插件。像富文本编辑、评论批注这类功能,如果业务不需要,可以不引入对应包。别小看这些包的内存占用,对“特种表格 + 客户端环境较老”的场景,影响会被明显放大。

第三,利用univerAPI的事件订阅机制做节流。例如监听onCellEdit事件时,如果单元格改动频繁,建议内部做 100~300ms 的防抖,避免每次键入都触发一遍业务逻辑。

关于“踩过的坑”还有一点想特别提一下:skia/skia?,不,我们用 Canvas 时,偶尔会遇到图形资源未释放导致的内存泄漏,特别是长时间开着页面、来回切换工作表之后。建议在插件里额外监听 workbook dispose 生命周期,手动清掉你自定义的定时器、事件监听和 Canvas 实例引用,否则在单页应用里反复创建、销毁 sheet 后,页面会同步变卡、变慢。

5.3 生态工具与协作建议

univer 的社区还在快速成长。遇到问题时,最靠谱的两个信息源是官方 GitHub 仓库中的 issues 和官方更新日志(changelog)。由于项目现阶段 API 变动频繁,搜索引擎里的部分文章可能对应的是几个月前的旧版本,建议以官方文档和 npm 包里的 TypeScript 类型定义为最终参考。

如果你的团队计划用它做核心组件,建议多做一层抽象封装,不要让业务代码直接散落在 univer API 的调用中。哪怕只是封装一层TableService,把创建、读取、更新、订阅等方法收敛一下,后续版本升级带来的 API 变更,也能把改动控制在一个文件里,而不用全局搜索替换。

6. 版本升级与长期维护策略

6.1 升级前别急着动工

我见过不少开源项目,用户从 0.1 升级到 0.2 就被 API 变更折腾得放弃治疗。univer 目前仍处在这个高频迭代期,升级前一定要先读 changelog。特别是核心包@univerjs/core的大版本变动,往往会连带影响到所有基于核心包的插件。

我的习惯是:每个组件库相关依赖独立锁定版本号,升级时先跑一边完整单测,再对着 changelog 逐项排查变更点。表格这类交互重、状态逻辑复杂的组件,仅靠“页面打开没报错”远远不够,还需要覆盖撤销、复制、公式重算、协同同步等核心断言。

6.2 团队内部的知识积累

把 univer 引入团队后,知识的沉淀比代码本身的产出更重要。你可以建一个内部的“univer 踩坑手册”,记录每次 API 迁移、每个奇怪的兼容问题、每个自定义插件的写法心得。等你们真正走到深度定制那一步时,这个手册的价值会远超任何外部文档。

建议至少每周抽出半天,去浏览一下 univer 仓库的 issue 和近期 commit。这样你能提前知道哪些功能马上要调整、哪些方案是官方主推路线,从而避免走错技术方向。

6.3 什么时候应该放弃它

说到底,univer 不是万能救世主。如果你的业务只是随便展示个表格,没有深度交互、没有协同需求、没有公式计算,那引入它就是给自己找麻烦;如果你要做的是类似 Excel 的高完整度产品,并且团队完全没有长期维护一个复杂前端引擎的决心,也建议认真评估一下成本。做开源二次开发,最怕的不是代码难,而是“用了没深入,坏了没人会修”的中间姿态。

7. 实际测试心得与后续扩展空间

这几天用下来,我最大的体会是 univer 的“潜力”大于“当前完成度”。它已经具备了像成熟商业产品一样的长线演进架构,但距离“开箱即用、替换 Excel”仍有相当距离。很多能力需要开发者补齐,例如数据校验的完整支持、复杂图表的三方集成、移动端的触控优化等。但正因为架构清晰,这些问题都不是堵死的墙,而是可以逐个击破的优化点。

我自己已经在这个原型上继续扩展了一个方向:用它的 Command 架构做了操作审计系统——每个用户的每一次编辑、每一次公式改动,都以命令形式记录起来,后端可以完整回放任意时间点的表格状态。这个能力在传统电子表格上实现成本极高,但在 univer 上真的非常顺手,因为命令本身已是第一公民。

还有一个后续值得关注的方向是它的 UI 层。目前整体交互完成度已经不错,但如果你想高度定制右键菜单、工具栏或状态栏,需要自己动 UI 插件的代码。这一点比较考验团队的 CSS 功底和插件架构理解。对于想要深挖的团队,我建议把所有 UI 定制都做进插件里,不要在 node_modules 里改核心代码,否则升级一次就崩一次。

最后分享一个非常实用的小技巧:如果你需要实现“模板市场”或者“个人偏好模板”,可以把 univer 表格的数据结构导出为 JSON,存入对象存储;用户新建时,直接通过univer.createSheet把这份 JSON 作为模板渲染出来。这套方案实现起来非常轻量化,而且不需要额外建复杂的文档存储服务。后续再做版本对比或模板收藏,都会非常自然。

在我测试期间,微信上不止一个朋友问我“这到底能不能用在生产环境”。我的回答一直很谨慎:如果你拿它当 Excel 替代品,生产环境要补的东西还很多;但如果你拿它当办公应用的底座、表格能力的中台,那它会是一块非常扎实的地基。挑好你的战场,它的稳定性和架构优势才会真正为你所用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进