这几年做企业应用兜兜转转总是绕不过“在线文档”这道坎。客户要表格要协同要能嵌到自己的业务系统里商业云文档又不让二次分发于是大家开始找开源方案。Univer 就是我最近几个月高频使用的一个能把电子表格直接嵌进 Web 系统、还自带公式引擎和协同能力的开源项目底层是 Canvas 渲染API 设计得很现代社区活跃度也不错。这篇文章就把我实际接入和二次开发的经验整理出来从架构到踩坑都聊一遍给准备入坑或者正在筛选方案的同学一个参考。1. 这到底是个什么Univer 的定位和使用了什么技术1.1 不是又一个表格组件而是一套“办公基座”很多人第一次看到 Univer 的 Demo会以为它只是某个 Vue/React 表格组件的皮肤。但它的定位其实更像个“办公套件基座”官方支持的插件不止是电子表格还包括文档和幻灯片底层共用一套渲染引擎和一套数据模型。也就是说Univer 可以做成类似多人协作的在线 Office 入口而不是单张表格孤岛。不过这中间最成熟、用户最多的还是电子表格模块这也是我现在主要使用的。它解决的典型问题有三类第一你需要在自研系统里提供一个“能编辑、能算数、能打印”的类 Excel 体验而不是一坨静态数据展示第二你需要让多个用户在同一个文件上编辑且能查看彼此的改动第三你需要能定制工具栏、权限、菜单、快捷键让办公界面贴合业务而不是反过来让业务去迁就通用表格的固定皮肤。Univer 的技术核心是 TypeScript 写的渲染不走 DOM 表格而是用 Canvas 自绘。每一个单元格、行列头、选区都是画在 canvas 上的这样做带来的最大好处是性能上限高一次性渲染上万行、几十列时DOM 表格早就卡成幻灯片了Canvas 还能基本保持交互流畅。同时Univer 把“交互 UI 层”和“业务数据层”拆得很干净开发者可以只引入表格核心然后按需挂上公式、协同、筛选等插件而不是一锅端装所有功能。1.2 版本、协议和社区现状Univer 是开源软件遵循 Apache 2.0 协议这意味着你可以拿来商用、修改、分发只要保留版权声明。对于很多公司来说这一点比功能本身还重要因为商业办公套件的授权费往往高得离谱而且不同终端、不同用户数都分别计价。目前 Univer 的发布节奏已经稳定项目维护者在 GitHub 上的响应速度也还可以Issues 区能看到不少真实企业用户反馈的问题。社区里流传的资料主要分英文文档和中文社区两种中文资料相对少一些但项目官方有自己的文档站和示例库基本够用。社区里搜“univer”能搜到的主要是入门案例和踩坑记录官方文档则更适合从 0 到 1 搭建。我自己的使用版本基于当前的 releases 主线。这里要提醒一句Univer 处于快速迭代期API 偶尔会不兼容你如果在大版本之间升级一定要看迁移文档别盲目替换依赖。曾经遇到一个小版本把createUnit的参数结构改了团队其他人没看变更说明结果跑起来报一堆类型错误。2. 为什么值得认真考虑Univer 的核心能力拆解2.1 数据、逻辑、UI 三层分离的设计我第一次看 Univer 源码时有点意外它不像很多表格组件那样组件内部包着所有状态而是借用了类似游戏引擎和办公软件混合的思路——渲染引擎、公式引擎、协同服务各自独立通过定义良好的接口协作。具体来说Univer 大致分成这几层数据模型层管理工作表、单元格值、样式、行高列宽等原始数据这部分像是后端数据库里的表结构不关心怎么画。渲染引擎层负责把数据模型映射到 Canvas 上包括在网格上的绘制、文本绘制、选区高亮、滚动区域裁剪等。交互层处理鼠标、键盘、触摸事件把用户操作翻译成命令。命令/事务层用户任意一次操作比如输入值、合并单元格、设置边框都会被封装成一个 command记录到操作栈里。这为撤销重做和协同都提供了一致的基础。这种分层带来一个很实际的收益你可以替换掉某一部分而不影响整体。比如你不喜欢 Univer 默认的深色主题可以只改设计变量你想让公式引擎独立跑在 Web Worker 里也可以在命令层做封装而不需要把渲染层拆掉。2.2 公式引擎合规且可扩展的计算底层公式是表格软件的灵魂。Univer 内置了较完整的公式引擎支持常用的数学、统计、文本、日期等函数也能处理跨工作表引用例如SUM(Sheet2!A1:A10)。它还有一个很关键的“Recalculation”机制当单元格值变化时公式依赖的单元格会自动标记为脏然后在下一帧统一重算避免每改一个值就全表计算。对我来说公式引擎真正值钱的不是内置函数数量而是它有一棵清晰的 AST抽象语法树和解析器。如果你要扩展自定义函数比如实现行业内的复利计算、加班工时换算可以直接注册一个函数定义然后在公式里使用。这个扩展方式类似 Excel 的 UDFUser Defined Function机制只是换成 TypeScript 实现。不过也要注意Univer 的公式引擎目前不是 100% 兼容 Excel在极端复杂函数、数组公式、循环引用处理上可能跟 Excel 有细微差异。做原型还好如果业务涉及大量遗留 Excel 公式建议在项目初期跑一遍公式兼容性测试库把常用公式都覆盖到尤其是财务和统计需求多的企业。2.3 协同编辑可以只接服务端也能自己实现Univer 的协同是基于操作日志游程的。两个客户端之间不传输整个表格快照而是传输每个用户的操作指令也就是上面的命令层产物。每个客户端在执行命令后会把命令通过通信通道发给其他端其他端在自身数据模型上重放同样的命令从而实现同步。这样做的好处是网络占用低一个单元格改文本的命令可能只有几十字节。也正因如此Univer 协同对网络通道的要求不高WebSocket、Socket.IO、甚至类似 Matrix 的协议都可以接。官方文档提供了协同编辑的示例实现默认是使用一套自己的协议栈你如果不想自己折腾可以直接用官方集成的协同服务如果你想自建则需要把“命令传输-冲突处理-游标同步-状态还原”这套链路实现完整。我在实际项目里没有直接启用官方协同而是先把单机版嵌入到了内部后台第二步才接协同服务。因为协同引入的不只是通信代码还有在线状态、权限控制、历史版本回滚。这些交互细节如果第一次直接上业务方很难把控体验。建议你先明确协同是核心卖点还是附属功能再决定投入。3. 亲手把它跑起来快速接入的完整实操过程3.1 用包管理器初始化项目我演示的环境是基于 Vite TypeScript如果你用 Vue 或原生 JS思路一样。先创建项目npm create vitelatest univer-demo -- --template vue-ts cd univer-demo npm install接着安装 Univer 相关依赖。Univer 采用了模块化 npm 包结构核心包和 UI 包分得很细。最省事的方式是安装官方预设包npm install univerjs/presets univerjs/presets-sheets如果你更想按需加载可以分别安装univerjs/core、univerjs/sheets、univerjs/ui、univerjs/engine-render、univerjs/engine-formula等但这种模式需要你自己装配入门成本较高。我建议第一版先全部用预设跑通以后再用 Tree Shaking 消减体积避免一开始就被依赖关系绕晕。3.2 创建并渲染一个表格实例在src/main.ts或入口文件里写如下代码import { Univer } from univerjs/presets; import { UniverPresetSheets } from univerjs/presets-sheets; const univer new Univer({ presets: [ new UniverPresetSheets({ ui: { container: app, toolbar: true, }, }), ], });这个Univer实例就是整个应用的门面。UniverPresetSheets这个预设会帮我们装好表格渲染、Ribbon 工具栏、单元格编辑、公式解析等基础能力。容器可以指定页面上的一个 DOM 元素比如 id 为app的 div。然后再创建具体的工作簿import { UniverInstanceType } from univerjs/core; univer.createUnit(UniverInstanceType.SHEET, { name: Demo, sheetOrder: [sheet-01], sheets: { sheet-01: { id: sheet-01, name: Sheet1, rowCount: 200, columnCount: 50, cellData: { 0: { 0: { v: 单价, s: { bl: 1, bg: #f5f5f5 }, }, 1: { v: 数量, s: { bl: 1 }, }, 2: { v: 小计, s: { bl: 1 }, }, }, 1: { 0: { v: 10, }, 1: { v: 3, }, 2: { f: A2*B2, }, }, }, }, }, });注意上面的cellData格式行索引和列索引都从 0 开始v表示原始值s表示单元格样式f表示公式。这里我们做了一个小 DemoA2 是 10B2 是 3C2 是公式A2*B2运行后 C2 会显示 30。一个常见的困惑是为什么既要createUnit又要在new Univer时传入预设简单说Univer管理运行时环境相当于操作系统createUnit创建具体文档实例相当于打开某个文件。这样一套环境可以创建多个表格文件也可以在同一页面创建不同类型文档适合以后做多标签页办公套件。3.3 把数据库里的 JSON 数据塞进表格真实场景下你肯定不是手动写死的单元格数据而是从后端接口拿数据后渲染到表格上。可以直接把接口返回的二维数组转换成cellData的嵌套结构。为了省事可以先写一个转换函数function arrayToCellData(headers: string[], rows: unknown[][]) { const cellData: Recordstring, Recordstring, { v: unknown; s?: object } {}; headers.forEach((header, colIndex) { cellData[0] cellData[0] || {}; cellData[0][colIndex] { v: header, s: { bl: 1, bg: #f0f0f0 } }; }); rows.forEach((row, rowIndex) { row.forEach((cell, colIndex) { cellData[rowIndex 1] cellData[rowIndex 1] || {}; cellData[rowIndex 1][colIndex] { v: cell }; }); }); return cellData; }然后把返回的 JSON 转成该结构后传给createUnit。我建议把创建单元的代码封装成一个函数方便后续动态切换不同数据源function loadSheet(data: { name: string; rows: unknown[][]; headers: string[] }) { const cellData arrayToCellData(data.headers, data.rows); // 移除旧实例避免重复创建 // 这里用 univer.getCurrentUnit() 判断并销毁旧文件 const oldUnit univer.getCurrentUnit(); if (oldUnit) { univer.disposeUnit(oldUnit); } univer.createUnit(UniverInstanceType.SHEET, { name: data.name, sheetOrder: [sheet-01], sheets: { sheet-01: { id: sheet-01, name: Sheet1, rowCount: data.rows.length 10, columnCount: data.headers.length, cellData, }, }, }); }这里有个容易踩的坑重复创建同一个名称的工作簿Univer 不会自动清理旧实例。如果你在单页应用里切换菜单不断createUnit页面上的表格区域就会叠加出多个透明 Canvas表现为“出现多个表格重叠”。正确做法是先拿到当前单元并disposeUnit或者直接用 SDK 提供的“载入快照”能力替换内容而不是反复创建新实例。3.4 页面里放哪些必备配置项如果你的使用场景是给内部运营人员做数据录入工具栏可以保留但需要调整默认功能。Univer 的预设里暴露了比较全的配置入口比如你可以通过ui配置项控制菜单项、工具栏按钮显隐new UniverPresetSheets({ ui: { container: app, toolbar: true, menu: { file: { export: true, print: true, }, insert: { image: false, }, formula: { functionList: true, }, }, }, })实际配置名以官方文档为准这里我的意图是说明你可以把不少无关按钮先藏掉让界面看起来更聚焦。注意Univer 很多动作是先经过“命令”的即使按钮隐藏了也可以通过键盘快捷键触发所以严格的权限校验还是要在业务层自己做单纯隐藏 UI 不是安全手段。4. 把 Univer 变成你自己的核心定制和开发实践4.1 自定义工具栏按钮企业内部经常需要一个“保存到服务端”的按钮。Univer 自带的是基于浏览器本地存储的草稿或者通过协同服务持久化。但在普通管理系统里我们更希望在点击“保存”时把当前整个工作簿的快照发送到我们自己的后端接口。Univer 提供了registerDomain或者通过injector来注册自己的命令和 UI。简单做法先在渲染出来的 Ribbon 工具栏上配置自定义按钮在点击回调里用univer.getCurrentUnit()拿到当前工作表单元调用它的快照接口获取数据再发请求。伪代码如下const currentUnit univer.getCurrentUnit(); const snapshot currentUnit.getSnapshot(); const workbookData JSON.parse(snapshot); // 然后 POST 到后端 fetch(/api/sheet/save, { method: POST, body: JSON.stringify({ name: workbookData.name, sheets: workbookData.sheets }), });get 到的快照是包含表格数据、样式、行列配置的完整 JSON 结构。你可以把它原样存到数据库里下次要打开时再通过createUnit传回去。需要注意的是快照里可能包含一些内部字段存储时可以原样存但如果你要让后端系统去读取具体单元格值最好自己在服务端写解析函数而不是依赖快照内部格式。4.2 自定义单元格渲染Univer 虽然整体是 Canvas 渲染但允许你在单元格内注册自定义渲染器。比如你想把某一列的“状态”字段渲染成圆点加文字而不是普通文本就可以实现一个单元格渲染器。这个功能基于注册表模式大约是这样import { CustomCellRender } from univerjs/engine-render; class StatusCellRender extends CustomCellRender { drawCellContent(ctx, info) { const { data, x, y, width, height } info; // 先画圆点 ctx.fillStyle data.color || #00aaff; ctx.beginPath(); ctx.arc(x 10, y height / 2, 4, 0, Math.PI * 2); ctx.fill(); // 再调用父类画文字 return super.drawCellContent(ctx, { ...info, data: { ...data, v: data.label ?? data.v }, }); } }实际 API 可能随版本变迁但思路是对的。自定义渲染要尤其注意性能不要在drawCellContent里做复杂计算因为滚动时 Canvas 会频繁重绘这些单元格复杂逻辑会直接拖慢帧率。我的经验是把需要自定义绘制的区域尽量限定在可视区通过range条件控制只对特定列启用。4.3 接入你的后端权限体系表格软件在系统集成中最难的不是渲染而是权限。比如某些列只有经理能看到某些单元格不可编辑需要根据当前登录人判断。Univer 有两种做法配合一种是靠数据层控制在渲染前根据用户权限过滤掉敏感字段再传给表格。但这种方法会导致“同一份数据需要生成多份快照”后端存储也会比较麻烦。另一种是靠交互层控制保留完整数据但通过 Univer 的编辑事件把非法的变更“拦截”下来。比如监听beforeChange或在命令执行管线的前端设置一个拦截器当发现当前用户对目标选区没有编辑权时就取消执行命令。我在实际项目中使用的是拦截器思路中偏简单的版本先禁用右键菜单的“粘贴”再监听onCellChange等单元格值变化后校验权限如果校验失败就把数据回滚到修改前。这个方案在体验上不算最优因为用户会看到值闪了一下然后变回去但胜在实现简单、逻辑清晰。追求更好体验的同学可以自己去深入命令管线在命令生效前就挡住。4.4 公式扩展自定义一个业务函数假设业务要求“根据员工工龄计算带薪年假天数”。Excel 里没有现成我们可以注册一个WORK_YEARS函数。Univer 的函数注册入口大概是这样import { FormulaEngineService, operatorToken } from univerjs/engine-formula; const formulaEngine univer.__getInjection(FormulaEngineService); formulaEngine.registerFunction(ANNUAL_LEAVE, (year: number) { if (year 1) return 0; if (year 10) return 5; return 10; });之后用户在单元格里输入ANNUAL_LEAVE(5)就会得到 5。扩展函数时要注意参数顺序和类型Univer 公式引擎会按尝试类型转换如果传进来的参数是字符串最好自己多写几个判断或者用“公式重算引起的”参数类型做兼容处理。我踩过坑是注册的函数名跟内置函数冲突导致后续公式计算结果异常所以注册自定义函数时一定先看看函数清单里有没有同名。5. 别等上线再后悔生产中必须要注意的事项5.1 性能优化从数据量开始前面提到 Univer 基于 Canvas 渲染滚动列表的性能比 DOM 表格好很多但也不是无限大。我做了一个 10000 行 × 20 列、每列都有样式和部分公式的测试初始渲染大概需要 2 秒左右滚动操作没有明显掉帧。如果把数据再拉大比如 5 万行并且每行都套公式浏览器主线程的压力就上来了。优化策略其实和通用前端性能优化一样核心原则是“减少重算范围”。具体措施包括公式引用范围精确定位不要整列求整列例如SUM(A:A)这种写法会让公式引擎计算量暴增改成SUM(A1:A1000)。大表格中临时隐藏复杂行通过数据模型设置行过滤。把只读场景和编辑场景分开如果需要展示 5 万行数据可以用静态 Canvas 列表平铺不一定非得挂完整的公式引擎。只有在真正需要输入和计算时启用完整表格。5.2 网络传输和协同冲突的坑接入协同后最容易出问题的是“先把本地修改发给远端”还是“先应用命令再发”。Univer 的推荐流程里命令是先在本端执行再广播给其他端。但如果某个命令依赖上下文的选区状态而其他端数据已经不同重放时可能报错。这个时候你要检查是否为命令携带了完整上下文或者采用类似 OT 的转换逻辑。我实测下来Univer 协同服务处理文本经典的“同时在某行插入字符”这类并发冲突相对可靠但对于合并单元格、插入行列等结构性操作协同稳定性还在完善中。如果你要上线正式的协作编辑功能一定不能只做单人测试至少用 3 个浏览器开同一份表格互相改动重点测并发插入行、插入列、修改同一单元格公式。5.3 样式和字体兼容Univer 的 Canvas 文本绘制依赖浏览器本地字体。如果你指定了系统不存在的字体Canvas 会回退到默认字体导致表格中的内容和 Excel 里显示的不一致。尤其中文字体Windows、macOS、Linux 字体库差别很大强烈建议在 CSS 里定义完整的字体栈body { font-family: PingFang SC, Microsoft YaHei, Source Han Sans SC, Arial, sans-serif; }另外复制粘贴到 Excel 的体验也要注意。Univer 支持的剪贴板格式需要和浏览器权限配合。如果页面放在 iframe 里且没有开启“剪贴板权限”用户可能无法粘贴 Excel 中带格式的数据。这个问题常见于嵌入式后台系统需要给 iframe 添加allowclipboard-read; clipboard-write。5.4 打包体积和按需加载Univer 是个大项目初始包体积不算小。使用全量预设时构建后的 JS 可能在 1MB 以上gzip 后几百 KB。对于后台系统问题不大但如果放到了面向 C 端用户的门户页面就需要注意首屏性能。做法是分模块加载首屏只加载渲染引擎、核心表格模块公式引擎和协同模块在用户点击“显示公式”或者登录协同服务时才按需 import。官方提供了较完整的模块拆分配合 Vite 的动态 import 可以做到只加载需要的部分。千万不要为了省事把一个公用的univer依赖放到所有页面的公共 chunk 里那样会让整个系统的首屏都背上一份沉重的负担。6. 常见问题速查与我在实战中的替代方案6.1 常见错误和解决办法我整理了几个高频问题都是团队里后来接手的人反复遇到的。现象可能原因解决办法页面出现两个表格叠加未销毁旧 unit 就再次createUnit获取当前 unit 并disposeUnit再创建新的container找不到 DOMUniver 初始化早于 Vue/React 挂载把初始化放到onMounted或useEffect之后公式显示#NAME?公式函数名未注册或拼写错误检查内置函数清单或注册自定义函数单元格内容在中文系统显示错位字体未显式声明导致 Canvas 字体回退在页面 CSS 设置完整字体栈脚本运行报错 “Cannot read property of undefined”依赖版本不一致统一升级到官方最新版本并按照迁移说明调整粘贴 Excel 数据丢失格式iframe 没有剪贴板权限在 iframe 标签增加allowclipboard-read; clipboard-write6.2 一些更好用的替代方案对比在很多技术选型讨论里大家会问“Univer 和 Luckysheet 怎么选”“和 Handsontable 怎么选”。我的看法是如果只是简单的表格展示、内联编辑要小于 100KB 而且不需要公式那原生的 HTML Table 或轻量表格库就够了没必要引入 Univer。如果明确需要 Excel 级体验比如合并单元格、跨表公式、条件格式、冻结行列那 Univer 和 Luckysheet 都在范围内。Univer 的现代架构和社区活跃度更好线新版 Luckysheet 则相对更成熟稳定但二次开发时改起来更费劲。如果只需要数据网格用于业务系统 CRUDHandsontable 是老牌选择文档丰富但受商业许可证限制必须注意协议。相比之下 Univer 的 Apache 2.0 更自由。如果是想快速做一个协作文档而不是自建数据系统那直接用成熟的在线协作文档产品反而更划算折腾 Univer 协同需要额外成本。我自己现在的选型标准很简单需要公式引擎就优先 Univer需要协作且团队有后端能力就选 Univer否则切到更轻量的方案。技术选型没有全能只有合适。6.3 关于“在 Vue3/React 里怎么封装”的心得Univer 是框架无关的它只是一个 TS 库管理自己的 DOM 容器并不依赖生命周期。但如果你在 Vue 里使用强烈建议封装成一个自定义组件或者 composable而不是在每个页面里裸写初始化和销毁逻辑。我用 Vue 3 封装时的核心逻辑大概是// useUniver.ts import { onBeforeUnmount, onMounted, ref } from vue; import { Univer } from univerjs/presets; import { UniverPresetSheets } from univerjs/presets-sheets; export function useUniver(containerRef) { const univer refUniver(); onMounted(() { univer.value new Univer({ presets: [new UniverPresetSheets({ ui: { container: containerRef.value } })], }); }); onBeforeUnmount(() { if (univer.value) { univer.value.dispose(); } }); return univer; }这样做的好处是组件的复用性好页面卸载时不会残留 Canvas 实例和事件监听。如果你在 React 里思路完全一样只需要把生命周期换成useEffect。6.4 我实际生产环境中走过的弯路最后一次补充几个我个人的体会。第一不要在初始化阶段把所有插件全开。Univer 提供的能力多但每一种能力都需要计算资源和内存。生产环境建议按业务模块拆分成不同入口例如“只读报表入口”不加载编辑插件“数据录入入口”只加载编辑和基础公式“分析入口”才加载全套分析功能。第二保存功能不要直接从快照里拿文件覆盖所有单元格。我一开始直接从getSnapshot拿整个表丢给后端用户数据少还好数据多时快照里有大量样式和行高列宽信息传输量大、保存慢。后来改成只存业务数据字段把样式相关的字段单独在做皮肤配置这样接口传输小了大概 70%。第三联动业务系统时尽量用“命令”而不是“直接改数据”。很多场景是用户点击一个按钮需要同时更新表格多个区域的数值。你看见 Univer 的公开 API 里有setCellValue之类的方法可以直接对单元格赋值但这种方式走不到“撤销/重做”流程里。如果业务允许撤销操作那么要用官方提供的 Command 方式而不是直接改内部数据。我个人的习惯是给每个业务动作都封装成“注册命令 触发命令”两层最后统一通过命令执行单元操作。这样用户无论手动输入还是点击系统按钮都走同一套变更通道撤销重做逻辑也自然兼容。7. 这个方向还能怎么延伸Univer 目前其实可以承载很多更重的办公场景。如果你用的版本较新可能还能看到它们在做公式追踪、命名区域、图表、透视表等功能。从实际项目角度我后续会优先尝试把 Univer 作为低代码平台里的“报表单元格”基础组件让业务人员通过简单拖拽把数据库字段映射到表格模板中。这样业务人员维护一个配置系统就能自动生成一张带公式的可填写表单落地速度会比传统开发的报表功能快不少。我个人在实际操作中的体会是不要用老思维去使用它把它当成一个可拆装的办公内核才能真正释放出价值。如果你也打算做类似的集成建议先写一个最小的可运行 Demo把数据读取、渲染、保存、二次加载走通再考虑后续的权限和协同。这套流程只要跑通后面的定制就会顺很多。这个方向往后还可以接智能表格、数据透视、报表打印等能做的事挺多。
