1. 从“univer”这个名字说起:它到底解决了什么问题
第一次听到 univer 这个名字,很多人会以为是某个大学项目或者某个开源社区的小众玩具。但如果你最近在关注在线表格、在线文档、协同编辑这类方向,这个名字出现的频率正在肉眼可见地变高。univer 是一个开源的、面向电子表格与文档场景的前端渲染与协同引擎,它把 Canvas 绘图、公式计算、协同编辑、插件化架构这几件事揉在了一起,目标很明确:让开发者能在自己的产品里,用相对低的成本嵌入一套接近专业办公软件的表格能力。
这件事为什么值得单独拿出来讲?因为过去想在 Web 里做一套像样的表格,路子其实很窄。要么用现成的商业组件,贵且不透明;要么自己基于 DOM 硬写,行数一多就卡到怀疑人生;要么用 Canvas 自己画,但画完之后公式、选区、复制粘贴、协同这些全得自己补,工作量直接爆炸。univer 的价值就在于,它把“Canvas 渲染引擎 + 数据模型 + 公式引擎 + 协同层 + 插件体系”打包成了一套 SDK,你拿到的是一个可以二次开发的底座,而不是一个只能看不能改的成品。
它适合谁?三类人最应该关注。第一类是前端工程师,尤其是做过或想做在线表格、报表、低代码平台的人,univer 的 Facade API 和插件机制能让你少走很多弯路。第二类是 Node.js 方向的服务端开发者,因为 univer 的协同和公式计算有一部分是可以放到服务端跑的,前后端如何分工是个很实际的问题。第三类是产品和技术负责人,需要评估“自研表格”和“接入 univer”之间的成本差。这篇文章我会按一个真实落地项目的思路来写,从架构选型、核心概念、实操接入、到踩坑排查,尽量把我知道的都倒出来。
2. 整体架构与选型思路:为什么是 Canvas 加 Facade API
2.1 Canvas 渲染这条路的必然性
先聊渲染。表格这个东西,本质上是一个二维网格,每个单元格可能有文本、样式、边框、合并、条件格式。用 DOM 来做,最直观,一个单元格一个 div 或者 td,简单表格确实够用。但一旦行数上万、列数上百,DOM 节点数量就是几十万级别,浏览器的布局和重绘压力会直接把你按在地上。我实测过一个两万行的 DOM 表格,滚动的时候帧率掉到个位数,用户体验基本没法看。
Canvas 的思路是把整个表格画在一张画布上,只渲染可视区域内的单元格,滚动时重绘。这样节点数量恒定,性能可控。univer 走的就是这条路,它内部维护了一套渲染调度,把单元格的绘制、选区高亮、滚动条、冻结行列这些都统一在 Canvas 上处理。代价是,Canvas 里的一切都是“画”出来的,没有 DOM 那样的天然可访问性和事件冒泡,所以选区、点击、输入这些交互都得自己实现一套命中检测和事件分发。univer 帮你把这部分做完了,这是它比“自己从零画 Canvas”省事的关键。
提示:如果你的场景表格规模很小(几百行以内),其实没必要上 Canvas 方案,DOM 表格的开发效率和可维护性反而更高。Canvas 的收益是在大数据量下才体现出来的。
2.2 Facade API 的设计哲学
univer 对外暴露的核心接口叫 Facade API。这个词直译是“门面”,在软件设计里指的是用一个统一的高层接口,把底层复杂的子系统包起来。为什么需要这个?因为 univer 内部模块非常多:渲染引擎、数据模型、公式引擎、命令系统、插件容器,如果每个模块都直接暴露给使用者,学习成本会高到劝退。
Facade API 做的事情就是,把“创建一个表格”“设置某个单元格的值”“监听选区变化”“注册一个自定义函数”这些高频操作,收敛成一组语义清晰的调用。你不需要知道底层命令是怎么派发的,也不需要关心渲染是怎么调度的,调 API 就行。这种设计对二次开发特别友好,因为它把稳定接口和内部实现解耦了,univer 内部重构的时候,只要 Facade API 不变,你的代码就不用动。
我个人的判断是,选型的时候一定要看一个库有没有这种“稳定门面”。很多开源项目把内部类直接暴露出来,用起来爽,但升级一次就崩一次。univer 在这一点上做得比较克制,值得肯定。
2.3 插件化架构带来的扩展空间
univer 的另一个核心设计是插件化。它本体只提供最基础的表格能力,公式、协同、导入导出、条件格式这些都是以插件形式存在的。这意味着两件事:一是你可以按需加载,不用为一个只用基础表格的场景引入整个公式引擎;二是你可以写自己的插件,把业务逻辑挂进去。
插件化的代价是心智负担。你得理解插件的生命周期、依赖关系、注册顺序。我见过有人把插件注册顺序搞错,导致公式引擎在数据模型之前初始化,结果公式一直算不出来。这类问题在后面排查章节我会细说。
3. 核心概念拆解:数据模型、命令系统与协同层
3.1 数据模型:一切从 Workbook 开始
univer 的数据模型是围绕 Workbook 组织的。一个 Workbook 里有多个 Worksheet,每个 Worksheet 里有单元格数据、行列信息、样式、合并区域等。这个结构和 Excel 的心智模型基本一致,所以如果你熟悉 Excel 的对象模型,上手会很快。
关键点在于,univer 的数据模型是“可序列化”的。也就是说,整个 Workbook 可以转成一个 JSON 对象,存到后端、传到前端、做快照、做版本对比都没问题。这一点对协同场景至关重要,因为协同的本质就是“把变更同步给所有人”,而变更必须能表达成可传输的数据。
单元格的值、公式、样式在模型里是分开存的。值是一层,公式是一层,样式又是一层。这种分层设计的好处是,改样式不会触发公式重算,改值才会。理解这个分层,对后面做性能优化很有帮助。
3.2 命令系统:所有修改都走命令
univer 里对数据的任何修改,都不是直接改对象,而是通过“命令”来做的。比如设置单元格值,是派发一个 SetRangeValuesCommand;插入一行,是派发一个 InsertRowCommand。为什么这么设计?因为命令是可记录、可撤销、可同步的。
可撤销好理解,Ctrl+Z 就是撤销上一个命令。可同步是协同的基础:本地执行一个命令,把这个命令广播给其他客户端,其他客户端执行同样的命令,状态就一致了。如果直接改对象,你没法知道“改了什么”,也就没法同步。
这个设计对使用者的影响是:你不能直接worksheet.getCell(0,0).value = 1,得走 Facade API 提供的方法。刚开始会觉得绕,但习惯之后会发现这是对的,因为它保证了所有变更都在可控范围内。
3.3 协同层:命令广播与冲突处理
协同是 univer 的一个重点能力。它的基本思路是:每个客户端本地先执行命令,保证即时响应,然后把命令发给协同服务,服务再广播给其他客户端。这就是所谓的“乐观更新”,用户感觉不到延迟。
冲突处理方面,univer 采用的是基于命令的合并策略。因为命令本身携带了操作意图(比如“把 A1 设为 1”),所以合并的时候可以按一定规则决定谁赢。这里不展开太深,但你要知道一个现实:协同的难点从来不是技术,而是产品语义。两个人同时改一个单元格,到底谁赢、要不要提示、要不要保留历史,这些是产品决策,不是技术能自动解决的。
注意:如果你的协同场景对一致性要求极高(比如财务对账),乐观更新可能不够,需要考虑加锁或者服务端仲裁。univer 提供了扩展点,但具体策略得自己定。
4. 实操接入:从零跑起一个 univer 表格
4.1 环境准备与依赖安装
先说环境。univer 是前端库,所以你需要一个 Node.js 环境来跑构建工具。Node.js 版本建议用 18 LTS 或更高,我实测 18.20.4 和 22.x 都能正常跑。安装步骤不复杂,去官网下载对应系统的安装包,一路下一步就行。装完之后在终端敲node -v和npm -v,能输出版本号就说明装好了。
如果你是在 CentOS 这类服务器上做构建,注意 Node.js 的安装方式。用包管理器装的版本往往偏旧,建议用 nvm 或者官方提供的二进制包。我踩过一次坑,服务器上 Node 版本太低,构建的时候各种语法报错,排查了半天才发现是版本问题。
依赖安装这块,univer 是拆成多个包发布的,核心包加上你需要的插件包。基础接入一般需要核心包、渲染包、公式包(如果需要公式)、协同包(如果需要协同)。用 npm 或 yarn 装都行,注意版本要对齐,不同包之间版本不匹配是常见问题。
npm install @univerjs/core @univerjs/design @univerjs/engine-formula @univerjs/sheets @univerjs/sheets-formula4.2 初始化一个最小可用的表格
装完依赖,接下来是初始化。univer 的初始化分两步:先创建 Univer 实例并注册插件,再创建 Workbook 并挂到 DOM 上。下面是一个最小示例。
import { Univer, LocaleType, merge } from '@univerjs/core'; import { UniverFormulaEnginePlugin } from '@univerjs/engine-formula'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsFormulaPlugin } from '@univerjs/sheets-formula'; const univer = new Univer({ locale: LocaleType.ZH_CN, theme: defaultTheme, }); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverFormulaEnginePlugin); univer.registerPlugin(UniverSheetsFormulaPlugin); univer.createUnit(UniverInstanceType.UNIVER_SHEET, { id: 'my-workbook', sheets: { 'sheet-01': { id: 'sheet-01', name: 'Sheet1', cellData: { 0: { 0: { v: 'Hello' }, 1: { v: 'Univer' } }, 1: { 0: { v: 1 }, 1: { v: 2 }, 2: { f: '=SUM(A2:B2)' } }, }, }, }, });这段代码做了几件事:创建实例、注册插件、创建一个带数据的 Workbook。注意cellData的结构,外层 key 是行号,内层 key 是列号,v是值,f是公式。这个结构就是前面说的可序列化数据模型。
4.3 挂载到页面与样式处理
创建完 Workbook,还得把它渲染到页面上。univer 需要一个容器元素,然后你调用渲染相关的方法把表格挂上去。容器的高度必须明确,否则 Canvas 不知道画多大。我见过有人容器没设高度,结果表格一片空白,排查半天。
<div id="univer-container" style="width: 100%; height: 600px;"></div>样式方面,univer 的组件库有自己的 CSS,需要引入。如果你用的是构建工具,直接在入口文件 import 对应的样式文件即可。漏引样式会导致表格布局错乱,这是新手常见问题。
4.4 用 Facade API 做常见操作
挂载完成后,日常开发大部分时间是在和 Facade API 打交道。举几个高频操作。
读取单元格值:
const workbook = univerAPI.getActiveWorkbook(); const worksheet = workbook.getActiveSheet(); const range = worksheet.getRange(0, 0); const value = range.getValue();设置单元格值:
worksheet.getRange(0, 0).setValue('新值');批量设置:
worksheet.getRange(0, 0, 3, 3).setValues([ [1, 2, 3], [4, 5, 6], [7, 8, 9], ]);监听选区变化:
univerAPI.getActiveWorkbook().getActiveSheet().onSelectionChanged((selection) => { console.log('当前选区', selection); });这些 API 的设计思路是链式的,getRange拿到区域对象,再调方法。熟悉之后写起来很顺。
5. 公式引擎与自定义函数:让表格真正“活”起来
5.1 内置公式的覆盖范围
univer 的公式引擎覆盖了大部分常用函数,SUM、AVERAGE、IF、VLOOKUP、COUNTIF 这些都有。它的计算是增量的,也就是说,你改了一个单元格,只有依赖它的公式会重算,不是全表重算。这个设计对性能很关键,否则一个大表格改一个格子就卡死。
公式的依赖关系是引擎自动维护的。你写=A1+B1,引擎会记录 C1 依赖 A1 和 B1,A1 变了就重算 C1。这个依赖图是动态更新的,插入删除行列的时候会重建。理解这一点,对排查“公式不更新”的问题很有帮助。
5.2 注册自定义函数的完整流程
内置函数不够用的时候,可以注册自定义函数。univer 提供了注册接口,你需要定义函数名、参数、计算逻辑。下面是一个简单示例,实现一个把两个数相乘再取整的函数。
import { IFunctionInfo, FunctionType } from '@univerjs/engine-formula'; const myFuncInfo: IFunctionInfo = { functionName: 'MYMUL', functionType: FunctionType.Math, description: '两数相乘取整', parameters: [ { name: 'a', detail: '第一个数', type: 'number' }, { name: 'b', detail: '第二个数', type: 'number' }, ], }; univerAPI.registerFunction(myFuncInfo, (a, b) => { return Math.floor(a * b); });注册完之后,在单元格里写=MYMUL(3, 4)就能得到 12。自定义函数的价值在于,你可以把业务逻辑封装成公式,让非技术人员也能用。比如做报表的时候,把一些复杂的计算规则做成函数,业务人员直接写公式就行。
提示:自定义函数的计算逻辑要尽量纯,不要在里面做异步操作或者依赖外部状态,否则公式重算的时候行为不可预测。
5.3 公式性能的几个关键点
公式是性能大户。我总结几个经验。第一,避免大范围的整列引用,比如=SUM(A:A),这种在数据量大的时候会拖慢计算。第二,VLOOKUP 这类查找函数,如果查找范围很大,考虑用 INDEX+MATCH 或者加辅助列。第三,循环引用要避免,虽然引擎会检测,但检测本身也有开销。
还有一个容易被忽略的点:公式的易失性。像 NOW()、RAND() 这类函数,每次重算都会变,会导致依赖它们的公式频繁重算。如果表格里大量使用这类函数,性能会明显下降。能用静态值就用静态值。
6. 协同编辑落地:从单机到多人
6.1 协同服务的基本形态
univer 的协同需要一个服务端来中转命令。这个服务端的职责是:接收客户端发来的命令,广播给同一文档的其他客户端,同时维护一份文档的当前状态。服务端可以用 Node.js 写,因为 univer 的核心逻辑有一部分是可以在 Node 环境跑的,前后端能复用代码。
服务端的核心是房间管理。每个文档对应一个房间,房间里有若干客户端连接。客户端加入房间时,服务端把当前文档快照发过去,客户端加载后再接收增量命令。这个流程和大多数协同系统类似。
6.2 命令的序列化与传输
命令在传输前要序列化。univer 的命令对象本身是普通对象,JSON 序列化就行。但要注意,命令里可能包含一些不需要传输的字段,比如本地的临时状态。传输前最好做一次清洗,只发必要的数据。
传输协议用 WebSocket 比较合适,因为协同需要双向实时通信。消息格式建议加一个类型字段,区分“命令”“快照请求”“光标位置”等。光标位置这种高频但轻量的数据,可以和命令走不同的通道,避免互相影响。
6.3 冲突场景与处理策略
前面提过,协同的难点在产品语义。举几个实际场景。两个人同时改 A1,一个改成 1,一个改成 2。乐观更新下,本地都先显示自己的值,服务端广播后,后到的命令覆盖先到的,最终两人都看到 2。这个行为可接受,但如果用户在意,就得加提示。
再比如,一个人删了一行,另一个人正在这行里输入。删除命令到达后,输入的那行没了,输入框要关闭。这类边界情况需要在客户端做处理,监听命令类型,做相应的 UI 响应。
我的建议是,协同功能上线前一定要做压力测试和异常测试。网络抖动、断线重连、消息乱序,这些在真实环境里都会遇到。univer 提供了重连机制,但业务层的状态恢复得自己写。
7. 常见问题与排查技巧实录
7.1 表格不显示或显示空白
这是最高频的问题。排查顺序:第一,容器有没有明确高度,Canvas 需要知道画多大;第二,样式文件有没有引入,漏引会导致布局塌陷;第三,Workbook 有没有正确创建并挂载,检查 createUnit 的返回值;第四,控制台有没有报错,插件注册顺序错误经常在这里暴露。
7.2 公式不计算或计算结果不对
先确认公式引擎插件有没有注册。然后检查公式语法,univer 的公式语法和 Excel 基本一致,但个别函数可能有差异。再检查依赖关系,如果公式引用的单元格是空的,结果可能是 0 或者空。最后看是不是循环引用,引擎会检测但可能不报错,只是不计算。
7.3 协同不同步
排查思路:先确认 WebSocket 连接是否正常,看网络面板有没有断线。再确认命令有没有发出去,在发送处打日志。然后看服务端有没有收到并广播。最后看接收端有没有正确执行命令。这条链路任何一环断了都会导致不同步。常见原因是命令序列化失败,比如命令里带了函数或者循环引用,JSON.stringify 会报错。
7.4 性能问题排查表
| 现象 | 可能原因 | 排查方法 | 解决方向 |
|---|---|---|---|
| 滚动卡顿 | 渲染单元格过多 | 看可视区域外是否也在渲染 | 检查虚拟滚动配置 |
| 输入延迟 | 公式重算频繁 | 看是否有易失性函数 | 替换为静态值 |
| 内存增长 | 命令历史未清理 | 看撤销栈是否无限增长 | 限制历史条数 |
| 协同延迟 | 命令体积过大 | 看单条命令大小 | 拆分或压缩命令 |
| 首屏慢 | 快照过大 | 看初始加载数据量 | 分片加载或懒加载 |
7.5 几个我踩过的坑
第一个坑是插件注册顺序。公式引擎必须在表格插件之后注册,否则公式引擎找不到数据模型。这个顺序在文档里不一定写得很显眼,但错了就是不行。
第二个坑是 Node.js 版本。前面提过,低版本 Node 会导致构建失败,而且报错信息往往指向具体语法,不直接说是版本问题,容易误导。
第三个坑是 Canvas 的导出。univer 的表格是画在 Canvas 上的,如果你想导出成图片,不能直接截 DOM,得用 Canvas 的 toDataURL。但要注意跨域图片会污染画布,导致导出失败。如果表格里有外部图片,需要服务端代理或者设置跨域头。
第四个坑是移动端适配。Canvas 在移动端的触摸事件和桌面端不一样,univer 做了处理,但如果你自定义了交互,要额外测试。iOS Safari 上有个已知问题是 Canvas 尺寸过大时导出白图,解决办法是限制导出尺寸或者分块导出。
8. 扩展方向:univer 还能怎么用
univer 的插件体系意味着它的边界是可以扩展的。我想到几个方向。一是和低代码平台结合,把表格作为数据展示和编辑的组件,通过 Facade API 和平台的数据源打通。二是做报表工具,利用公式引擎和自定义函数,让业务人员配置计算规则。三是做数据采集,把表格作为录入界面,配合校验插件做数据质量控制。
还有一个方向是服务端计算。univer 的公式引擎可以在 Node.js 里跑,这意味着你可以把一些重计算放到服务端,前端只负责展示。这在数据量大、计算复杂的场景下很有价值。比如财务模型,几万个公式,放前端算会卡,放服务端算完把结果推给前端就流畅了。
Node.js 在这里的角色不只是构建工具,它可以是协同服务端、计算服务端、导入导出服务端。如果你本来就是 Node.js 方向,univer 的服务端扩展是个很自然的切入点。
9. 一些个人体会
用 univer 做项目,最大的感受是“底座很稳,但上层要自己搭”。它把最难的部分(渲染、公式、协同)做完了,但业务逻辑、交互细节、性能调优这些还是得自己来。这不是缺点,反而是它的定位决定的——它是一个 SDK,不是一个成品。
选型的时候,我建议先想清楚自己的场景。如果只是要一个能展示数据的表格,用现成的组件库更省事。如果要的是可深度定制、能协同、能跑公式的表格引擎,univer 值得投入时间研究。学习曲线是有的,但一旦理解了它的数据模型和命令系统,后面的事情就顺了。
最后分享一个小技巧:调试的时候,把 univer 的命令日志打开,能看到每一条命令的派发和执行。这对理解它的工作机制特别有帮助,排查问题的时候也能快速定位是哪条命令出了问题。这个日志在开发环境开着,生产环境关掉,对性能影响不大。