最近我接手了一个内部项目需求听上去很简单在网页里给运营团队提供一张能编辑、能算数、能导出 Excel 的表格。但真正做起来才发现这种需求最折磨人。普通 table 组件只能看不能算软件即服务的在线表格又没法嵌进自己的后台。翻了一圈开源社区之后我在 GitHub 上看到了 Univer —— 一个基于 Web 的开源办公套件然后整个技术选型思路都被它带偏了准确说是带对了。Univer 底层用 TypeScript 开发提供表格、文档、演示文稿三大核心能力其中最成熟、用得最多的是电子表格模块 Univer Sheets。它能像普通组件一样嵌入任意 Web 应用支持复杂公式、样式渲染、Excel 导入导出底层用 Canvas 自绘渲染数据量大时不会像 DOM 表格那样卡到怀疑人生。这篇文章我会从设计思路、选型对比、集成步骤、核心功能到踩坑记录把 Univer 这套东西完整拆一遍。如果你也在做类 Excel 的Web 场景这应该正是你需要的。1. 拆解 Univer 的设计思路它到底在解决 Web 表格的什么痛点1.1 传统 Web 表格方案的死穴在遇到 Univer 之前我试过好几条路。第一条路是纯展示表格后端把数据算好前端用 Ant Design Table 或者自研 Grid 渲染查询条件一变就重新拉数据重新画。这个方案看数据没问题但只要运营同学问一句能不能帮我在页面上直接改一个数字、重新算一下合计需求就变味了。它只能做到看做不到改和算。第二条路是导出 Excel让用户下载后在本地改完再传回来。这条路的体验割裂感极其严重用户每改一个单元格都会产生一个新文件版本再传回服务器时你还得解析、校验、合并稍微复杂点的业务逻辑光对齐字段就能耗掉一整天。而且 Excel 文件本身是 zip 压缩的 XML要在服务端解析修改非常痛苦。第三条路是嵌入在线表格服务比如直接用 Google Sheets 或微软的 Office 网页版。这个方案功能确实全公式、图表、协同都有但问题在于国内访问不稳定且你很难把它的工具栏、样式、交互按自己的产品风格定制。更现实的问题是数据主权和权限很多企业内部系统根本不允许把业务数据放到第三方平台上。这三条路走下来会发现一个核心矛盾你要的不是表格组件而是Excel 的一小块能力——具备完整的网格编辑、单元格寻址、公式计算、样式渲染、数据序列化能力同时又能像普通前端组件一样被自由集成。这就是 Univer 的定位。1.2 Univer 的架构设计不只是表格是套件底座Univer 不是从 Grid 组件往上加功能做出来的而是从底层架构上把办公套件这件事拆开了。它整体分为三层第一层是核心数据层也就是 Univer Core。它定义了 Workbook、Worksheet、CellData、Range 等数据模型负责维护单元格值、公式、样式、条件格式、数据透视这一整套结构。这一层完全不依赖 DOM 和 Canvas所以你可以在 Node 环境里直接创建、修改、序列化一个工作簿对象。第二层是渲染引擎层负责把数据画出来。Univer 使用 Canvas 自绘而非 DOM 单元格这是一项关键选择。想象一下如果用 DOM 去渲染一万个单元格浏览器至少要维护一万个节点滚动、选中、编辑时的事件处理和重排开销非常可怕。而 Canvas 是一块画布Univer 自己管理可见区域、命中测试和重绘调度就算整个工作簿有上百万单元格实际需要绘制的也只有视口内那几十上百个。这跟游戏引擎的渲染策略是同一个思路。第三层是插件与 UI 层。Univer 把工具栏、右键菜单、底部工作表标签栏、公式栏这些界面元素全部做成了可插拔的插件。默认的 UniverSheetsUIPlugin 会给你一套完整的表格界面但如果你只想留一个网格编辑区域不想要工具栏和右键菜单完全可以不注册那一部分插件或者自定义一套 UI 壳子。这种架构带来的直接好处是数据与渲染分离、渲染与 UI 分离。我可以在后台用一个 Headless 的 Univer 实例算公式、改数据然后把最终结果输出成 JSON 存进数据库前端再挂载一个带界面的 Univer 实例给用户操作。同一个数据模型两种用法这一下就把服务端计算和客户端交互之间那座桥搭起来了。2. 选型对比Univer、Luckysheet、Handsontable 与 SpreadJS 怎么选2.1 市面上主流方案的多维对比在确定用 Univer 之前我把市面上的方案过了一遍主要对手是 Luckysheet、Handsontable、SpreadJS 和 OnlyOffice。下面这张表是我整理过的对比结果维度UniverLuckysheetHandsontableSpreadJSOnlyOffice开源协议Apache 2.0MIT已停更维护非商用免费/商业收费商业授权AGPL/商业渲染方式Canvas 自绘Canvas 自绘DOMCanvasCanvas公式引擎内置300 函数支持自定义内置基础公式无完整公式引擎内置完整内置完整协同编辑原生支持架构需二次开发不支持付费模块支持技术栈TypeScript框架无关原生 JS/jQuery框架无关TypeScript较重Excel 导入导出支持支持不完整很好很好可定制性极高插件化一般高一般低文档与社区活跃度高持续迭代中基本停滞成熟稳定商业支持社区大这里我需要多说一句 Luckysheet。很多人可能知道 Luckysheet它是最早一批把类 Excel 体验带到浏览器里的开源项目UI 效果确实不错。但它的代码架构是早期 jQuery 时代的思路数据层和渲染层耦合很深想加一个自定义公式、想改右键菜单、想做协同都得在源码里挖维护成本很高。Univer 其实可以视为 Luckysheet 作者团队沉淀几年后推出的重构版——架构更现代、模块更清晰、扩展性更强某种意义上继承并升级了整个思路。Handsontable 是另一个常见选项。它的优点是纯前端实现、格子交互流畅、文档完善但它本质上是一个高级 Grid不是表格应用。公式引擎、多 sheet 管理、Excel 导入导出的完整度都不如 Univer如果你只需要一个可编辑的表格组件而不需要公式计算可以考虑它。SpreadJS 功能确实强Excel 兼容性做得非常细公式、图表、数据透视都有。但它是商业产品一套授权费用不低而且对需求方来说这是一个黑盒你们团队内部要做深度定制时能改的只有 API 暴露出来的那部分底层渲染和算法碰不到。Univer 是 Apache 2.0 协议可以商用也可以改源码这一点对很多中小团队来说是决定性的。2.2 Univer 的几项核心能力拆解选型不能只看协议和名气关键功能也要落实到位。我重点验证了 Univer 的这几个能力公式引擎是第一个重点。Univer 内置的公式引擎支持 300 多个常用函数包括 SUM、IF、VLOOKUP、FILTER、XLOOKUP 这类Excel 用户每天都会用的函数。关键是它还支持自定义函数注册比如我想在表格里直接算毛利率 (售价 - 成本) / 售价可以注册一个自定义函数 CUSTOM_MARGIN让用户像用普通函数一样调用它。这个能力在很多竞品里是收费的或者根本不提供。第二个重点是样式系统。Univer 支持单元格级别的字体、颜色、边框、背景色、对齐方式、数字格式比如百分比、货币、日期也支持条件格式和合并单元格。渲染层会统一处理这些样式指令并把它们转换到 Canvas 绘制上。对于业务系统来说能判断一个单元格是否是橙色背景、是否加粗往往比存储一百个字段更有用。第三个重点是数据序列化。Univer 的数据结构是干净、可遍历的 JSON你可以随时通过 API 读取某个工作簿的所有单元格值、样式、公式存进自己的数据库也可以把库里的数据重新组装成一个工作簿塞给 Univer 渲染。没有私有二进制格式没有强制数据库依赖数据是你自己的这一点在对接内部系统时太重要了。2.3 为什么最终放弃自研表格的念头我也短暂考虑过自研几万行数据自己用 Canvas 画网格自己实现选中、编辑、键盘导航、公式解析。仔细盘算之后发现这根本不是一个数量级的工程量。Excel 之所以复杂不只是因为它能显示表格而是它背后有完整的计算模型单元格地址引用、公式依赖树、循环引用检测、区域选择、填充序列、撤销重做。就算只做其中 20% 的精度也需要一个全职团队数月的投入而且做出来的东西大概率还是有一堆边界问题。把 Univer 嵌进来相当于把类 Excel 运行环境这个子问题整个外包给了开源社区团队只需要关注自己业务那层——表格里的数据怎么来、改动怎么同步、导出后怎么流转。这种边界划分才是技术上最划算的选择。3. 从零集成 Univer Sheet一个能跑起来的表格页面3.1 工程准备与依赖安装集成 Univer 之前第一件事是把 npm 包拿对。Univer 从 0.2 版本之后做了模块化拆包不再是下载一个超大 JS 文件就完事而是区分了核心包、渲染引擎、UI 框架、表格插件、表格 UI 插件、公式引擎等多个模块。以 Vite TypeScript 项目为例需要安装的依赖大致如下npm install univerjs/core univerjs/engine-render univerjs/engine-formula npm install univerjs/sheets univerjs/sheets-ui univerjs/ui这里特别提醒一下Univer 不同版本的 API 有调整安装时尽量把版本号统一住最好使用官方文档里配套的版本组合不要图新单独升级某一个包。我见过不少朋友只升了univerjs/core没升univerjs/sheets-ui结果两个包的数据结构对不上页面直接白屏。如果时间紧直接在 package.json 里把几个包锁成同一个版本号是最省事的。3.2 创建基础表格安装完依赖创建一个最简单的 Univer 工作簿只需要几十行代码。下面这段是我在项目里用的初始化方式import { LocaleType, Univer } from univerjs/core; import { UniverRenderEnginePlugin } from univerjs/engine-render; import { UniverFormulaEnginePlugin } from univerjs/engine-formula; import { UniverSheetsPlugin } from univerjs/sheets; import { UniverSheetsUIPlugin } from univerjs/sheets-ui; import { UniverUIPlugin } from univerjs/ui; const univer new Univer({ locale: LocaleType.ZH_CN }); univer.registerPlugin(UniverRenderEnginePlugin); univer.registerPlugin(UniverFormulaEnginePlugin); univer.registerPlugin(UniverUIPlugin, { container: app }); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsUIPlugin); univer.createUnit(UniverSheetsPlugin, { name: 业务报表, sheets: { sheet1: { name: 销售数据, rowCount: 100, columnCount: 20, cellData: { A1: { v: 品类 }, B1: { v: 销售额 }, C1: { v: 成本 }, D1: { v: 毛利率, f: (B2-C2)/B2 }, A2: { v: 数码 }, B2: { v: 120000 }, C2: { v: 85000 } } } } });这段代码做的事情看起来多其实拆开就四步第一步创建Univer实例相当于启动一个办公套件的运行时LocaleType.ZH_CN把界面文案切成中文。第二步按需注册插件UniverRenderEnginePlugin是 Canvas 渲染引擎UniverFormulaEnginePlugin是公式计算引擎UniverUIPlugin是 UI 框架容器UniverSheetsPlugin是表格数据核心UniverSheetsUIPlugin才会把工具栏、右键菜单和 sheet 标签渲染出来。第三步调用createUnit创建实际的工作簿也就是一个 workbook 对象。第四步在sheets里定义 sheet 以及初始的cellData。cellData的格式需要特别说明它是一个以行号为键、以列为键的二级 Map列可以直接用 A、B、C 这种字母也可以用 0、1、2 这种数字下标两者等价。每个单元格对象里v是显示值/存储值f是公式字符串。如果你设置了f这个单元格会自动参与公式计算用户点击它能像 Excel 一样看到计算过程。注意公式字符串里的逗号和引号不要用中文的这里踩坑很常见。3.3 加载真实业务数据与导出下载初始化示例里写死的cellData只是开胃菜真实业务中数据肯定来自后端。我的做法是后端返回一个 JSON 数组每个元素代表一行记录前端拿到后组装成cellData再交给 Univer。interface RowData { category: string; sales: number; cost: number; } const rows: RowData[] await fetch(/api/rows).then(res res.json()); const cellData: Recordstring, Recordstring, { v: string | number } {}; rows.forEach((row, index) { const rowNumber index 2; // 第一行放表头 cellData[String(rowNumber)] { A: { v: row.category }, B: { v: row.sales }, C: { v: row.cost }, }; }); // 把组装好的 cellData 塞进去 workbook.getSheetBySheetId(sheet1)?.getCellDataModel()?.setCellData(cellData);这段逻辑的关键是行号对齐Excel 的单元格从 1 开始计数而数组从 0 开始所以第一行记录要放到工作表的第二行rowNumber 2。如果你习惯用 0 下标会把表头直接覆盖掉。导出部分我建议走 JSON 中转先用 API 把整个工作簿的数据读出来转成自己的格式再用 SheetJS 这类成熟库生成 Excel 文件。Univer 自己提供了导入导出能力但我更倾向于自己做导出层因为这样能顺便完成数据清洗和格式整形避免把界面上的临时样式也带进正式交付文件里。核心代码如下import * as XLSX from xlsx; const workbook univer.getActiveWorkbook(); const snapshot workbook.getSnapshot(); const rows: unknown[][] []; // 遍历 sheet 的 cellData读取每个单元格的值 const sheet snapshot.sheets[Object.keys(snapshot.sheets)[0]]; const cellData sheet.cellData as Recordstring, Recordstring, { v: unknown }; Object.keys(cellData).forEach((rowKey) { const rowIndex Number(rowKey) - 1; const row: unknown[] []; Object.keys(cellData[rowKey]).forEach((colKey) { const colIndex colKey.charCodeAt(0) - 65; row[colIndex] cellData[rowKey][colKey].v; }); rows[rowIndex] row; }); const ws XLSX.utils.aoa_to_sheet(rows); const wb XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 数据); XLSX.writeFile(wb, export.xlsx);这段代码把 Univer 的cellData转换成二维数组再用 SheetJS 生成 Excel。注意列号转数字下标时用了charCodeAt(0) - 65这是一个简化处理只支持 A 到 Z 的列。如果列数超过 26需要另外做进制转换不过对绝大多数内部系统来说够用了。4. 公式、自定义功能与数据联动把 Univer 用活4.1 自定义公式函数让表格懂你的业务语言表格组件已经嵌进去了接下来就要解决业务公式问题了。举一个实际例子我的项目里运营人员要经常算一个毛利率贡献指数公式本身不复杂但每次都要手动在 Excel 里拉公式很麻烦。如果能把这种业务计算注册成一个公式函数用户直接在单元格里敲MARGIN_CONTRIBUTION(B2, C2)就能出结果体验完全不一样。Univer 的公式引擎提供了函数注册机制自定义函数需要实现一个标准签名接收参数数组返回计算结果。下面是一个自定义公式的简化实现import { FunctionType, registerFunction } from univerjs/engine-formula; registerFunction({ id: MARGIN_CONTRIBUTION, functionType: FunctionType.USER, calculate: (sales: number, cost: number, weight 1) { if (!sales || !cost) return 0; const margin (sales - cost) / sales; return margin * weight; } });注册完之后用户在单元格里输入MARGIN_CONTRIBUTION(120000, 85000, 0.8)Univer 的公式引擎会识别函数 id、解析参数、调用calculate方法然后把结果写回单元格。自定义函数做得好业务人员基本感觉不到这是系统算的还是我自己拉的 Excel 公式他们只会觉得这系统好用。这里要注意一个细节registerFunction必须要在 Univer 实例创建之前注册至少在公式引擎实例使用之前完成。如果你在页面已经渲染完成后才通过 console 敲代码注册公式引擎可能已经加载过一次函数表新函数不会同步进去。最稳妥的做法是放在初始化文件的最顶部和 import 语句写在一起。4.2 常用配置项从默认表格到业务组件Univer 默认给你的是一整套完整 Excel 界面但对于业务系统来说往往不需要那么全。比如后台管理页面里我只想让用户编辑特定区域、不想让他们删行插列最好还能隐藏公式栏和网格线。Univer 的 UI 插件暴露了比较丰富的配置项我常用的几个如下container必填指定渲染挂载的 DOM 节点toolbar控制顶部工具栏是否显示可以传false或自定义配置对象showFormulaBar控制公式栏是否显示allowUserToAddSheet控制是否允许用户手动新增 sheet 标签sheetOrder/sheetTabColor控制初始 sheet 数量和标签颜色contextMenu决定右键菜单是否渲染示例里初始化UniverUIPlugin时可以传第二个参数univer.registerPlugin(UniverUIPlugin, { container: app, toolbar: false, showFormulaBar: false });这样页面里就只剩一个干干净净的网格了运营人员只能编辑单元格不会被工具栏上那些看不懂的按钮干扰。我实际做下来把不需要的 UI 收起来之后不仅视觉清爽用户误操作的概率也低了很多。很多系统上线初期反馈表格 太复杂多半就是没有做好权限和 UI 裁剪。4.3 数据联动监听单元格变化并同步到后端表格计算是一回事把用户编辑结果保存下来又是另一回事。Univer 提供了一套事件系统可以监听单元格的修改。监听方式很简单在Univer实例上通过命令服务订阅事件当用户编辑单元格后触发对应的 command我们在回调里拿到最新的单元格信息。import { CommandType } from univerjs/core; univer.getCommandService().onCommandExecuted((command) { if (command.type CommandType.MUTATION) { // 过滤出修改单元格的 mutation 命令 const mutations command.mutations ?? []; mutations.forEach((mutation: any) { if (mutation.id mutation.id.includes(set-range-values)) { const params mutation.params as { range: { startRow: number; startColumn: number; endRow: number; endColumn: number }; value: string; }; console.log(单元格被修改了, params); } }); } });拿到修改范围之后建议做防抖 批量提交用户连续编辑多个单元格时每次都触发后端请求会把接口打爆。我一般是滚动收集 2 秒内的变更折叠成一次批量保存请求等后端返回成功后再在界面上做一次轻量提示。这套逻辑与其说是 Univer 的功能不如说是 Web 应用通用惯例但跟 Univer 的事件模型结合得很顺。需要注意命令事件的 API 在不同版本里有差异上面代码里params的字段名可能需要按你实际安装的版本调整。调试时把command对象完整打出来看一遍是最快的方法没必要背 API一切都以你控制台里的实际结构为准。5. 实战踩坑记录集成 Univer 时最常见的 5 个问题5.1 版本不一致导致白屏或样式失灵这是我遇到最痛的问题也是社区提问最多的问题。Univer 拆包多、版本迭代快如果你把univerjs/core升到 v0.x 的最新版但univerjs/ui还停留在旧版本两个包里的类型定义和数据模型很可能不兼容表现出来就是页面白屏、控制台报xxx is not a function或者工具栏渲染出来但点击无响应。解决方案有两个一是锁版本把 Univer 相关包的版本号填成完全相同的版本比如0.6.5就全部0.6.5二是用 npm 的 overrides 功能强行统一间接依赖版本。遇到白屏先去看浏览器控制台的报错栈九成是插件注册顺序不对或者某个包没装全。5.2 中文输入法的组合态问题表格编辑绕不开中文输入法。在 Univer 的单元格里输入中文时如果开启了实时保存输入法还在组合拼音阶段比如你打了 zhong还在选 中Univer 的单元格编辑事件就可能提前触发把未确认的拼音字符串当成最终值读走。这个问题不只在 Univer 里有所有类 Excel 的 Web 组件都有。我的处理方案是在提交数据前加一层输入法安全检查监听compositionstart和compositionend事件当正在组合输入时不触发提交逻辑。具体做法是在单元格激活时给编辑器容器挂这两个事件等compositionend之后再读取单元格值。这个细节如果没处理用户会经常反馈我字还没打完系统就把半个字保存了非常影响信任感。5.3 大数据量下的性能优化按需插件和虚拟滚动Univer 的 Canvas 渲染性能已经比 DOM 方案好很多但也不能盲目地往里塞数据。我曾经试着直接塞了 20 万行、50 列的数据首帧渲染耗时明显上升滚动和编辑也有一点迟滞。后来做了三个调整第一个调整是按需注册插件不用的功能坚决不引。如果业务只需要表格就不要注册文档和幻灯片相关插件减少初始化计算量。第二个调整是合理拆分 sheet把 20 万行拆成 4 个 sheet、每个 5 万行而不是塞进一个 sheet 里。用户看数据时可以通过筛选或切换 sheet 来定位渲染压力小得多。第三个调整是关闭不必要的动画和实时计算。公式引擎在每次变更时都会重算依赖单元格如果你的表格里有大量SUM这种依赖公式建议设置手动计算模式用户点击计算按钮后再执行公式引擎而不是每敲一个数字就全表重算一遍。5.4 焦点冲突弹窗与右键菜单的抢焦点表格组件在页面上会不断捕获键盘事件和焦点如果你的页面里还有弹窗、下拉框、聊天面板很容易出现焦点打架用户在弹窗里按 Tab 键结果焦点被表格抢走或者右键弹出 Univer 的菜单后点击页面其他地方菜单不消失。解决办法是在打开业务弹窗时主动调用 Univer 的blur或禁用编辑状态弹窗关闭后再恢复。如果只是简单遮挡可以在弹窗外层加一层透明遮罩让表格组件的鼠标事件被遮罩拦截这样右键事件不会穿透到表格。5.5 协同编辑的落地方式Univer 在架构层面支持协同但真正要落地到业务里还是需要一套后端协同服务和 WebSocket 通信。如果你只是需要在两个用户之间做谁改了什么的同步可以先走后端轮询 版本号对比的简单方案每次编辑后把范围变更记录提交到服务器服务器推送给其他客户端客户端再用 Univer 的 command 重放这些变更。这种伪协同在十人以下的小团队场景里足够用也不需要在初期就引入复杂的 CRDT 算法。如果业务规模大了再考虑接入 Univer 官方提供的协同方案或者自己做一套基于操作转换的协同服务。我的建议是不要在项目第一版就把协同做成核心需求先把单用户编辑、数据保存、Excel 导入导出的闭环跑通协同可以放在二期演化。表格数据这个东西做对了核心模型协同只是在上面加一层广播协议而已。最后再分享一点个人的感受。把 Univer 集成到项目里之后最大的变化不是代码量少了而是产品经理和运营人员的沟通成本低了。以前他们想要个什么区域冻结、条件格式、下拉筛选我得解释半天这个需求有多复杂现在表格组件本身支持这些能力我只需要在配置项里打开对应开关就行。这种用开源项目拉高产品下限的做法对一个开发团队来说是最划算的投入。如果你也在做一个需要在线表格的内部系统强烈建议直接从 Univer 入手少走我之前绕的那些弯路。