
1. 从一张表格说起为什么我们需要 Univer做过在线表格类产品的人大概都有过这种体验产品经理跑过来说“我们要在系统里嵌一个能编辑的表格最好还能支持公式、图表、多人协作”你打开搜索引擎搜了一圈发现能选的方案无非就那么几类——要么是重量级的商业组件授权费按年收还得看人家脸色要么是开源方案但文档稀碎、扩展性堪忧想加个自定义函数得把源码翻个底朝天。Univer 就是在这个背景下进入视野的。它是一个开源的、面向在线表格与文档场景的前端 SDK核心能力是把“电子表格”这件事从零到一地在浏览器里跑起来。你可以把它理解成一套“表格引擎 渲染层 插件系统”的组合拳底层用Canvas做高性能绘制上层用插件架构把功能拆成一个个可插拔的模块运行时既能在浏览器里跑也能在Node.js环境里做服务端计算。我第一次接触 Univer 是因为一个内部数据看板的需求需要让运营同学直接在页面上改数据、加公式、导出结果而不是每次改个数字都要找开发。当时评估了几个方案最后选 Univer 的核心理由有三点第一它是真正的前端 SDK不是那种“iframe 嵌一个别人的表格”的伪集成第二插件架构意味着我可以只引入需要的功能不用为一个简单的只读表格背上整个富文本编辑器的体积第三Canvas 渲染在几万行数据滚动时的表现比 DOM 渲染方案稳太多。这篇文章适合谁看如果你正在做在线表格、报表工具、低代码平台里的数据编辑模块或者单纯想搞清楚一个现代表格引擎是怎么设计出来的那接下来的内容应该对你有用。我会从整体设计思路讲到核心实现细节再到实操步骤和踩坑记录尽量把“为什么这么设计”讲透而不是只丢一堆 API 文档。2. 整体架构拆解Univer 到底是怎么组织的2.1 三层结构数据层、逻辑层、渲染层Univer 的架构可以粗略分成三层这个分层思路其实和很多成熟的编辑器产品是一致的但它在“解耦”这件事上做得比较彻底。最底下是数据层负责存储表格的原始状态——单元格的值、样式、公式、行列结构、合并信息等等。这一层不关心你怎么画只关心数据长什么样、怎么变。Univer 用了一套基于命令的模式来管理状态变更任何对表格的修改都不是直接改数据而是发一个命令出去由对应的处理器去执行。这么做的好处是天然支持撤销重做、协同编辑时的操作同步以及操作日志追溯。中间是逻辑层也就是各种 Service 和 Controller。公式计算、数据校验、选区管理、剪贴板逻辑这些都属于这一层。它从数据层拿状态算完之后告诉渲染层“该画什么”。最上面是渲染层基于 Canvas 实现。这里有个关键设计Univer 并不是把整个表格画成一张大 Canvas而是做了分层渲染和可视区域计算。你看到的表格视口里只有当前可见的那部分单元格会被真正绘制滚动的时候动态更新。这就是为什么它能扛住大数据量——DOM 方案里几万个单元格节点直接把浏览器拖垮而 Canvas 只画看得见的那几十行。2.2 插件架构为什么不是一个大包Univer 最核心的设计决策之一就是一切皆插件。核心包只提供最基础的运行时、依赖注入容器和生命周期管理具体功能——比如公式、条件格式、图表、协同——全部以插件形式存在。这个选择背后的逻辑很实际。表格产品的需求差异极大有人只要一个能编辑的网格有人要完整的 Excel 替代品还有人只要只读展示。如果做成一个大而全的包前者会抱怨体积太大后者会抱怨功能不够。插件化之后你可以按需组合基础表格编辑univerjs/sheetsuniverjs/sheets-ui加公式再引入univerjs/sheets-formula加条件格式引入univerjs/sheets-conditional-formatting加协同引入协同相关的插件包每个插件通过依赖注入容器注册自己的服务、命令和 UI 组件插件之间通过容器解耦不直接互相引用。这意味着你可以替换掉某个插件的实现只要它注册的服务接口不变其他插件完全无感知。提示插件化带来的一个实际好处是“按需加载”。在 Web 场景下你可以把非首屏需要的插件做成动态 import首屏只加载基础表格用户点开公式面板时再加载公式插件首屏体积能省下不少。2.3 跨端能力浏览器和 Node.js 共用一套核心Univer 的另一个特点是它的核心逻辑不依赖浏览器环境。数据层、公式计算、命令系统这些纯逻辑部分在 Node.js 里也能跑。这意味着你可以做几件有意思的事一是服务端计算。比如用户上传一个 Excel 文件你在服务端用 Univer 的核心包解析、计算公式、导出结果不需要起一个无头浏览器。二是协同服务。多人编辑时的操作合并、冲突处理可以在服务端完成客户端只负责发操作和收结果。三是测试。公式引擎这种纯逻辑模块在 Node.js 里跑单元测试比在浏览器里跑端到端测试快得多也稳定得多。这种跨端能力的实现靠的是把“平台相关”的代码隔离在渲染层和少数适配层里核心包只依赖抽象接口。你在 Node.js 里引入核心包时渲染相关的模块根本不会被加载。3. 核心细节解析几个关键机制的实际运作方式3.1 命令系统所有修改都走同一条路前面提到 Univer 的状态变更走命令模式这里展开说一下它具体怎么运作。当你调用一个 API 去修改单元格值时实际发生的是这样一条链路你调用类似univerAPI.getActiveWorkbook().getActiveSheet().getRange(A1).setValue(hello)的方法这个方法内部并不直接改数据而是构造一个SetRangeValuesCommand并派发出去命令被对应的SetRangeValuesCommandHandler接收处理器校验参数、执行变更、更新数据层数据层变更后触发事件渲染层监听到事件标记对应区域为“脏区”下一帧重绘这条链路看起来比“直接改数据然后重绘”绕但它换来了几个关键能力。撤销重做只需要记录命令和反向命令不用去 diff 整个状态。协同编辑时把命令序列化发给其他端其他端重放命令即可天然保证一致性。操作审计也有了基础每个命令都带着来源和参数。注意自定义功能时正确的做法是注册自己的命令和处理器而不是绕过命令系统直接改数据。绕过命令系统会导致撤销重做失效、协同不同步后期排查起来非常痛苦。我见过有人为了图快直接操作内部状态结果协同场景下两端数据对不上回头重构花了两倍时间。3.2 Canvas 渲染可视区域与脏区重绘Canvas 渲染的性能关键在于“只画该画的”。Univer 的渲染流程大致是这样的首先渲染层维护一个视口模型知道当前滚动位置、可见的行列范围。每次需要重绘时它只遍历可见范围内的单元格而不是全部数据。对于十万行数据可见的可能就五十行绘制量是常数级的。其次它做了分层 Canvas。背景网格线、单元格内容、选区高亮、悬浮提示这些分别画在不同的 Canvas 层上。这样当只有选区变化时只需要重绘选区那一层内容层不用动。这个思路和地图应用里的图层管理是一样的。第三是脏区标记。数据变更时不是整个表格重绘而是把受影响的单元格区域标记为脏区下一帧只重绘脏区。改一个单元格就只重绘那一个格子改一行就重绘那一行。这套机制组合下来实际表现是滚动流畅、编辑响应快、大数据量下不会卡死。当然代价是渲染逻辑比 DOM 方案复杂得多调试的时候不能像 DOM 那样直接审查元素得靠日志和渲染层的调试工具。3.3 公式引擎依赖图与增量计算公式是表格的灵魂也是最容易出性能问题的地方。Univer 的公式引擎核心是一张依赖图每个公式单元格记录它引用了哪些单元格每个被引用的单元格记录它被哪些公式依赖。当某个单元格的值变化时引擎不是重新计算所有公式而是沿着依赖图找到所有受影响的公式按拓扑顺序增量计算。比如 A1 变了只有直接或间接引用 A1 的公式会重算其他公式的结果保持不变。这里有个细节值得说循环引用的检测。如果 A1 的公式引用 B1B1 的公式又引用 A1依赖图里就出现了环。引擎在构建依赖关系时会做环检测发现循环引用就标记为错误值而不是无限递归下去。实操心得自定义公式函数时一定要正确声明函数的参数类型和是否易变volatile。如果一个函数依赖外部状态比如当前时间要标记为易变否则引擎会缓存结果导致不更新。我当初写一个自定义函数时忘了标记易变结果时间一直不刷新排查了半天才发现是这个原因。3.4 插件生命周期注册、启动、销毁一个 Univer 插件的完整生命周期包括几个阶段。插件类实现onStarting和onReady等钩子在onStarting里注册自己提供的服务和命令在onReady里做依赖其他插件的初始化工作。销毁时实现dispose清理事件监听和定时器。依赖注入容器保证了插件之间的依赖顺序如果插件 B 依赖插件 A 提供的服务容器会确保 A 先启动。这个机制让插件可以安全地假设“我依赖的服务在我启动时已经就绪”。4. 实操过程从零搭一个可编辑表格4.1 环境准备与依赖安装先确认 Node.js 环境。Univer 对 Node.js 版本有要求建议用 18 LTS 或更高版本。检查当前版本node -v npm -v如果版本太低去官网下载 LTS 版本安装即可。安装完成后新建项目并安装 Univer 相关包。以一个基础的表格编辑场景为例需要安装核心包、表格插件和 UI 插件npm install univerjs/core univerjs/design univerjs/engine-formula univerjs/engine-render univerjs/sheets univerjs/sheets-formula univerjs/sheets-ui univerjs/ui这里解释一下每个包的作用。univerjs/core是运行时核心提供依赖注入、命令系统、生命周期管理。univerjs/design是设计系统提供基础 UI 组件和样式变量。univerjs/engine-formula是公式引擎univerjs/engine-render是 Canvas 渲染引擎。univerjs/sheets提供表格的数据模型和命令univerjs/sheets-formula把公式引擎接入表格univerjs/sheets-ui提供表格的交互 UIuniverjs/ui是通用 UI 框架。提示包名和版本会随迭代变化安装前建议去官方仓库确认当前推荐的包组合。不同版本之间 API 可能有差异混用版本是常见的报错来源。4.2 初始化实例与注册插件安装完成后在入口文件里初始化 Univer 实例。核心思路是创建一个 Univer 对象然后依次注册需要的插件import { Univer, LocaleType, merge } from univerjs/core; import { UniverFormulaEnginePlugin } from univerjs/engine-formula; import { UniverRenderEnginePlugin } from univerjs/engine-render; import { UniverUIPlugin } from univerjs/ui; import { UniverSheetsPlugin } from univerjs/sheets; import { UniverSheetsFormulaPlugin } from univerjs/sheets-formula; import { UniverSheetsUIPlugin } from univerjs/sheets-ui; const univer new Univer({ locale: LocaleType.ZH_CN, theme: defaultTheme, }); univer.registerPlugin(UniverRenderEnginePlugin); univer.registerPlugin(UniverFormulaEnginePlugin); univer.registerPlugin(UniverUIPlugin, { container: app, }); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsFormulaPlugin); univer.registerPlugin(UniverSheetsUIPlugin);注册顺序有讲究。渲染引擎和公式引擎是底层能力要先注册。UI 插件提供了容器表格 UI 插件依赖它。如果顺序错了插件启动时找不到依赖的服务会直接报错。4.3 创建工作簿与配置初始数据插件注册完之后创建实际的工作簿univer.createUnit(UniverInstanceType.UNIVER_SHEET, { id: sheet-001, sheetOrder: [sheet1], sheets: { sheet1: { id: sheet1, name: 数据表, rowCount: 1000, columnCount: 26, cellData: { 0: { 0: { v: 姓名 }, 1: { v: 数量 }, 2: { v: 单价 }, 3: { v: 金额 }, }, 1: { 0: { v: 商品A }, 1: { v: 10 }, 2: { v: 25.5 }, 3: { f: B2*C2 }, }, }, }, }, });这段配置里cellData的结构是“行索引 - 列索引 - 单元格数据”。v是值f是公式。注意公式里用的是 B2、C2 这种 A1 表示法引擎会自动解析成行列索引。4.4 挂载到页面与样式处理最后一步是把 Univer 挂到页面的容器元素上。容器需要明确的高度否则 Canvas 算不出可视区域div idapp stylewidth: 100%; height: 600px;/div// 容器准备好后Univer 会自动在其中创建 Canvas // 如果容器尺寸变化需要通知渲染层重新计算 window.addEventListener(resize, () { // 触发重绘逻辑 });样式方面Univer 的 UI 组件依赖设计系统的 CSS 变量。如果样式没引入界面会看起来“裸奔”。确保在入口处引入了univerjs/design的样式文件。注意容器高度为 0 是新手最常见的坑。Canvas 初始化时如果容器没有高度可视区域计算出来是空的表格就“消失”了。排查时先看容器有没有实际尺寸。5. 常见问题与排查技巧实录5.1 表格不显示或显示空白这是最高频的问题原因通常有几类。第一类是容器尺寸问题前面说过容器没有高度或者被display: none隐藏时Canvas 算不出可视区域。第二类是插件注册顺序问题渲染引擎没注册就去创建表格自然画不出来。第三类是数据格式问题cellData的索引结构写错了比如把行列索引搞反了数据其实存在但不在可视区域。排查顺序建议是先看控制台有没有报错再看容器尺寸再看插件注册日志最后检查数据格式。Univer 的插件启动会打日志打开 debug 模式能看到每个插件的启动状态。5.2 公式不计算或计算结果不对公式问题的排查要分几步。首先确认公式引擎插件和表格公式插件都注册了缺一个都不行。其次检查公式字符串的格式A1 表示法要写对函数名大小写不敏感但拼写要对。第三如果是自定义函数检查有没有正确注册到函数注册表里。还有一种情况是计算结果“看起来不对”其实是单元格格式的问题。比如计算结果是 0.10.2显示成 0.3 是因为格式化了实际值还是 0.30000000000000004。这是浮点数的经典问题不是引擎的 bug。5.3 大数据量下滚动卡顿如果数据量上万行后滚动开始卡先检查是不是所有数据都塞进了初始cellData。Univer 支持虚拟滚动但初始数据太大时解析和建索引本身就要时间。建议的做法是初始只加载可见范围的数据滚动时按需加载。另一个常见原因是自定义渲染逻辑太重。如果你在单元格渲染回调里做了复杂计算或者频繁 DOM 操作会拖慢整个渲染循环。渲染回调里只做必要的绘制复杂逻辑提前算好缓存起来。5.4 协同场景下数据不一致协同问题排查起来最麻烦因为涉及多端。核心原则是所有修改必须走命令系统不能绕过。如果发现两端数据不一致先检查有没有代码直接操作了内部状态。其次是检查命令的序列化和反序列化有没有丢信息比如某些命令带了不可序列化的参数。还有一个容易忽略的点是命令的执行顺序。协同场景下不同端的命令到达顺序可能不同需要服务端做排序或者用 OT/CRDT 算法处理冲突。Univer 的命令系统提供了基础但具体的协同策略需要自己实现或引入协同插件。问题现象可能原因排查方向表格空白容器无高度检查容器 CSS 尺寸表格空白插件未注册检查注册顺序和日志公式不计算公式插件缺失确认两个公式包都注册滚动卡顿初始数据过大改为按需加载协同不一致绕过命令系统检查是否有直接改状态自定义函数不更新未标记易变检查函数元数据配置5.5 打包体积过大Univer 的包不少全量引入后打包体积可能超出预期。优化思路有几个一是按需引入插件只装用到的功能包二是用动态 import 做代码分割非首屏插件延迟加载三是检查有没有重复引入不同版本的依赖用打包分析工具看看哪些包占了大头。我实测过一个只读表格场景只引入核心、渲染、表格和表格 UI 四个包打包后 gzip 体积能控制在比较理想的范围内。加上公式和条件格式后会明显增大所以按需引入不是可选项是必选项。6. 插件扩展写一个自己的功能模块6.1 插件的基本结构写一个 Univer 插件本质上是实现一个类在生命周期钩子里注册自己的东西。最小结构大概是这样import { Plugin, ICommandService, Inject } from univerjs/core; class MyPlugin extends Plugin { static override pluginName my-plugin; constructor( Inject(ICommandService) private _commandService: ICommandService ) { super(); } override onStarting(): void { // 注册命令、服务 } override onReady(): void { // 依赖其他插件的初始化 } override dispose(): void { // 清理资源 } }onStarting里做注册onReady里做初始化dispose里做清理。依赖注入用装饰器声明容器会自动注入。6.2 注册自定义命令假设我们要加一个“批量填充”功能选中一个区域后填入相同值。先定义命令export const MyBatchFillCommand { id: my.command.batch-fill, type: CommandType.COMMAND, handler: (accessor, params) { const commandService accessor.get(ICommandService); // 执行填充逻辑 return true; }, };然后在插件的onStarting里注册this._commandService.registerCommand(MyBatchFillCommand);命令的 handler 里拿到参数后构造对数据层的修改操作。注意这里要复用 Univer 已有的命令比如设置单元格值的命令而不是自己直接改数据这样才能保证撤销重做和协同正常工作。6.3 添加 UI 入口功能有了还得让用户能触发。Univer 的 UI 插件提供了菜单、工具栏、快捷键等扩展点。以工具栏按钮为例通过 UI 服务注册一个按钮绑定到刚才的命令// 在 onReady 里注册 UI const uiService accessor.get(IUIService); uiService.registerToolbarButton({ id: my-batch-fill-btn, label: 批量填充, commandId: MyBatchFillCommand.id, });UI 扩展点的具体 API 会随版本变化但思路是一致的找到对应的 UI 服务注册组件绑定命令。实操心得写插件时尽量把逻辑和 UI 分开。逻辑放在命令 handler 里UI 只负责触发命令。这样同一套逻辑可以同时被工具栏、快捷键、右键菜单复用也方便写测试。我一开始把逻辑写在 UI 回调里后来想加个快捷键触发发现得把逻辑抽出来重写白费功夫。7. 性能调优与生产环境注意事项7.1 渲染性能的几个调节点Univer 的渲染层有几个可以调的参数。一是可视区域的行列缓冲默认会多渲染可视范围外的一两行滚动时更平滑但会增加绘制量。数据量特别大时可以调小缓冲。二是重绘的节流高频数据更新时可以合并到下一帧统一重绘避免每来一个更新就重绘一次。另外单元格的样式尽量用共享样式对象而不是每个单元格一个独立样式对象。样式对象多了之后比较和缓存的成本会上升。7.2 内存管理大数据量场景下内存是另一个瓶颈。Univer 的数据层用稀疏结构存储空单元格不占空间这是好的。但如果你在渲染回调里创建了大量临时对象垃圾回收压力会很大。渲染回调里尽量复用对象避免频繁分配。插件销毁时要记得清理事件监听和定时器否则页面切换后旧实例还在后台跑内存泄漏就是这么来的。7.3 生产环境构建生产构建时确保用 production 模式打包去掉开发期的警告和检查。如果用了动态 import配置好 chunk 分割策略把不常用的插件分到独立 chunk。CDN 部署时注意 Canvas 相关的资源路径字体文件如果加载失败表格里的文字会显示异常。还有一点Univer 的某些功能依赖 Web Worker 做后台计算。生产环境要确保 Worker 文件被正确打包和部署路径配置错了 Worker 加载失败公式计算会退化到主线程大数据量下会卡。8. 我对 Univer 这套方案的实际体会用 Univer 做项目这段时间最大的感受是它的架构设计确实经得起推敲。命令系统、插件化、Canvas 渲染这几个核心决策在需求变化时体现出了价值——加功能是加插件改行为是换命令处理器性能不够是调渲染参数各管各的不会牵一发动全身。当然它也不是没有门槛。插件架构意味着你得理解依赖注入和生命周期命令系统意味着你得放弃“直接改数据”的直觉Canvas 渲染意味着调试方式跟 DOM 完全不同。这些学习成本是实打实的但一旦跨过去后面做扩展会顺很多。如果让我给准备上手的人一个建议那就是先把官方的最小示例跑通然后照着它的模式写一个自己的小插件把命令注册、UI 扩展、数据修改这条链路走一遍。走通了这一遍后面再复杂的定制都是在这个骨架上加东西。别一上来就想着改源码先把扩展机制用熟大部分需求其实不需要动核心。最后分享一个排查问题的习惯遇到诡异现象时先打开 Univer 的 debug 日志看命令有没有派发、处理器有没有执行、数据有没有变更、渲染有没有触发。这四个环节定位下来问题基本就跑不掉了。比盲目翻源码高效得多。