
电子表格这个东西大家每天都在用但真要自己动手做一个绝大多数人第一反应是这活儿得一个团队干半年。我一开始也这么想直到接触到 Univer 这个开源项目才发现事情没那么简单——它把电子表格最核心的那套东西用一套插件化的架构重新拆了一遍让前端开发者能像搭积木一样把表格能力嵌进自己的产品里。这篇内容就是围绕 Univer 这个 SDK 展开的我会从它的架构设计、Canvas 渲染机制、插件体系、Node.js 环境搭建一直到实际集成时踩过的坑完整地聊一遍。不管你是刚听说 Univer 想试试水还是已经在项目里用它但被某些问题卡住了应该都能从里面找到点有用的东西。1. Univer 到底解决了什么问题1.1 从我要做个表格说起先聊一个很现实的场景。假设你在一家做 SaaS 的公司产品是一个项目管理工具客户提了个需求能不能在任务详情页里直接编辑一个表格支持公式、支持多 sheet、支持复制粘贴 Excel 的数据。你第一反应可能是去找个现成的表格组件库但翻一圈会发现大部分所谓的表格组件本质上就是个加强版的 HTML table排序筛选分页做得不错但一碰到公式计算、单元格依赖、跨 sheet 引用这些需求就歇菜了。那用 Excel 的在线版本嵌进去不行那是别人的产品你没法改、没法集成、没法做二次开发。自己从零写一个渲染层、数据层、公式引擎、协同层每一块都是深坑没个一年半载出不来能用的东西。Univer 就是在这个缝隙里长出来的。它是一个开源的电子表格 SDK注意关键词是 SDK 而不是应用。它提供的是一套完整的底层能力单元格模型、公式引擎、Canvas 渲染、插件系统、协同能力你拿过去之后可以按自己的需求组装成一个电子表格产品也可以只取其中一部分能力嵌到现有系统里。这个定位很关键它决定了 Univer 不是一个开箱即用的 Excel 替代品而是一个让你能造出 Excel 替代品的工具箱。1.2 和传统表格方案的差异在哪我用过不少表格方案这里做个横向对比会更清楚。传统的 HTML table 方案比如各种 UI 库里的 Table 组件它们的核心是 DOM 渲染每个单元格就是一个 DOM 节点。这种方案在数据量小的时候没问题但一旦行数上千DOM 节点数量爆炸滚动就开始卡。而且 DOM 方案做公式计算、单元格合并、冻结行列这些功能实现起来非常别扭因为 DOM 本身不是为这种场景设计的。Univer 走的是 Canvas 渲染路线。整个表格区域就是一块 Canvas所有的单元格、文字、边框、选中态都画在这块画布上。这样做的好处是渲染性能和数据量基本解耦你有一万行还是一百万行Canvas 只画可视区域内的那部分滚动的时候重绘就行。这个思路和很多专业的表格产品是一致的也是 Univer 能在性能上站住脚的根本原因。另一个差异是架构层面。Univer 是插件化的核心只负责最基础的数据模型和渲染调度公式、协同、导入导出、条件格式这些能力全部以插件形式存在。这意味着你可以按需加载不需要公式就不装公式插件不需要协同就不装协同插件打包体积可控。同时你也可以自己写插件扩展能力这在传统表格组件里几乎是不可能做到的。1.3 谁适合用 Univer说句实在话Univer 不是给所有人准备的。如果你只是想要一个能展示数据的表格用现成的 UI 组件库就够了没必要上 Univer学习成本和集成成本都不低。但如果你符合下面这几种情况Univer 就值得认真考虑你的产品需要嵌入一个真正意义上的电子表格要有公式、多 sheet、单元格样式这些能力你需要对表格做深度定制比如自定义单元格类型、自定义右键菜单、自定义工具栏你有协同编辑的需求多个人同时编辑同一份表格你想把表格能力和自己的业务数据打通比如表格里的数据直接来自你的后端 API反过来说如果你只是要一个数据展示列表或者你的团队完全没有 Canvas 和前端工程化的经验那还是先用成熟组件库把需求满足了再说。2. 插件架构Univer 的骨架是怎么搭的2.1 核心与插件的职责边界Univer 的架构里最核心的一个设计决策就是把什么是核心和什么是插件划得很清楚。核心Core负责的事情其实很少维护一份文档数据模型、管理插件的注册和生命周期、提供事件总线、调度渲染。除此之外的所有能力包括公式计算、单元格编辑、复制粘贴、导入导出全部是插件。这个划分的好处在于核心足够稳定插件可以自由生长。你升级 Univer 版本的时候核心 API 基本不会变插件即使有 breaking change 也影响可控。而且因为插件是独立的你可以只装自己需要的比如一个只读的报表展示场景你完全可以不装编辑相关的插件体积能小一大截。我刚开始用的时候不太理解为什么要这么设计觉得把功能都放核心不是更简单吗。后来自己写了一个自定义插件才明白插件化真正的价值在于可替换。比如公式引擎Univer 默认提供了一套但如果你公司有自己的公式计算服务你完全可以写一个插件把默认的替换掉核心代码一行不用改。这种灵活性在传统单体架构里是做不到的。2.2 一个插件从注册到生效的完整链路理解插件机制最好的方式是跟着一个插件的生命周期走一遍。Univer 的插件大致经历这么几个阶段注册阶段通过Univer实例的registerPlugin方法把插件类注册进去这时候只是告诉 Univer 有这么个插件存在插件还没被实例化实例化阶段Univer 创建实例时会遍历所有注册的插件逐个 new 出来这时候插件的构造函数执行但还不能访问其他插件依赖注入阶段Univer 会把插件之间声明的依赖关系解析好通过DependentOn这类装饰器声明依赖的插件在这个阶段能拿到启动阶段所有插件的onStarting钩子被调用这时候插件可以开始初始化自己的状态、注册命令、监听事件就绪阶段onReady钩子被调用此时整个 Univer 实例已经可以正常工作了这个链路里最容易踩坑的是依赖注入。如果你在插件的构造函数里就去访问另一个插件很可能会拿到 undefined因为那时候依赖还没注入。正确的做法是把跨插件的访问放到onStarting或之后。我一开始就犯过这个错构造函数里直接this._otherPlugin.doSomething()结果报错排查了半天才反应过来是生命周期的问题。2.3 插件之间怎么通信插件之间不能直接互相调用方法那样耦合太紧。Univer 提供了几种通信方式各有适用场景第一种是命令Command。这是最推荐的方式一个插件注册一个命令其他插件通过命令 ID 来执行。命令的好处是有统一的拦截、撤销重做机制而且调用方不需要知道具体是哪个插件实现的。比如设置单元格值这个操作就是一个命令任何插件都可以触发它。第二种是事件Event。插件可以订阅其他插件发出的事件做响应式处理。事件适合那种发生了某件事我需要知道的场景比如选区变化了、文档保存了。事件是单向的发出方不关心谁在听。第三种是共享服务Service。如果两个插件关系比较紧密可以通过服务的方式暴露接口。这种方式耦合度最高一般只在确实需要紧密协作的插件之间使用。实际开发中我大部分时候用的是命令和事件。命令负责做事事件负责通知这个分工很清晰。只有在写一些配套插件的时候才会用到服务比如一个插件的 UI 部分和逻辑部分之间。3. Canvas 渲染为什么不用 DOM3.1 DOM 渲染表格的天花板在哪要理解 Univer 为什么选 Canvas得先搞清楚 DOM 渲染表格到底卡在哪。DOM 方案的本质是一个单元格一个节点假设你有 1000 行 20 列那就是 20000 个 DOM 节点。浏览器渲染这么多节点本身就要花不少时间更别说每次数据变化都要走一遍 diff 和重排重绘。有人会说那我做虚拟滚动不就行了只渲染可视区域的节点。虚拟滚动确实能解决节点数量的问题但它引入了新的复杂度滚动时的节点复用、动态高度计算、选中态跨区域处理每一个都是坑。而且即使虚拟滚动做得再好单元格的样式计算、文本测量这些操作还是绕不开 DOM 的那套开销。还有一个更根本的问题DOM 方案很难做精细的渲染控制。比如你想给某个单元格画一个特殊的边框或者做一个自定义的填充效果用 DOM 就得靠各种 hack用 Canvas 就是几行绘图代码的事。3.2 Canvas 渲染的核心思路Univer 的 Canvas 渲染核心思路可以概括成分层 按需绘制。整个表格区域不是一块 Canvas而是分了好几层每一层负责不同的内容图层负责内容更新频率背景层单元格背景色、网格线低内容层单元格文本、公式结果中选区层选中高亮、边框高交互层光标、拖拽指示最高分层的意义在于不同内容的更新频率不一样。你滚动表格的时候背景和内容基本不变只有选区层需要重绘你拖动选区的时候只有选区层和交互层需要更新。如果所有内容都画在一层上任何一点变化都要全量重绘性能就下来了。按需绘制则是说Canvas 只画可视区域内的单元格。Univer 会维护一个当前视口的概念每次渲染前先算出哪些单元格落在视口内只对这些单元格执行绘制。视口外的单元格完全不处理这就是它能扛住大数据量的关键。3.3 文本测量与换行的处理Canvas 渲染里有个绕不开的难题文本测量。DOM 里你写个文字浏览器自动帮你算宽高、自动换行但 Canvas 里这些都得自己来。measureText这个 API 能测出文本宽度但它不考虑换行你得自己实现断行逻辑。Univer 在这块做了不少工作。它会根据单元格宽度和字体把文本切成一行行每行的高度由字体决定。如果单元格开启了自动换行还要处理中英文混排的断行规则——中文可以在任意字符间断英文得在单词间断。这些细节听起来琐碎但直接决定了表格看起来专不专业。我自己在做自定义单元格类型的时候就踩过文本测量的坑。当时没考虑字体加载的时机页面刚打开时字体还没加载完测出来的宽度是错的导致文本被截断。后来改成监听字体加载完成事件加载完再触发一次重绘问题才解决。这个经验告诉我Canvas 渲染里所有依赖字体度量的逻辑都要考虑字体异步加载的情况。3.4 高分屏下的清晰度问题Canvas 在 Retina 屏上如果不做处理画出来的东西会糊。原因是 Canvas 的物理像素和 CSS 像素不是 1:1 的关系在 2 倍屏上一个 CSS 像素对应 2 个物理像素。如果你按 CSS 尺寸设置 Canvas 的宽高浏览器会把画布拉伸导致模糊。正确的做法是把 Canvas 的实际宽高设置成 CSS 尺寸乘以devicePixelRatio然后用ctx.scale(dpr, dpr)把绘图坐标系缩放回来。这样画出来的内容在物理像素层面是清晰的。Univer 内部已经处理了这件事但如果你自己写自定义渲染逻辑一定要记得这个细节否则在高分屏上你的自定义内容会比周围的单元格糊一截非常明显。4. 公式引擎表格的大脑4.1 公式计算为什么比想象中复杂很多人觉得公式计算不就是解析个表达式然后算一下吗能有多难。真做过就知道难点根本不在计算本身而在依赖关系。一个单元格的值可能依赖另外十个单元格那十个又各自依赖别的形成一张有向图。当某个单元格变化时你得知道哪些单元格需要重算而且不能重复算、不能漏算、不能陷入循环依赖。Univer 的公式引擎维护了一张依赖图每个公式单元格记录它依赖了哪些单元格每个被依赖的单元格记录它被哪些公式引用。当数据变化时从变化的单元格出发沿着依赖图往下游找找到所有需要重算的公式按拓扑顺序依次计算。这个过程中还要检测循环引用比如 A1 引用 B1B1 又引用 A1这种情况要报错而不是死循环。4.2 公式的解析与执行公式的解析分两步先做词法分析把公式字符串切成一个个 token比如SUM(A1:A10)会被切成、SUM、(、A1:A10、)然后做语法分析把这些 token 组织成一棵抽象语法树AST。有了 AST 之后执行就是遍历这棵树遇到函数调用就查函数表遇到单元格引用就去取值。Univer 内置了一批常用函数SUM、AVERAGE、IF、VLOOKUP 这些都有。如果你需要自定义函数可以注册进去。我做过一个项目需要对接公司内部的汇率服务就写了个自定义函数在函数体里调后端 API 拿汇率用起来和内置函数没区别。这里有个性能上的注意点自定义函数如果是异步的比如要请求网络处理起来会比较麻烦因为公式计算默认是同步的。Univer 对异步函数有支持但需要按它的规范来写不能随便返回 Promise。我建议除非必要自定义函数尽量做成同步的把数据提前加载好。4.3 公式重算的性能优化当表格里公式很多的时候重算性能是个大问题。假设你有几万个公式单元格每次改一个值都全量重算那肯定卡死。Univer 的做法是增量重算只算受影响的那些。但即使增量重算如果依赖链很长也可能有性能问题。我遇到过一个场景用户在一个汇总表里改了个数字结果触发了上千个公式的连锁重算界面卡了将近一秒。后来分析发现是因为依赖图里有几个超级节点被大量公式引用。针对这种情况我的做法是把一些不常变的中间结果缓存起来减少重算的深度。还有一个技巧是延迟计算。如果短时间内有大量数据变化不要每变一次就算一次而是攒一批等空闲的时候统一算。Univer 内部有一些批处理的机制但如果你在业务层做批量数据导入最好也自己做一下节流避免频繁触发重算。5. 把 Univer 跑起来环境搭建与最小示例5.1 Node.js 环境的准备Univer 是一个前端 SDK但它的开发环境依赖 Node.js。不管你用 npm、yarn 还是 pnpm都得先有个 Node.js。这里说几个实际会遇到的点。版本选择上建议用 LTS 版本。Node.js 的版本迭代很快新版本有时候会有兼容性问题LTS 相对稳定。安装方式看你的系统Windows 和 macOS 直接去官网下安装包就行Linux 服务器上可以用包管理器装。装完之后用node -v和npm -v验证一下能输出版本号就说明装好了。如果你之前装过 Node.js 但不确定版本或者机器上有多个版本可以用 nvm 这类版本管理工具来切换。我自己机器上就同时装了 18 和 20 两个版本不同项目用不同的切换起来很方便。提示如果你在安装依赖时遇到网络相关的报错可以配置一下 npm 的镜像源国内访问官方源有时候会比较慢。5.2 创建项目并安装依赖环境准备好之后建个项目。用 Vite 或者 Create React App 都行Univer 本身不挑框架React、Vue、甚至原生 JS 都能用。我这里以 Vite React 为例因为 Vite 启动快开发体验好。npm create vitelatest univer-demo -- --template react cd univer-demo npm install然后安装 Univer 的核心包和预设包npm install univerjs/core univerjs/presets univerjs/preset-sheets-core这里解释一下这几个包。univerjs/core是核心必须装。univerjs/presets是一系列预设的集合它把常用的插件打包好了省得你一个个装。univerjs/preset-sheets-core是电子表格的核心预设包含基础的表格能力。如果你需要公式、协同这些还要装对应的预设包。5.3 一个能跑起来的最小示例装完依赖写代码。下面是一个最小的可运行示例import { Univer, LocaleType } from univerjs/core; import { UniverSheetsCorePreset } from univerjs/preset-sheets-core; import univerjs/preset-sheets-core/lib/index.css; const univer new Univer({ locale: LocaleType.ZH_CN, }); univer.registerPlugin(UniverSheetsCorePreset({ container: app, })); // 初始化一份空的工作簿数据 univer.createUnit(UniverInstanceType.UNIVER_SHEET, {});这段代码做了几件事创建 Univer 实例、注册表格预设、创建一份空工作簿。跑起来之后页面上就会出现一个可编辑的表格能输入数据、能选中、能复制粘贴。虽然功能还很简单但骨架已经搭起来了。这里有个容易忽略的点CSS 文件的引入。Univer 的很多 UI 组件依赖样式如果你忘了引入 CSS表格能显示但样式会乱工具栏、菜单这些都会错位。我见过好几个新手卡在这里以为是代码问题其实是漏了 CSS。5.4 加载一份真实数据空表格跑通之后下一步是加载真实数据。Univer 的工作簿数据是一个 JSON 结构包含 sheet 列表、每个 sheet 的单元格数据、样式、合并信息等。你可以从后端拿到这个 JSON直接传给createUnit。const workbookData { id: workbook-1, sheetOrder: [sheet-1], sheets: { sheet-1: { id: sheet-1, name: Sheet1, cellData: { 0: { 0: { v: 姓名 }, 1: { v: 年龄 }, }, 1: { 0: { v: 张三 }, 1: { v: 28 }, }, }, }, }, }; univer.createUnit(UniverInstanceType.UNIVER_SHEET, workbookData);这个数据结构看起来有点绕cellData是个二维结构第一层 key 是行号第二层 key 是列号值是一个单元格对象v字段是值。刚开始用的时候我老是搞混行列的顺序后来记住先行后列就不会错了。6. 集成过程中真正会卡住你的那些坑6.1 容器尺寸与响应式Univer 渲染到指定的容器里容器的尺寸决定了表格的显示区域。这里最常见的坑是容器高度为 0。如果你把容器放在一个没有设置高度的 div 里或者用了 flex 布局但没给 flex 项设置高度容器高度就是 0表格渲染出来什么都看不见。解决办法是确保容器有明确的高度。可以用固定高度也可以用flex: 1配合父容器的display: flex; flex-direction: column。我一般会在容器上加个min-height防止极端情况下塌陷。响应式方面窗口大小变化时 Univer 需要重新计算布局。它内部有监听 resize 的机制但如果你用的是某些特殊的布局方式比如容器尺寸由 JS 动态计算可能需要手动触发一下重绘。6.2 数据同步的时机问题Univer 的数据是双向的用户编辑会改变内部数据你从外部设置数据也会改变内部状态。这两者如果处理不好会出现数据不一致。我遇到过一个典型问题用户在表格里编辑了内容我监听到变化后把数据同步到后端后端返回成功后又把数据回写了一遍结果触发了二次渲染光标位置丢了。后来改成只在必要时回写并且回写前先比对一下数据是否真的变了问题才解决。还有一个是初始化时机的问题。如果你在 Univer 实例还没完全就绪的时候就往里塞数据可能会失败。稳妥的做法是监听就绪事件在回调里再做数据加载。6.3 自定义插件时的生命周期陷阱前面提过插件生命周期这里再展开说几个具体的坑。第一个是依赖注入的时机。前面说过构造函数里拿不到其他插件这个坑很常见。正确的做法是在onStarting里访问依赖。第二个是命令注册的时机。命令要在插件启动时注册如果注册晚了其他插件触发命令时会找不到。一般放在onStarting里注册。第三个是资源释放。插件如果创建了定时器、事件监听、Canvas 资源在插件销毁时要清理干净否则会造成内存泄漏。Univer 提供了onDispose钩子记得在里面做清理。6.4 打包体积的优化Univer 功能全但全量引入的话打包体积不小。如果你的场景只需要基础表格能力可以通过按需引入来减小体积。univerjs/presets虽然方便但它把很多东西都打包进去了如果你确定不需要某些能力可以直接引入具体的插件包而不是用预设。另外Univer 的一些依赖比如公式引擎里的函数库体积也不小如果不用公式可以不引入公式相关的包。我做过一个只读报表的项目把编辑、公式、协同相关的包全去掉最终打包体积比全量引入小了一半多。7. 协同编辑Univer 的进阶玩法7.1 协同的底层模型Univer 的协同能力建立在 OTOperational Transformation或者 CRDT 这类算法之上。简单说就是多个人的操作要能合并成一致的结果。比如 A 在 A1 输入了helloB 同时在 B1 输入了world这两个操作要能正确地合并最终两个人的表格内容一致。Univer 把协同做成了插件核心的协同逻辑在univerjs/rpc和相关的协同插件里。它需要一个后端服务来中转操作Univer 官方提供了一些服务端的实现思路你也可以自己实现。7.2 接入协同需要准备什么接入协同不是装个插件就完事你需要一个支持 WebSocket 的服务端用来实时转发操作一套操作日志的存储用来做持久化和历史回溯用户身份和权限的管理控制谁能编辑谁只能看这几块里服务端的实现是工作量最大的。Univer 的协同协议有一定的复杂度如果你自己从零实现需要仔细读它的文档和源码。我的建议是先用官方提供的示例服务端跑通理解协议之后再考虑自己实现。7.3 协同场景下的常见问题协同场景下光标和选区的同步是个体验关键点。用户能看到别人在哪儿编辑这个体验会好很多。Univer 支持选区同步但需要你在服务端把选区变化也作为操作转发。另一个问题是冲突处理。虽然 OT/CRDT 算法能保证最终一致但用户看到的过程可能不符合预期。比如两个人同时改一个单元格最终结果可能是后提交的覆盖先提交的用户会觉得我改的东西怎么没了。这种场景需要在产品层面做提示比如显示某某正在编辑此单元格。8. 一些实战中的经验与建议8.1 什么时候该用 Univer什么时候不该用了这么久我的判断标准是这样的如果你的核心需求是展示数据用普通表格组件如果核心需求是编辑数据且需要电子表格级别的能力才考虑 Univer。中间地带的需求比如只是要个能编辑的表格但不需要公式可以先用轻量方案等需求真的涨上来了再迁移。Univer 的学习曲线不算平缓尤其是你要做深度定制的时候得理解它的插件机制、命令系统、渲染流程。如果团队里没有人愿意花时间啃这些用起来会很痛苦。8.2 性能调优的几个方向如果表格数据量很大性能优化可以从这几个方向入手减少不必要的重绘比如选区变化时不要触发内容层重绘公式计算做增量避免全量重算大数据量导入时做分批处理不要一次性塞进去合理使用虚拟滚动Univer 内部有相关机制但配置要调对8.3 社区与文档的使用Univer 的文档在持续完善中但有些细节还是得看源码。我的习惯是遇到问题先搜 issue很多坑别人已经踩过了。如果搜不到就去读相关插件的源码Univer 的代码结构还算清晰读起来不算太费劲。另外Univer 的版本迭代比较快升级时要注意看 changelog有些 API 会有 breaking change。我一般会先在测试环境升级验证没问题再上生产。8.4 一个真实的集成案例最后分享一个我做过的案例。有个客户需要一个在线的财务报表工具要求支持公式、多 sheet、数据从后端 API 加载、编辑后能保存。我用 Univer 搭了个原型核心工作包括用预设包快速搭起基础表格、写了个自定义插件对接后端 API、用命令系统拦截编辑操作做数据校验、用事件系统监听变化做自动保存。整个原型大概花了一周时间如果从零写一个月都未必能到这个程度。当然后面做产品化的时候又花了不少时间打磨细节比如错误提示、加载状态、权限控制这些。但至少验证了 Univer 在这个场景下是可行的而且开发效率确实高。这个案例给我的体会是Univer 最大的价值不是它现成的功能而是它提供的那套扩展机制。你可以在它的基础上快速搭出符合自己业务的东西而不用从最底层开始造轮子。对于需要深度定制表格能力的团队来说这个价值是实打实的。