搞在线文档这行的人,估计都搜过“univer在线”这个关键词。你搜它的原因多半不是想找个云表格随便用用,而是想给自家产品嵌一个能编辑、能算公式、甚至能多人一起操作的表格能力。这种需求在内部系统、SaaS产品、数据分析平台里太常见了,常见到每个项目组最后都会踩出不同的坑:要么用ExcelWeb控件绑定一个老旧插件,要么强行把OpenOffice套壳,要么干脆自己用Canvas画一个假表格面板。
Univer这个开源项目进入视野的时候,我第一反应是“又来了一个在线Excel组件”。但把它跑起来,翻了核心代码,又去看了它的命令系统和协作协议之后,我的判断变了。这已经不是简单复制一个表格交互层,它更像是把文档类基础设施重做了一遍。
这篇文章不打算做那种“你看看我们也能跑出Excel”的展示,而是从工程选型和二次开发角度,把Univer到底解决了什么问题、底层怎么设计、上手怎么操作、坑在哪里,完整拆一遍。你如果正在评估在线表格方案,或者准备在项目里嵌入Univer,照着这篇文章的思路走,能省不少试错时间。
1. 页面里要一张能编辑的Excel:Univer这块开源拼图填了什么窟窿
先说清楚“Univer解决的是哪一类问题”。它不是做一个Excel替代品给你日常办公,而是把一套完整的、可嵌入的、支持编程控制的表格能力,变成你网页里的一个组件。换句话讲,Univer定位是“办公套件基础设施”,而不是“又一个WPS在线版”。这里的关键差异在于:基础设施意味着你能基于它二次开发,能控制数据流,能接入自己的后端,能定制界面。
那为什么说它填了窟窿?过去几年,前端想在网页里做表格,能选的就那么几个:
- Excel官方JavaScript API基本只能操作桌面端的Office,放到Web上还要SharePoint或Microsoft Graph那一整套,太重。
- Google Sheets的嵌入式方案只能在Google生态里玩,数据都过人家的服务器。
- OpenXML、ExcelJS之类的库只能读写xlsx文件,不能做交互界面。
- Handsontable是个很成熟的表格组件,但它更接近一个“可编辑的网格控件”,公式引擎、单元格样式、复杂打印方案都得自己攒。
- OnlyOffice倒是能嵌入在线办公,但部署一个完整的DocumentServer,再怎么轻量也要落实到Docker、数据库、文件存储,中小团队直接调不动。
- Luckysheet曾经的社区热度很高,但它的代码迭代早已慢下来,维护者自己也转向了新的项目,也就是后来的Univer。
所以“前端想要一个能编辑的Excel”这件事,一直处于一种无人认真维护的中间地带。Univer把你的相似罕见需求抓起来了:开源、纯前端渲染、支持TypeScript二次开发、命令系统可扩展、协同编辑有底层设计。
Univer吸引我的第二个点,是它不止做表格。同一个内核里,它还规划了文档和幻灯片两种单元。这意味着表格、文档、PPT用的是同一套数据模型、同一套命令机制、同一套渲染引擎。对业务方来说,未来如果要从表格扩展到在线文档、数据简报,不需要换一套技术栈,这是很省成本的架构决策。
当然,光说不算,下面得看它代码和组织上的实际设计。
2. 从Luckysheet到Univer:一次推倒重写背后隐藏了什么
如果你接触过Luckysheet,再看Univer的代码和文档,会有一种“果然是同一个团队但完全换了思路”的既视感。Univer一开始就没打算在Luckysheet的代码上缝缝补补,而是推倒重来。想理解Univer为什么这样设计,就得先知道Luckysheet败在哪儿。
2.1 Luckysheet的痛点:状态管理和协同扩展太困难
Luckysheet当年能火,是因为它用Canvas把Excel交互在网页上画出来了,视觉上很像,操作手感也还行。但代码结构还是典型的“jQuery时代思维”:全局状态一大坨,模型和视图高度耦合,每次改个单元格要同步至少三处地方——内部数据、Canvas重绘、底部Sheet标签页。功能叠加多了以后,公式引擎、撤销栈、条件格式这些模块互相拉扯,想为其加入协同能力基本等于重新设计数据流。
更现实的问题是,Luckysheet的数据结构保留了大量与渲染相关的冗余字段,格式信息散落在多个对象里。导入xlsx导出xlsx靠的是另外一套插件去转换,中间任何一处字段对不上,就会出现“明明文件里没问题,页面显示错乱”的尴尬现象。对项目负责人来说,接手这种代码去做二次开发,内心是绝望的。
2.2 Univer的新架构:核心、渲染、UI三层彻底分离
Univer的做法是把职责拆干净。最底层是@univerjs/core,它只负责数据结构、命令、公式和状态管理,完全不知道什么是DOM。渲染层负责把数据画出来,当前主要基于Canvas,Univer把渲染也做成了独立引擎,理论上未来可以换WebGL或者干脆在原生端自绘。UI层负责工具栏、右键菜单、对话框这些与编辑器外观相关的部分。
这三层分离带来的直接好处是,你操作表格的时候不依赖页面上的具体UI控件。你在React、Vue、纯TS环境里都能跑。想要去掉某个按钮或者换成自己的顶部工具条,根本不需要去改源码里的组件,因为UI层本质上只是订阅Univer状态变化的一个客户端,替换它属于正常扩展能力。
2.3 统一数据模型的野心:Sheet、Doc、Slide共用一套基础设施
Univer内部把表格、文档、幻灯片统称为“单元”(Unit)。每个单元在核心层里都是一个可编辑的数据块,有自己形状和内容,但对外暴露的操作逻辑统一走命令系统。这个抽象让一个复杂场景变得可行:比如在文档里嵌入一段可编辑表格,或者在表格的单元格里插入一段富文本,这些跨类型的交互在统一模型下不再需要转成另外一套语法。
对实际开发的影响是:你学会操作Univer的表格单元,再去碰它的文档单元、幻灯片单元,概念是相通的。这个学习成本比切换三套完全不同的SDK低得多。而且由于数据模型统一,未来同一个工作簿导出成文档也好,把文档的一部分表格结构化读取也好,都有实现的路径。
3. 深入原理:命令、不可变数据和Yjs协同是如何打成一套的
Univer在工程上最核心的三个设计是命令系统、不可变数据、CRDT协同。三者不是各干各的,而是咬合在一起来支撑“一个可编辑文档占多人在线编辑”这个复杂场景。
3.1 命令系统:一切修改都走同一扇门
Univer里,不管是用户在界面上敲了个字符,还是你用API批量填充一片区域,最终都会转化成命令。命令进入统一入口后,会被拆解成一组更细的“行为变更”(Mutation)。这些变更才是真正去修改数据的执行者。
这样做最大的好处是撤销和重做变得非常简单。同一个命令在发起时自带undo逻辑,Univer只需要把每次变更的逆操作挂进历史栈,用户按Ctrl+Z就能一步步撤销。传统的表格组件之所以难以实现稳定撤销,就是因为每个功能都自己改状态,撤销时根本不知道该从哪里恢复到哪个状态。
命令机制还让“记录操作路径”这件事变得可行。你可以在命令执行前后做拦截,对用户操作进行审计、二次确认、自动补全。比如某些敏感列不允许修改,在命令进入核心之前就能被拦截掉并返回错误提示,而不是先改完数据再去校验错误。
3.2 不可变数据:每次编辑都是新状态
Univer的数据模型改起来像“给Excel拍快照”,但这里的快照不是整份文件的复制。它内部使用不可变数据的思想,每次变更都会生成新对象,旧对象仍然保留在历史中。因为旧状态没有被破坏,撤销时可以精准恢复到某个节点,数据竞态问题也被减少。
这套处理方式放在单人编辑里就是一种工程洁癖,放到协同环境里就变成刚需。当一个远端用户的变更通过WebSocket传入本地,另一个用户本地也在输入时必须保证两边数据不互相污染。不可变数据的天然隔离性让合并变更的安全边界清晰起来。
3.3 协同编辑:为什么Univer选CRDT而不是传统OT
在线协同编辑最经典的技术路线是OT(操作变换)。OT在中国互联网产品里应用非常广泛,很多在线文档产品都基于OT实现,但它依赖一个中心服务器来保证操作的顺序和变换。一旦服务器压力上来或者网络断连,复杂操作的合并就非常烧脑。
CRDT(无冲突复制数据类型)是另一条路线,更激进,它允许每个节点在本地直接修改数据,然后通过同步机制把状态传递出去,数学上保证所有节点最终收敛到同一个结果。Univer选择基于Yjs这套CRDT方案来做协同,我认为是符合当前Web协作产品趋势的。稍微有点复杂的是,CRDT虽然解决了最终一致性问题,但如果你不理解它的合并方式,做一些交互时会出现“光标跳来跳去”或者“稍后几秒才看到别人的字”的感觉。这需要应用层在处理远端更新时做UI提示,给用户“在协同”的反馈。
Univer把协同插件做成独立模块,社区版本里你可以看到底层用的就是Yjs思路。如果你想在生产环境做协同,理论上可以自己接WebSocket或WebRTC,打通服务端存储。企业版会把这部分封装得更直接,但理解底层是Yjs这层设计,对接起来就不会心里没底。
4. 实践:用一条Preset命令把Univer表格工程跑起来
理论讲再多,不如把代码跑一遍。我用Vite搭了一个最简单的Univer表格Demo,整个过程大概就是安装预设、创建实例、渲染容器三步。当前Univer主推的快速接入方式是“预设包”,你不用像老版本那样手动注册几十个插件,几个预设函数就搞定。
4.1 初始化工程并安装依赖
先创建一个干净的Vite项目,用TypeScript模板:
npm create vite@latest univer-demo -- --template vanilla-ts cd univer-demo npm install npm install @univerjs/presets这里只安装了一个包,因为@univerjs/presets内部把核心包、表格UI、公示引擎、条件格式等主要模块都组织好了。你不需要自己去逐个安装@univerjs/core、@univerjs/sheets那一堆依赖,这也是预设包最大的意义。
4.2 写启动代码
在src/main.ts里写入:
import { Univer, UniverSheetsCorePreset, UniverSheetsPreset } from '@univerjs/presets'; import { UniverSheet } from '@univerjs/presets/sheets'; const univer = new Univer({ container: document.getElementById('app')!, presets: [ UniverSheetsCorePreset(), UniverSheetsPreset(), ], }); univer.createUnit(UniverSheet, {});UniverSheetsCorePreset提供的是表格核心能力,包括工作簿、工作表、选区、单元格操作;UniverSheetsPreset把我们的工具栏、公式栏、状态栏这些UI带过来。createUnit传入UniverSheet,意思是创建一个表格类型的单元。一个页面里可以创建多个实例,也可以创建文档、幻灯片单元。
然后是index.html的容器:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <title>Univer Demo</title> <style> html, body, #app { width: 100%; height: 100%; margin: 0; padding: 0; } </style> </head> <body> <div id="app"></div> <script type="module" src="/src/main.ts"></script> </body> </html>最后执行:
npm run dev浏览器打开控制台给出的地址,应该就能看到一个完整的表格界面,可以输入数据、做基本的格式调整、用公式求和。整个接入过程不到十分钟,这是Univer在工程化上做得确实到位的地方,没有露出难啃的骨头。
4.3 初始化时塞入业务数据
空表格没意义,我们可以在创建单元时传初始化数据。Univer数据结构大体上是工作簿下挂多个工作表,每个工作表里用行列索引对应cellData存放单元格信息。按你安装版本的实际类型定义来写即可,大致结构是这样的:
univer.createUnit(UniverSheet, { name: '销售看板', sheets: [ { name: '7月数据', rowCount: 200, columnCount: 30, cellData: { 0: { 0: { v: '产品', t: 1 }, 1: { v: '销量', t: 1 }, }, 1: { 0: { v: 'A产品', t: 1 }, 1: { v: 128, t: 2 }, }, }, }, ], });v是单元格的值,t是数据类型标记,比如字符串是1、数字是2。这个数据结构和Unreal内部序列化格式保持一致,所以当你保存一个Univer文件时,本质上就是把这套JSON存进去,读回来时就能还原整个工作簿状态。
4.4 通过FUniver操作单元格
跑起来只是第一步,实际开发里你需要通过代码批量操作数据。Univer提供了一套门面API,让你不必直接面对底层命令,它就是FUniver。
import { FUniver } from '@univerjs/core'; const api = FUniver.newAPI(univer); const sheet = api.getActiveWorkbook()?.getActiveSheet(); sheet?.getRange('A1').setValue('销售额'); sheet?.getRange('B1:C5').setValues([ [100, 200], [150, 250], [200, 300], [250, 350], ]); sheet?.getRange('D1').setFormula('SUM(B1:C5)');FUniver这套API很符合前端直觉:拿到工作簿、拿到当前工作表、定位区域、操作数据。你不用关心内部构造命令、入栈撤销、触发重算这些细节。同时它也是Univer官方推荐的编程入口,内部会帮你把操作封装成正确的命令流程。如果需要在命令层面做精细化控制,再回去用命令服务,大部分业务场景先用FUniver就够了。
4.5 调整UI和画布布局
默认Univer渲染出来的工具栏非常完整,但很多项目只需要保留核心工具栏。你可以通过预设的配置开关或者后续替换UI层来控制。简单需求里,可以用CSS去隐藏掉不需要的DOM元素,但追求干净的话,最好按官方文档去定制工具栏组件。
除了工具栏,Excel式网格的布局参数也可以调,比如隐藏行标列标、限制缩放比例、隐藏网格线。这些配置分散在不同插件里,需要找各区插件的options,比如:
UniverSheetsPreset({ gridlines: true, showRowHeader: false, showColumnHeader: false, });注意不同预设版本参数名可能变化,按你的TypeScript智能提示去填是最稳的。不要背参数名,版本一变所有背过的都有可能失效。
5. 拿到表格之后怎么编程:FUniver API和核心数据结构速览
很多项目做到“能打开能编辑”就以为结束了,真正麻烦的是让表格和业务数据联动。这里需要理清Univer里的数据模型,至少要明白Workbook、Worksheet、Range、Cell这四层关系。
5.1 核心对象关系
Univer的数据模型层级并不复杂:
| 概念 | 说明 | 前端API里的拿法 |
|---|---|---|
| Workbook | 工作簿,相当于Excel文件 | api.getActiveWorkbook() |
| Worksheet(sheet) | 工作表,工作簿里的单个页签 | workbook.getActiveSheet()或workbook.getSheetByName() |
| Range | 单元格区域,比如A1:B5 | sheet.getRange('A1:B5') |
| Cell | 单个单元格,包含值、样式、公式 | range.getValues()返回二维数组 |
我把这个层级叫做文件-页签-区域-单元格,和Excel界的直觉是一致的。前端新人容易误解的地方是Range不仅代表连续的矩形区域,多个不连续选区也会被表达成Range集合。在做合并单元格、批量设置样式时要注意你拿到的Range可能是一个选区列表,遍历方式与单个区域不同。
5.2 单元格值与数据类型
Univer判断值类型时会区分字符串、数字、布尔、日期、公式、错误值。同样一个数字128,如果存成t: 2就是数字类型,参与求和;如果存成t: 1就是文本,Excel计算时不会把它当数字算。这个细节最坑人,外部系统导入数据时经常把数字变成了“看起来像数字的字符串”,导致公式结果全错。
用FUniver写值时建议明确类型:
sheet.getRange('A1').setValue({ v: 42, t: 2 });如果只传一个裸值,Univer会根据值的类型猜测数据类型。大多数时候能猜对,但遇到纯数字字符串、日期字符串、公式字符串时,最好显式指定。
5.3 监听单元格变化
Univer把状态变更挂在命令系统上,所以你监听的不是DOM事件,而是命令变更。简单做法是订阅核心的变更事件流:
univer.getCommandService().onCommandExecuted((command) => { console.log(command); });这样能捕获用户和程序发起的所有命令。如果你想知道某个单元格变化了,可以在命令执行后读取当前值,也可以针对set-range-values这类关键命令做拦截。生产环境里更建议直接监听状态变化再对比前后值,避免对每个命令做硬编码判断。
5.4 读取整个工作表的数据
遍历单元格最忌讳的做法是用getValues()把整个rowCount * columnCount矩阵拉下来。一个大表几十万格,浏览器会直接卡死。Univer内部是稀疏存储,不会把所有空单元格都展开,但你用API读整个表时,它有可能会把范围内所有格子都返回。
最佳实践是先获取实际有值的区域边界。你可以遍历所有行和列找到非空的range,或者用getRange()限定业务区域读取。实时性要求不高的场景,甚至建议后端把需要的数据先聚合好,再一次性灌给前端。
6. 做二次开发时躲不掉的坑:字体、性能、样式与导入导出
Univer整体架构质量不错,但真正二次开发时不会事事顺利。下面这些坑是我跑Demo和做集成时遇到过的,写出来希望大家别重复踩。
6.1 Canvas渲染的字体问题
Univer用Canvas画单元格文字,这意味着它依赖Canvas的字体测量能力。如果你的应用用了自定义字体,要在表格区域渲染之前确保该字体已加载完成。否则Univer在采用内部字体绘制时会忽大忽小,等字体加载完又突然闪一下,非常影响体验。
解决思路通常是用document.fonts.ready等待字体加载,然后调用Univer的重新渲染方法。千万不要懒到刷新页面来规避,用户那边重新打开网页的成本太高。
6.2 预设包体积和按需加载
用@univerjs/presets启动非常快,但代价是包体积可能偏大。如果你只做一个简单表格,不需要全部功能,最好使用底层API手动组合所需插件,别把所有预设都塞进去。Vite和webpack都能做tree-shaking,但也要确认没在入口文件中强引用预设里所有导出。
还可以把Univer的初始化单独抽成一个异步模块,用户点开“在线表格”按钮时才加载对应JS,避免影响到主应用的秒开速度。
6.3 样式覆盖要找准CSS变量层级
Univer的界面主题依赖一套CSS变量体系,你可以通过在根节点覆盖变量来改颜色。但实际操作里它有很多特定状态的样式是写在局部class里,直接覆盖全局变量不一定生效。
稳妥的方法是用浏览器开发者工具去定位具体class名,再加上一个足够具体的优先级限定写覆盖样式。同时要注意,Univer升级版本时,class名可能变化,不要写太多依赖私有class的强覆盖代码。
6.4 xlsx导入导出不是开箱即用
Univer社区版不是启动就能直接导入Excel xlsx文件的。它需要引入额外的导入导出插件,而插件在不同版本里的加载方式也会变化。我自己测试时遇到过导入一个带复杂图表和大数据透视表的Excel文件,内容能显示但部分公式和单元格样式丢失的情况。这类问题的根源是Univer内部数据模型和xlsx文件规范存在差异,转换必然会有损耗。
生产项目里强烈建议在接入前梳理你要支持哪些Excel特性:常见的单元格值、公式、合并单元格、条件格式这几样,Univer支持度可以接受。但复杂的图表联动、宏、切片器、数据透视表就不要指望完整复刻了。
6.5 协同编辑的实时更新与权限控制
协同不是光靠引入Yjs就完事。两个用户同时改一个单元格时,CRDT会保证最终一致,但业务上你可能需要锁定某些行或某几列,甚至限制某些用户只读。Univer的权限控制不会自动提供,它需要你把权限模型放在命令拦截层去判断。
协同服务端还需要处理“历史版本保存”的问题。Yjs解决了并发合并,但数据库里不能只存一个最终状态,你得定期把Yjs的状态更新保存下来,否则从历史中恢复某个时间点会变成一头雾水。这块要自己设计持久化方案。
7. 选型参考:Univer和OnlyOffice、Luckysheet、Handsontable怎么取舍
最后聊聊选型这件事。很多人一上来就问Univer能不能替代Excel,这个视角其实问错了。它的强项是嵌入、编程控制、协同底层、跨平台渲染,而不是在一百个功能细节上和桌面Excel硬碰。
拿四个主流方案放一起对比:
| 方案 | 开源协议 | 产品定位 | 二次开发难度 | 协同能力 | 适用场景 |
|---|---|---|---|---|---|
| Univer | Apache 2.0(核心) | 在线办公基础设施,包含表格/文档/幻灯片 | 中等,但有预设包兜底 | 有底层CRDT支持,需要自己接服务端 | 想把表格嵌入自研产品,前端团队有掌控力 |
| OnlyOffice | AGPL(需注意商用约束) | 完整文档服务套件 | 部署重,前端定制空间小 | 自带完整协同 | 企业级文档中台,愿意接受较重部署 |
| Luckysheet | 开源但维护放缓 | 在线表格组件 | 高,代码耦合历史问题多 | 几乎没有现成方案 | 旧系统回归或者学习参考 |
| Handsontable | 商业授权 | 数据表格编辑组件 | 低到中 | 需要自己扩展 | 纯表格录入、数据管理,不需要公式体系 |
如果你还需要公式引擎、多种工作表模型、未来做在线文档路线,Univer的现代技术栈优势会明显。如果只是需要在一张网页里快速录入表格数据,不上公式,也不做多Sheet联动,那Handsontable这类轻量组件可能更省事。
团队技术储备也是关键因素。Univer是TypeScript项目,面向现代工程化,它要求开发者愿意去读源码,能理解命令和状态的含义。如果你的团队只接受“拖一个组件到页面上就不再管”,那么基于Univer做二次开发的成本会被低估。
我个人在实际项目中的体会是,Univer最理想的切入点不是那种“把Excel完全搬到网页上”的重型需求,而是把表格能力作为产品模块输出。比如运营后台里给用户一个可编辑的预算表,数据分析平台里生成可下钻的透视数据,项目管理工具里嵌入一个动态日程矩阵。这些场景不需要Excel的全部功能,但需要可控、可编程、可扩展,而Univer给到的恰恰是一条不太拥挤的路。最后再分享一个建议:动手之前,先把你业务里必须支持的Excel特性列成清单,拿着清单去Univer的Demo里逐一验证,比看任何评测都靠谱。表格这种项目,别人说“能”和“不能”都不算数,你得让它跑在你们自己的数据上,看到结果才算数。