前阵子公司要在一个内部数据产品里嵌入一套可编辑的表格能力,需求听起来很简单——用户能像操作 Excel 一样改单元格、公式能算、数据能回存,但真正调研起来才发现,网页里想给人一套“不违和的表格”远比想象中复杂,也就是从这个时候开始,Univer 这个名字逐渐占据了我所有的调研笔记。Univer 是一个基于 TypeScript 的开源办公文档引擎,简单说,它可以把表格、文档、幻灯片能力直接嵌入你自己的前端应用,而不是像传统做法那样套一个 iframe 或买一套在线 Office 服务。它解决了我在上面提到的那个最核心的问题:表格不只是个“展示组件”,而是一整块可以编程、可以深度嵌入业务的画布。这篇文章不只是介绍 Univer 是什么,更多的是我把它从一个空页面跑通、再逐步改造成符合业务需求的前端组件时,积累下来的集成路径、架构理解和避坑经验,适合正准备调研或已经决定在项目里接入 Univer 的开发者作为参考。
1. 我为什么放着现成的在线表格不用,选了 Univer
1.1 网页里做表格能力的几种常见姿势,以及各自的问题
在细聊 Univer 之前,值得先摊开我当时的选型思路。团队里有一种提议是把公开的在线表格产品直接嵌进来,一个 iframe 完事,开发成本几乎为零。这个方案在快速出 Demo 的时候确实很有吸引力,但进入到产品级落地阶段就会开始处处难受:先不说样式和字体能不能和主站保持统一,光是权限交互就很麻烦——用户在 iframe 里打开的是云文档站点的界面,我们控制不了右键菜单、工具栏、键盘快捷键,也拿不到编辑过程中的实时事件。更尴尬的是数据回写,让用户在主站填完一张表格,再引导去另一个页面复制粘贴,这体验放在任何正式产品里都说不出口。
另一种常见做法是后端渲染表格快照,把数据渲染成图片或者 HTML 发给前端。这个方案适合只读报表,但一旦需要编辑,就得做选区、光标、输入法、复制粘贴这些底层交互,本质上还是走回自研的路子。自研表格的话,公式解析、条件格式、剪贴板、拖拽填充、撤销栈、协同冲突处理……每一项都是需要按年计的工作量,我衡量了一下团队的人力,根本不可能在业务周期内交付。
所以当我把需求收敛成“可编辑”、“可编程”、“可自绘”、“可协同”这四个关键词去重新搜一轮开源项目时,Univer 就跳出来了。它和当时其他开源表格项目最大的不同是,它没有被封装成一个“组件”,而是提供了一整套可插拔的架构,UI 层和逻辑层切得很干净,也就是说,业务方可以拿它当作底座往上做自己的界面和交互,而不是被它的界面绑死。
1.2 Univer 的真实身份:不只是表格,而是一套带渲染引擎的文档内核
很多第一次接触 Univer 的人,会习惯性地把它类比成“一个开源的在线 Excel”,这个类比在功能上没错,但它会严重低估 Univer 的可扩展边界。Univer 的底座是一个自研的 Canvas 渲染引擎和一个独立的前端状态模型,表格只是基于这套底座实现的其中一个文档类型,后续还有文档、幻灯片这些产品形态,同样是跑在同一套核心之上的。
这一点对技术选型非常重要。因为在 Univer 里,一个单元格“长什么样”并不像传统表格项目那样被写死,而是由渲染器决定的。你可以注册自己的自定义渲染器,替换掉默认的文本绘制逻辑,于是单元格里能画进度条、画标签、画头像、画热力状态。这对我们做数据产品来说是决定性的,因为我们有很大一部分需求不是“让用户填数字”,而是“让用户在一个表格结构里完成业务操作”,这种操作需要表格本身变成业务界面的一部分。
另外,Univer 的前端状态模型是围绕命令系统设计的,几乎每一次用户操作都会变成一条可追溯的命令,这也为协同编辑和 undo/redo 提供了非常自然的实现基础。从实际开发体验来看,Univer 更像一个“前端文档操作系统”,表格只是它很擅长的一个应用层表现。
2. 最小集成:从安装到页面出现一个可编辑表格
2.1 第一步先锁版本,别追最新
Univer 的版本迭代速度非常快,从早期的 0.1.x 到后面的 0.2.x、再到更新的版本,模块化和 API 都发生了巨大的变化,甚至同一个功能在不同版本里的引入方式和命名都不一样。如果直接照着最新文档写,写出来的代码很可能过不了编译;如果照着网上搜到的旧博客抄,那个示例代码可能一开始就跑不起来。
我的做法是,在 package.json 里直接锁死一个能跑通的版本,不随便用latest。对于新接入的团队,我更建议先不要去区分“最新版”和“稳定版”,而是直接把当时 npm 上版本号和自己项目里锁定的版本号记录下来,一切以实际运行的代码为准。Univer 官方 npm 包现在建议直接用@univerjs/preset-sheets来起步,这个预设包把表格需要的基础插件、渲染引擎、样式都组装好了,对新手来说比手动拼插件要友善得多。
2.2 一个能跑的最小页面示例
我用 Vite + React 作为宿主工程,安装过程没什么特别的,核心就两步。
npm install @univerjs/preset-sheets @univerjs/core安装完成之后,样式文件记得引入,这一步很多人会漏掉,漏了之后页面上会出现一个没有样式的白板,还以为是初始化失败。
import { Univer, UniverInstanceType } from '@univerjs/preset-sheets'; import { LocaleType } from '@univerjs/core'; import '@univerjs/preset-sheets/lib/index.css'; const univer = new Univer({ locale: LocaleType.ENGLISH, }); const workbook = univer.createUnit(UniverInstanceType.UNIVER_SHEET, { id: 'my-workbook', sheets: { 'sheet-1': { id: 'sheet-1', name: 'Sheet1', rowCount: 200, colCount: 30, cellData: { 0: { 0: { v: 'Hello' }, 1: { v: 'from Univer' }, 2: { v: 100 }, }, 1: { 0: { v: 'Total' }, 2: { v: '=SUM(C1:C1)' }, }, }, }, }, });等这段代码跑起来,页面上就应该出现一个可编辑、可选中、可拖拽的表格了。这里需要注意createUnit的写法,在我项目里锁定的这个版本里它是正确的,如果你的版本比这个新,具体方法名只会更稳,不会退步到什么不可用的程度。Univer 的文档更新速度很快,遇到编译问题优先查当前安装版本的 TypeScript 类型定义,那是最真实的 API 参考。
2.3 数据从哪进、怎么出
表格对大多数业务来说不是一个独立存储,数据最终要从服务端来、再回到服务端去。Univer 里的工作簿基础结构是一个嵌套的 JSON:最外层是workbook,里面有sheets工作表数组,工作表里是cellData三维结构——行号、列号、单元格对象。理解了这个结构,数据加载就很简单,服务端返回什么结构,你就往createUnit的配置里塞什么结构。
数据导出方向,我一开始习惯去找“导出 Excel”按钮,但实际上在数据产品里更需要的是“监听用户改了什么单元格”这个能力。Univer 的编辑器操作都会经过命令层,你可以订阅单元格内容变化相关的事件,然后自行决定是整体回传,还是只把变化的部分打包成一条增量更新。整体回传最简单,但数据量大时不合适;增量更新需要你在命令层自己比对,好在命令本身就带了足够的信息,做起来不算难。
有一点需要提醒:内部表格数据格式和 Excel 文件格式不是一回事。Univer 核心负责的是数据状态,Excel 的.xlsx导入导出是另一个扩展包做的事,如果你只接入了核心包,就别指望能直接打开 xlsx 文件,需要的时候该引扩展包就引。
3. 从 Demo 到业务,把 Univer 改装成你的表格
3.1 自定义单元格渲染器:在单元格里画业务视图
Demo 跑通之后,第一个让我觉得选型没选错的功能,就是自定义单元格渲染器。给单元格传文本、数字是基础能力,但真实业务里单元格需要呈现出更多样的状态,比如一个“审批状态”列,我们希望状态值不只是一段文字,而是在格子里显示一个圆点、一段带颜色的标签,甚至一个等待图标。Univer 允许注册自定义渲染器,渲染器在绘制单元格时接管 Canvas 上的绘制逻辑,你可以拿到单元格的坐标、尺寸、数据值,还可以拿到当前是否处于选中、悬停等状态。
实际写自定义渲染器时,我对两个点印象很深:一是绘制时不能只考虑单元格本身,还要考虑旁边被选中时的蓝色边框会被覆盖掉的问题,需要自己处理选中态的视觉反馈;二是单元格可能被拉伸得特别宽,渲染器绘制内容时要自己做好对齐规则,是靠左、居中还是靠右,默认 API 不会自动帮你处理。从代码接入上看,思路更像是“我给某个单元格区域注入自定义渲染指令”,渲染器在绘制阶段被调用,而不是替换整个表格的渲染方式。
这种能力让产品设计的自由度大了很多。后来我们在表格里做了状态标签、迷你趋势图、甚至一个简单的甘特条,全部是同一个底层机制换着花样画,这也是我认为 Univer 最值得花时间研究的功能点之一。
3.2 裁剪工具栏和右键菜单,只保留业务需要的入口
Univer 默认的工具栏很齐全,从撤销重做到合并单元格、筛选、排序,一应俱全。对于内部工具,默认菜单基本都是够用的,但一旦要把表格嵌入客户产品,很多默认入口就不合适了:比如“导出 xlsx”可能想要收费权限控制,工作表的新增删除可能要按用户角色区分,有些高级单元格操作业务团队根本不想让用户碰到。
Univer 的 UI 插件在设计时考虑了按需配置的问题,工具栏按钮、右键菜单项都是可以通过配置和命令控制裁剪的。我的实际做法是先看我需要哪些命令,再从插件配置里把不需要的菜单项关闭,而不是反过来先全量展示再想办法拦截。拦截的思路不如直接关闭干净,因为右键菜单和快捷键的触发入口往往不止一处,漏了一个就会在验收时被揪出来。
裁剪的时候还要小心一件事:命令和菜单的对应关系不是一对一。一个命令可能同时在工具栏、右键菜单、快捷键里注册了入口,如果你只想保留命令能力而移除按钮,那是在 UI 层去重;如果你想整个能力都不要,那要连命令一起停掉,不然用户按快捷键仍然能触发。
3.3 多实例与状态隔离
业务里经常出现一个页面同时存在多个表格的场景,比如一个数据面板里并排放两个工作表。Univer 支持在同一个页面上创建多个 Univer 实例,也可以在一个引擎下创建多个工作簿。这两种方式我其实都试过,体验差别很大。
如果多个表格之间完全不相关、生命周期也不一致,用一个独立 Univer 实例管理更清爽,隔离更彻底;如果你希望多个表格共享一套插件和主题,希望它们之间的资源能复用,那就放在同一个引擎下,但这时候要注意自定义渲染器的注册表是所有实例共享的,命令处理器里如果依赖全局状态,多工作簿之间可能会互相影响。最稳妥的做法是保证你自己的扩展代码不写全局可变变量,所有状态都挂在单元格数据或者工作簿配置上,这样无论单实例多工作簿还是多实例,都不会出现离奇的脏数据问题。
4. 协同编辑接入:命令流同步的核心设计
4.1 Univer 协同的本质是命令流,不是快照覆盖
把 Univer 接入协同之前,我先想明白了一个问题:协同到底同步什么?最朴素的做法是定时把整个工作簿 JSON 上传、下发、整体覆盖,这个方案在数据量小、人数少的时候也勉强能用,但一旦编辑频繁就会出问题:双方同时改同一行,后到的覆盖先到的,用户的输入会莫名其妙被吞掉。
Univer 的底层事件和操作模型天然更适合“命令流转发”的思路。用户改单元格、加行、删列、调整行高,这些操作都会生成可序列化的命令结构,协同服务端需要做的,就是把某个用户产生的命令顺序地广播给房间内的其他人,其他客户端拿到命令后在本地重放。这个思路和在线文档领域常说的 OT 或 CRDT 在理念上是相通的:同步的是操作,不是结果。
想强调一点:Univer 核心并没有把协同服务端直接打包给你,它提供的是客户端的命令流基础设施,以及基于这套命令机制做协同扩展的基础文档。真正把命令转发出去、做顺序保证、做持久化,都需要业务自己实现。好在命令结构本身设计得很完整,不需要我们再去发明一套协议。
4.2 一个最小协同服务端的落地思路
如果你项目里暂时没有接入成熟的在线文档协同基础设施,从零搭一个最小可用服务端其实没有想象中那么复杂。我当时的方案是:Node.js + WebSocket,房间由业务参数决定,服务端不执行具体表格逻辑,只负责转发和排序。一个非常粗的模型是:
import { WebSocketServer } from 'ws'; const wss = new WebSocketServer({ port: 8080 }); const rooms = new Map(); wss.on('connection', (ws, req) => { const roomId = new URL(req.url, 'http://localhost').searchParams.get('roomId'); if (!rooms.has(roomId)) rooms.set(roomId, new Set()); rooms.get(roomId).add(ws); ws.on('message', (data) => { const message = JSON.parse(data.toString()); rooms.get(roomId).forEach((client) => { if (client !== ws && client.readyState === 1) { client.send(JSON.stringify(message)); } }); }); ws.on('close', () => rooms.get(roomId)?.delete(ws)); });这段代码当然离生产还差很远,但它能说明协同服务端的最小边界:不解析业务命令、不执行计算,只做一个有顺序的广播管道。真正到生产环境要考虑的点在于:命令要有服务端生成的递增序号或时间戳,客户端不能直接信任自己收到命令的顺序;断线重连后,要能从服务端拉取最近一段时间的命令历史重新补齐状态。
4.3 协同落地时容易踩的坑
第一个坑是只管广播、不保证顺序。如果两个客户端同时提交命令,服务端不做排序就直接转发,各端收到的顺序可能不一致,最后整个表格状态就分裂了。稳妥的办法是服务端统一编号,客户端按编号应用命令,不跳号、不重复。
第二个坑是把所有东西都广播。滚动位置、选中区域、临时悬停状态这类 UI 层状态不应该被同步,如果走协同通道,不仅浪费流量,还可能引发远端用户的界面跳动,体验很差。
第三个坑是协同会话和权限要联动。不是所有房间成员都有同样的写入权限,表格里某些工作表可能只有特定角色可以编辑。协同命令应用前要在服务端做权限校验,不能在客户端只过滤菜单就算完,否则绕过 UI 直接发命令就能越权修改表格内容。
这些坑我们每个都踩过,尤其顺序问题,前期用统一编号解决之后,基本上就稳定了。如果团队没有专门做协同的同事,我建议先用只允许一个编辑者、其他人只读的“伪协同”上线,后面再逐步放开。
5. 性能与资源占用:几个实战教训
5.1 数据规模要提前想清楚
Univer 的渲染机制和 DOM 表格不同,它是在 Canvas 上绘制,默认不会为每个单元格创建一个 DOM 节点,这一点让它在处理较大数据量的时候有很大优势。但这个优势也不是没有边界,一次性往cellData里塞几十万行数据,即使画出来了,公式重算、序列化、命令回滚的成本也会快速上升。
我建议在项目设计阶段就把数据规模分成几个档位:表格真正展示的数据量、用户一次操作可能触碰的数据量、将来可能承载的上限。对大多数产品后台来说,单表可视区域内的数据量往往只有几千行,超过这个量,应该走“虚拟加载/分页”的路子,而不是指望前端一次渲染百万行。Univer 的单表模型能撑住非常大的引用范围,但工程上合理的做法还是按需填充分片数据。
下表是我在项目里大致采用的分级建议,仅供参考:
| 场景 | 数据规模 | 工程策略 |
|---|---|---|
| 轻量配置页 | 100 行以内 | 直接全量填充 |
| 普通数据面板 | 几千到几万行 | 首屏可见区填充,滚动加载 |
| 复杂报表/日志表 | 十万行以上 | 分页/按条件过滤,配合服务端聚合 |
| 含大量公式 | 同时活动单元格多 | 缩小公式依赖范围,worker 化计算 |
5.2 公式引擎:worker 化之后的问题
Univer 的公式计算可以运行在 worker 线程里,这是我很欣赏的设计。把公式计算从 UI 主线程挪走之后,大量单元格同时重算时界面不再卡顿,滚动、选中都很流畅。但 worker 化也不是白来的,它引入了一个新的复杂度:单元格内容变化后,公式结果什么时候回到主线程并刷新界面?如果公式计算是异步的,那么你监听“单元格变化”事件的时机就不能简单理解为“这个格子已经算完了”。
实际开发中我遇到过界面先显示了公式文本、过一会儿才刷出结果值的情况。排查下来不是数据错误,而是公式引擎在 worker 里跑完异步回传 UI,中间有一段时间状态不一致。如果你要做实时联动类的产品,比如公式结果驱动某个按钮的可用状态,就一定要监听“公式计算完成”这类更下游的事件,而不是只监听数据变更就判定一切就绪。
5.3 内存泄漏排查的常规路线
前端表格最容易在内存上翻车的地方,不是表格本身渲染不动,而是页面反复进入退出后内存悄悄涨。Univer 的多实例和插件注册机制决定了它不像普通 React 组件那样卸载即清理,自定义渲染器、事件订阅、命令注册都需要手动释放。
我的排查路线比较固定:先在 Chrome Performance 里录制进入退出页面的内存快照,看是否存在整体上涨;然后逐个模块做二进制排除,先不注册自定义渲染器跑一轮,再全量跑一轮,基本能定位到是哪部分没清理。常见的释放动作包括:切换路由或销毁组件时调用对应 Univer 实例的释放方法、移除已经注册的自定义渲染器、解绑所有通过addEventListener挂到 DOM 上的监听器。这些问题官方文档不会主动提醒,只能靠实际项目里一趟趟踩。
6. 生态、文档和选型结论
6.1 看源码比看文档更快
Univer 的官方文档一直在持续完善,但由于项目本身迭代快,文档在某些角落仍然会和最新代码脱节,尤其是一些比较新的扩展能力。我接近它的方式是直接打开node_modules/@univerjs/preset-sheets里的类型声明文件,这是一个可靠到令人安心的 API 参考。再看packages/sheets这类源码目录,能明白一个操作从前端命令到模型更新的完整链路。
源码目录的整体结构是围绕“核心逻辑层 / 渲染层 / UI 层”拆分的,真正要读懂的不是某一个组件怎么渲染,而是“命令如何被注册、如何被触发、如何作用到模型”,这个链路理解之后,再复杂的定制需求都不会找不到下手点。
6.2 提 issue 前做好最小复现
Univer 社区活跃度不错,但开源项目维护者的时间永远紧张。直接在 issue 里丢“我这边有个 bug,某个操作报错”而不附版本、环境和最小复现,大概率得不到有效的帮助。我自己提交过几次 issue,最有效的模板是:锁定的 Univer 版本号、宿主框架版本、一个可以跑起来的最小仓库、明确指出期望行为与实际行为。
如果连最小复现仓库都来不及整理,去仓库的 Discussions 或 issue 区搜关键词往往也能找到答案。Univer 的版本变化频繁,很多早期 issue 提到的问题在新版本里已经被修掉了,先确认版本再决定是否跟着提问,能少浪费很多时间。
6.3 我对 Univer 的最终判断
项目跑到今天,我对 Univer 的整体评价是:在开源可编程嵌入式表格这个方向里,它是目前选择里非常认真且架构完整的一个。它适合的团队是:前端有基本工程能力,愿意花一周左右时间熟悉它的插件机制和命令机制;它不适合的场景是:想要一个零学习成本的现成表格组件,拿过来就希望什么都有——这不现实。
我个人在实际操作中体会最深的一点是:Univer 最值钱的其实不是它“像 Excel”,而是它允许你把表格当作一块画布来用。以前产品提一个“在单元格里画个状态条”的需求,我要么找现成表格库看有没有接口,要么做个图片塞进去,但在 Univer 里这只是一个自定义渲染器的正常用例。这种自由度,是嵌入 iframe 或者用传统表格组件很难得到的。
最后再分享一个小技巧:刚接触 Univer 时,不要一上来就同时开启协同、自定义渲染器、多实例这些高阶功能,先把一个最简表格嵌入现有工程,跑通数据加载和修改监听,再逐步往上加一层层能力,每一步都验证清楚再走下一步。这个节奏不那么激动人心,但会帮你少一半的排查时间。