☰
Univer开源表格SDK:前端协同编辑与插件化架构实战解析
2026/9/26 13:01:34 网站建设 项目流程

做前端这些年,表格类需求永远是最磨人的那个。从最早用JQuery插件,到后来上手Luckysheet,再到给客户集成SpreadJS,每次都被性能和定制化折腾得够呛。直到去年折腾一个内部中台项目,同事甩过来一个叫univer的开源项目,说这玩意儿能直接当Excel用、能协同编辑、还能自己写插件,我一开始是不信的,结果上手一试,还真被它震住了。这篇就把我折腾univer的过程和思路完整写下来,给正准备选型或者已经被表格折磨过的朋友做个参考。

1. univer到底是什么?为什么值得关注

1.1 一个开源的办公软件SDK,不是又一个Excel

Univer(项目地址在GitHub上可以直接搜到)是一个基于TypeScript开发的开源办公软件SDK。注意它强调的是SDK,而不是“又一个在线Excel”。这意味着你拿到的不只是一个能用的表格应用,而是一套可以像乐高积木一样组合、再嵌入到你自己业务系统里的前端基础设施。

它支持的能力很全面:

  • 电子表格:从基础的单元格编辑、格式设置,到公式计算、数据透视表、条件格式、图表,都能做。
  • 文档与幻灯片:Univer不止有Sheet,还有一个内核上同时支撑Doc和Slide的野心。
  • 协同编辑:内置了操作转换(OT)相关的底层设计,可以和WebSocket服务端配合实现多人同时编辑。
  • 插件化架构:UI、编辑器、命令、数据源都可以通过插件机制扩展。
  • 跨端渲染:基于Canvas的渲染引擎,不依赖DOM,这为性能和后续跨端能力打了底子。

所以它解决的核心问题是:你现在开发业务系统时,不再需要自己从零造一个表格,也不需要在性能和定制化之间二选一。你可以直接“安装”一个表格编辑器,然后把它的API、样式、交互方式都掰开揉碎改造成自己需要的样子。

适合谁来用呢?我觉得有两类人最合适:一类是做低代码平台或者中后台系统,需要频繁内嵌表格编辑场景的团队;另一类是正在搞协同办公SaaS创业,不想从轮子开始造,但又希望核心能力在自己手里的团队。如果你是只想快速做个调查问卷回填表格,那直接用腾讯文档或金山文档就行,用不上univer。

1.2 同类方案横向对比:凭什么选univer

选型最怕“人云亦云”,所以我列了个简单的对比,把市面上常见的前端表格解决方案和univer放在一起看,可能更直观:

方案渲染方案协同能力扩展性协议与成本适合场景
HandsontableDOM需自行实现中等,有插件但核心闭源商业授权,费用不低中型管理后台简单编辑
LuckysheetCanvas有方案但偏单机中等,文档维护一般MIT开源偏展示和轻编辑
SpreadJSCanvas有,但定制靠厂商商业闭源,强依赖厂商支持授权贵,绑定深大型企业级复杂表格
univerCanvas原生设计,OT架构强,插件系统完整Apache-2.0,但有商业授权边界需要深度定制+协同的场景

无脑吹univer不客观,它有它的短板。比如核心版本迭代非常快,API变动频繁,社区中文文档还不算丰富,很多依赖得自己读源码。但从架构角度说,它把“协同”写进了底层,而不是一个插件收编进去;插件化设计让深度定制不需要硬改源码。对我来说,这两点就是杀手级优势。尤其是你团队里有人既不满足于死板的商业组件,又不想在联网输入卡顿上反复调优,univer是非常值得赌一把的方向。

2. 核心架构与设计思路拆解

2.1 从DOM到Canvas:渲染层选择的门道

很多人第一次看到univer在浏览器里的表现,第一反应是好奇:为什么看起来像网页表格,却那么流畅?秘密在于它没有用DOM做表格的窗口可视区渲染,而是基于Canvas实现了一套自绘渲染引擎。做前端时间久了就明白,一个10万行乘100列的表格,如果用DOM节点去渲染,浏览器早就卡死了。DOM的优点是易交互、易调试,但对大数据量渲染来说代价太高,直接表现就是创建节点、布局、重排。Canvas则相当于一个“画板”,你只需要把可视区域内的数字、网格线、底色精确画出来就行,它天然就适合这种高频重绘的场景。

univer的渲染引擎做得很讲究,它不是简单把整张表格画成一张大图,而是分层渲染:单元格背景一层、文字一层、边框一层、选区层又在上面。这样做的好处是,当你拖动选区或者改变样式时,可以只重绘受影响的图层,不必每次都从网格线开始全部重画。实测下来,处理单个Sheet 50万级别单元格的数据量,只要不是频繁全量重算,交互仍然能用“丝滑”来形容。

但Canvas也有缺点,比如传统DOM里的“右键菜单”“输入框聚焦”“文本选择”这些习惯交互,全得自己用代码模拟。univer做得不错的地方在于,它会用绝对定位的DOM叠加层来处理文本输入和下拉菜单这些交互性强的部分,也就是说,它在Canvas主体之上,聪明地组合了一层“虚拟DOM交互层”。这种混合方案既保持了性能,又兼顾了交互开发效率。

2.2 公式引擎和计算链:数据怎么在表里流动

表格的核心不只是显示,更是计算。univer有一个独立的公式引擎,不是“写完公式后硬算”那种笨办法,而是把单元格之间的引用关系构成一张依赖图。当某个单元格的值变化时,引擎会沿着依赖图找到所有受影响的单元格,然后按拓扑序依次重算。

听上去很高深,其实可以拿Excel的使用习惯来类比:你在B1里写“=A1+1”,C1里写“=B1*2”,那你改A1的值时,B1和C1都需要跟着更新。univer做的事情,就是主动维护A1→B1→C1这条链路。它还会做循环引用检测,避免两个宿命单元格互相引用导致计算死循环。

还有一点值得夸:它的公式计算默认可以在Web Worker里跑。对,就不用UI线程来扛重计算了。你在窗口里输入公式回车,页面不会白屏或卡死,因为计算在另一个线程并行。这对用户体验太重要了,我集成时把默认的worker开关开启后,明显感觉大公式集的处理再也不会阻塞交互了。

2.3 命令系统与插件化架构:为什么它好扩展

你如果需要理解univer为什么能被“拆成自己想要的样子”,那必须先理解它的命令系统和插件机制。

在univer里,几乎每次合法的用户操作都会转化成一条“命令”。比如“单元格A1设为值100”是一条命令,“合并选中的单元格区域”也是一条命令。所有命令都走统一的总线和调度器,因此可以做两件非常关键的事:

第一,撤销/重做变得非常天然。因为是命令流,撤回到上一步只需要反向执行命令或恢复快照,而不需要专门去保存整个表格的JSON快照。第二,外部代码可以像用户一样发起命令。比如你在业务系统里有一个按钮“一键填充所有缺考标记”,只要post出一条命令,表格就会正确地重算和渲染,从而保证数据一致性。

插件化架构又是另一层灵活度。插件在univer里分得比较细,有的负责UI,比如顶部的菜单栏和工具栏;有的负责功能,比如筛选、排序、导入导出;有的负责数据源对接,比如连接后端接口获取数据。插件之间有明确的依赖注入方式,你甚至可以只开启核心引擎,把UI换成自己公司风格的。

我自己印象最深的体会是:以前在SpreadJS里想改一下工具栏按钮的样式,要么看半天闭源文档,要么找厂商客服。在univer里,直接写个插件,把自己想要的按钮挂上去,然后通过命令总线监听用户点击动作。那种“代码自己做主”的爽快感,是商业组件给不了的。

2.4 协同编辑不是叠加功能,而是写进内核

在线Excel最难的从来不是画格子,而是多人同时编辑按一个单元格时的冲突处理。univer从底层数据结构设计上就考虑了协同:每次编辑都是通过记录“操作原子”(比如对某个Range做替换、插入、删除)来更新,然后服务器或者客户端通过特定的算法对这些操作做“转换”,也就是OT(Operational Transformation)。这样即使用户A往单元格里填了数字,用户B同时合并了那一行,两个人最后看到的结果都不会错乱。

对大多数团队来说,你不需要自己从零实现OT,但需要理解这个模型。这样你在设计后端保存方案时,就不会傻到每次把整个表格的JSON全量传回服务器覆盖,而是要设计成“增量操作序列同步”。我见过有人把univer和Yjs这类CRDT库结合使用,这也可行,但和univer原生命令流的配合得做好适配,不然容易有数据分歧。

协同是一件“所有层都得为它服务”的事。所以univer在架构上选择了命令机制:每个本地操作都能导出成一份可传输的“操作报文”,另一端拿到报文后执行效果和本地完全一致。这个设计非常聪明,它是把前端的编辑事件当作了“协议数据”,而不是“UI更新通知”。这也是我判断一个表格编辑器能否真正支持协同的核心标准。

3. 实操:从零把一个可编辑表格集成进你的系统

3.1 环境准备和最小可运行实例

先说技术栈要求:Node版本建议16以上,包管理器用npm或者pnpm都可以,框架层它本身不绑定你用的框架,但React/Vue都有官方封装。我平时项目是Vue3+Vite,所以这里以React的演示更通用,但思路完全一致。

安装依赖的方式现在比较清晰。univer主包是@univerjs/core、@univerjs/sheets、@univerjs/sheets-ui、@univerjs/design这一组 。新版本也有内置的preset可以快速启动,适合想先看效果再细抠的:

npm install @univerjs/core @univerjs/sheets @univerjs/sheets-ui @univerjs/design @univerjs/ui

然后写一个最基础的实例:

import { Univer } from '@univerjs/core'; import { defaultSheetUIPlugin } from '@univerjs/sheets-ui'; import { SheetsUniver } from '@univerjs/sheets'; import { UniverUIPlugin } from '@univerjs/ui'; import '@univerjs/design/lib/index.css'; import '@univerjs/sheets-ui/lib/index.css'; import './index.css'; const univer = new Univer({ locale: 'zhCN', }); univer.addPlugin(SheetsUniver); univer.addPlugin(UniverUIPlugin, { container: 'app', header: true, toolbar: true, footer: true, }); univer.addPlugin(defaultSheetUIPlugin);

这段代码会往页面里塞一个带标准工具栏、公式栏、状态栏的完整表格UI。这里我不建议一开始就自定义太多,先把基础跑通,哪怕就一个输入框和几行数据,也是一种成功。因为univer的依赖关系比普通组件复杂,如果刚上手就自定义配置出问题,排查范围会变得很大。

实操心得:如果你发现样式错乱或者图标不显示,多半是CSS文件没引全,univer包的样式是分成多个子包的,UI层和Sheets UI层缺一不可。

3.2 常用配置与API:数据源绑定、单元格操作、样式

跑通基础后,多数业务系统的第一需求就是“把后端数据填充进表格”。univer初始化表格时可以直接指定初始数据,同时也支持后续更新:

const workbook = univer.createUniverSheet({ sheetOrder: ['Sheet1'], sheets: { Sheet1: { id: 'sheet-1', name: 'Sheet1', rowCount: 100, columnCount: 20, cellData: { 0: { 0: { v: '姓名', s: { bold: 1 } }, 1: { v: '分数' }, }, 1: { 0: { v: '张三' }, 1: { v: 87 }, }, }, }, }, });

这里cellData是个稀疏嵌套对象,行索引 -> 列索引 -> 单元格对象,单元格对象里的v是显示值/原始值,s是样式对象。如果你接触过Luckysheet,对这种格式应该非常亲切。

如果你需要动态更新某个单元格,直接用命令的方式去改,而不是直接操作对象:

import { SetRangeValuesCommand } from '@univerjs/sheets'; const commandService = univer.getCommandService(); commandService.executeCommand(SetRangeValuesCommand.id, { unitId: workbook.getUnitId(), subUnitId: 'sheet-1', range: { startRow: 2, startColumn: 1, endRow: 2, endColumn: 1 }, value: { 2: { 1: { v: '李四', s: { backColor: '#ffff00' } }, }, }, });

这个方式刚开始有点重,但你一旦习惯“通过命令改数据”的模式,后面做撤销、协同、服务端同步都会受益。

样式系统支持很多常用属性:bold、italic、backColor、foreColor、border、merge、hAlign、vAlign等等。合并单元格也不复杂,只需要在cellData里把区域右下角的单元格标记为{ merge: 1 },并把起始单元格的坐标写清,就能形成合并效果。

3.3 数据导入导出:Excel交互的完整链路

做表格系统,绕不开Excel的导入导出。univer为此提供了完整的序列化方案。你可以把整个workbook转换成一个JSON结构,也可以让它对齐XLSX格式。新版的导入导出插件一般叫@univerjs/preset或者独立的xlsx插件,用起来是这样:

import { importXlsx, exportXlsx } from '@univerjs/xlsx'; // 导入 const fileInput = document.getElementById('upload'); fileInput.addEventListener('change', async (event) => { const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return; const buffer = await file.arrayBuffer(); await importXlsx(univer, buffer); }); // 导出 const buffer = await exportXlsx(univer); const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'export.xlsx'; a.click();

导入大文件时需要注意内存问题。我们自己测过,十几兆的xlsx文件,用univer解析后生成内部数据结构,内存占用会有数倍的膨胀。如果后台服务器有空闲能力,建议把导入解析放到后端做,然后直接往后端传univer自己的JSON格式,避免在前端做大量转换。

避坑提示:不要直接用浏览器自带的FileReader读大文件再传给univer,尽量走arrayBuffer()加异步解析,保持UI线程不被长时间阻塞。

3.4 把univer接进你的后端:请求层封装和业务数据联动

表格单独能跑只是第一步,真正的业务系统里,它必须和你的后端服务、权限系统、组织架构数据发生关系。

我常用的做法是,在univer的命令执行钩子里挂一个“自动保存”监听。比如当SetRangeValuesCommand执行完成后,解析这次操作影响的行列范围和值,然后包装成一条审计日志POST到后端。这里的关键是要处理好节流:

let saveTimer: ReturnType<typeof setTimeout> | null = null; univer.getCommandService().onCommandExecuted((command) => { const { id } = command; if (id === SetRangeValuesCommand.id || id === InsertDataCommand.id) { if (saveTimer) clearTimeout(saveTimer); saveTimer = setTimeout(() => { const snapshot = univer.getSnapshot(); // 把snapshot做差量/全量同步到后端 fetch('/api/sheet/save', { method: 'POST', body: JSON.stringify(snapshot), }); }, 1000); } });

这里我故意用了getSnapshot()全量快照做示例,因为在真实场景中,如果你改动量不大,直接全量快照在几百KB的场景下其实可以接受;但如果你的表格有上万行数据,建议设计成增量操作序列同步。具体做法可以参考univer官方文档里“Collaborative Editing”章节,它会把操作包装成CommandTransformer或者带OperationType的消息,服务端只需按序应用即可。

实操心得:做后端联动时,最好让后端只依赖univer导出的JSON结构,而不要让后端直接解析xlsx格式。univer JSON包含了单元格内容、样式、行列结构,语义上比xlsx更清晰,后端处理也更快。xlsx只作为导入导出的边界格式。

4. 踩坑清单与排查技巧实录

4.1 版本碎片化:锁定版本比追新更重要

这可能是新手遇到最多的问题。univer在2023到2024年之间的版本演化非常剧烈,今天搜到一篇博文用的还是@univerjs/core的旧API,明天官方文档就已经换了一套插件初始化方式。不同版本的依赖名也变化过,有人用预设包,有人用底层包。再加上社区包偶尔滞后,版本不匹配就会导致运行时报错或者样式丢失。

我的建议是:无论你从哪篇教程开始的,先把项目里univer相关包的版本锁定,然后指定在那一个版本范围内学习和开发,别天天追最新。

{ "dependencies": { "@univerjs/core": "0.3.1", "@univerjs/sheets": "0.3.1", "@univerjs/sheets-ui": "0.3.1", "@univerjs/design": "0.3.1" } }

这样至少能保证你在网上查到的资料和实际代码是能对上的。等你完全掌握了当前版本的核心API,再计划跨版本升级。升级时要重点看官方changelog里关于“breaking changes”的说明,其他小修小改影响不大。

4.2 样式丢失和重计算异常的排查思路

如果你发现初始化后单元格的底色或字体样式没生效,先不要怀疑univer bug。大部分情况是格式键名写错了。univer的样式对象里键名是驼峰式的,不是xlsx里那种font-color格式。比如红底应该是backColor,加粗是bold,而不是background-color或fontWeight。

另外,条件格式和自定义数字格式是两个容易出问题的地方。如果写了条件格式但界面没有反应,记得检查conditionalFormatting是挂在SubUnit级别还是Workbook级别的Context上,这两个地方适用的范围并不一样。数字格式则要注意yyyy-mm-dd这种格式在序列化和重新渲染时可能由于时区出现一天偏差,这个最容易在日期列上翻车。

遇到重计算结果不对时,优先检查依赖图是否被手动改动破坏。比如你用代码逻辑直接修了某个单元格的v值,而没有通过命令系统,这个改动很可能没有触发公式依赖链上的更新。正确的做法永远是executeCommand,而不是直接对象赋值。

4.3 大数据量下的卡顿问题

univer的Canvas渲染确实能扛得住大数据量,但是大数据量往往不是瓶颈的全部。卡顿通常出在三个环节:初始化时全量构建数据模型、公式重算时依赖链爆炸、UI频繁重绘。

初始化大数据时,不要一条条用命令写入,要直接构造cellData对象一次性给到Sheet。这样能跳过命令调度和UI更新的中间步骤,实测下来速度差好几倍。公式重算的优化,核心思路是缩小重算范围,比如减少跨Sheet引用,避免多个巨型数组公式堆叠。UI重绘方面,可以通过设置渲染节流或者关闭连续动画来让出资源,尤其在协同场景里,远端操作导致的刷新如果没限流,浏览器会有明显掉帧。

实战经验:一次我测试一个约8万行、20列的数据表,每分钟进行上百次单元格修改。一开始总是卡,后来把所有直接对象操作改成批量命令,并把每个命令的undo: true关掉,性能提升非常明显。因为每一条可撤销命令都会在undo栈里记住快照,数据量一大,这个快照就是灾难。

4.4 协同冲突和“无敌时刻”的处理

这里想聊一个协同编辑里不太被人提到的实际问题:你接了协同服务端之后,往往会出现“我已经改了A1单元格,但远端的一个操作同时改了A1单元格合并”的情况。univer底层的OT能力虽然能处理操作序列在逻辑上的冲突,但业务语义上的冲突它是管不了的,比如你填写了“销售数量”,别人又把这一列改成了“备注信息”,底层不会报错,但数据意义已经变了。

要我给建议的话,团队需要在产品层面规避这种冲突,比如精细化锁定行、列、Sheet,或者用“记录锁”的方式:用户编辑某区域前,向后端申请一个宝贵的写锁,其他成员只能看到锁定状态。

实现时可以利用univer的CommandService在前置判断逻辑中加锁检查:

univer.getCommandService().beforeCommandExecute((command) => { const { id } = command; if (id === SetRangeValuesCommand.id) { // 在这里查一下后端锁状态,如果被锁定则返回false,阻止执行 if (isRangeLocked(command.params.range)) return false; } return true; });

这样一个锁,既保护了业务数据不被光学意义上的OT误解,也保护了用户不被自己的冲突操作绕晕。

5. 实用插件开发:自己动手扩展一个业务按钮

要论univer最吸引人的地方,插件机制绝对排第一。我建议你从最简单的插件案例开始,比如在工具栏加一个“提交所有修改”的按钮。这个案例虽小,但它包含了监听命令、调用服务、更新UI状态、与后端交互的所有模型。

新版本里注册自定义按钮的方式常常通过UI插件里的uiParts或者contextMenu扩展。一个简单的思路是注册一个自定义MenuItem,它的onClick里调用getSheetCommandService()发出批量操作。如果需要监听当前选中区域,也可以借用SelectionRenderService。

具体伪代码思路:

class SubmitController extends UniverPlugin { onMounted(): void { this.registerStaticMenu('toolbar.submit', { order: 5, label: '提交', onClick: () => { const snap = this.getContext().getUniver().getSnapshot(); fetch('/api/submit', { method: 'POST', body: JSON.stringify(snap) }); }, }); } }

中间有几个关键点:第一,插件的生命周期绑定在univer实例上,卸载时记得释放资源;第二,自定义菜单样式的key如果不确定,先打开浏览器控制台看DOM结构再调整层级;第三,如果你在按钮回调里需要读当前选中范围,不要缓存在构造函数里,一定要每次实时获取,否则选中位置变了回调还拿着旧数据,这种bug特别隐蔽。

实操心得:插件化开发时建议开TypeScript严格模式。univer自身类型体系比较完整,开着正确性检查能逼着你把所有接口参数捋明白,能少一半运行时调试成本。

最后再分享一个小技巧

如果你刚接触univer,不建议直接开一个大而全的协同系统,那是个太大的工程。我建议你先做一个小内网系统:把univer嵌进去,接好导入导出,再把自己公司的业务字段用自定义命令填进表格。等你跑通了这套基础链路,再往上面加协同、加锁、加移动端适配。它架构的天花板远比你想得高,但地上要走的第一步还是先把眼前的路看清。

另外,多翻它的源码,尤其@univerjs/sheets里各种Command的实现方式,那是比任何教程都准确的设计文档。我就是靠啃命令实现理清了数据流的来龙去脉。这项目的社区还很年轻,你现在踩的坑,过几个月可能就被人总结成了最佳实践,这种从混沌到清晰的过程,本身就是最有意思的部分。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询