ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Univer SDK:面向开发者的 Web 电子表格引擎

Univer SDK:面向开发者的 Web 电子表格引擎

1. Univer 是什么:一个被误读的开源办公套件 SDK,不是“在线 Office 替代品”

很多人第一次看到 “univer” 这个词,是在搜索“univer在线”“univer SDK”或者“Office 替代方案”时跳出来的结果。它不像 WPS、OnlyOffice 那样自带完整桌面安装包,也不像 Google Docs 那样开箱即用——它压根就不是一个面向最终用户的“软件产品”,而是一个专为开发者设计的、模块化、可嵌入的富文档处理 SDK 核心引擎。我第一次在客户项目里接触它,是接到一个需求:“把 Excel 表格编辑能力无缝集成进我们自己的 SaaS 系统后台,不希望用户跳转到外部页面,也不能用 iframe 套壳”。当时团队试了几个方案,最后锁定 Univer,不是因为它“多像 Office”,而是因为它从第一天起,就只解决一个事:如何让任何 Web 应用,拥有原生级的电子表格、文档、幻灯片处理能力,且完全可控、可定制、可审计。

它的核心定位非常清晰:不是 Office 的竞品,而是 Office 能力的“拆解器”与“组装器”。你可以把它理解成一套乐高积木——Excel 的公式引擎、Word 的段落排版模型、PPT 的动画渲染管线,全都被拆成独立的、带 TypeScript 类型定义的 npm 包(比如@univerjs/core、@univerjs/sheets、@univerjs/docs)。你不需要全都要,只需要npm install @univerjs/sheets,再配上几行初始化代码,就能在一个 React 组件里跑起一个支持基础公式、行列操作、样式设置的电子表格;如果还需要 PDF 导出,再加一个@univerjs/plugin-export-pdf即可。这种“按需加载、按需组合”的设计,直接绕开了传统 Office SDK 动辄几百 MB、强依赖特定运行时、升级即崩溃的顽疾。

关键词里反复出现的 “SDK” 和 “spreadsheets” 并非偶然——Univer 的本质就是Web-first 的 Spreadsheet SDK,其底层架构甚至比多数前端表格组件(如 Handsontable、AG Grid)更贴近 Excel 的语义模型。它内置了完整的 Formula Engine(支持超过 400 个 Excel 函数),实现了真正的 Cell Dependency Graph(单元格依赖图),这意味着当你修改 A1,B1 的公式=A1*2会自动重算,C1 的=SUM(A1:B1)也会联动更新,整个过程不依赖后端,纯前端完成。这背后是它对 Excel Open XML 标准的深度解析能力,以及自研的轻量级计算内核。而 “PDF” 相关热词,则指向它最常被低估的价值点:不是简单截图导出,而是基于真实文档结构的语义化 PDF 渲染。它导出的 PDF,文字可选、表格可复制、页眉页脚位置精准,远超 canvas 截图或 html2canvas 的“伪 PDF”。

所以,如果你正在找一个“下载就能用的 Office 替代品”,Univer 不是你的答案;但如果你是一名前端/全栈工程师,正被“如何在自己系统里嵌入专业级表格编辑”这个问题卡住,那 Univer 很可能就是那个被埋没的、真正能落地的解决方案。它不承诺“和 Office 一模一样”,但它承诺“你想要的每一个功能点,都能以最小成本、最高可控性接入”。

2. 为什么不是 Electron 或 WebView?Univer 的架构选择逻辑

在决定引入 Univer 之前,我们团队做过三轮技术选型对比:第一轮是 Electron 封装现有 Web Office(比如 OnlyOffice 的社区版);第二轮是用 WebView 加载一个独立的 Office Web App;第三轮才是深入研究 Univer 的源码和 Demo。最终放弃前两者的理由,不是因为 Univer 更“高级”,而是因为它们在生产环境的稳定性、安全合规性、以及长期维护成本上,存在无法回避的硬伤。这个决策过程,值得详细拆解。

Electron 方案看似省事:打包一个网页进去,加个菜单栏,就成了“桌面版”。但问题在于,它本质上是一个黑盒浏览器。你无法控制其内部 JavaScript 引擎的版本、内存泄漏的修复节奏、甚至无法干预其网络请求的拦截策略。我们曾遇到一个真实案例:某政务系统要求所有外发请求必须经过统一网关鉴权,而 Electron 内嵌的 OnlyOffice 页面,其内部的图片上传、字体加载、甚至某些 API 调用,都绕过了前端 JS 层,直接走 Chromium 的网络栈,导致网关策略失效,安全审计直接不通过。更麻烦的是,Electron 的更新机制——一旦上游 Chromium 发布安全补丁,你必须立刻升级整个 Electron 版本,而这个过程往往伴随着大量 UI 兼容性问题,测试成本极高。

WebView 方案则走向另一个极端:它把复杂度甩给了“外部网页”。你只是个容器,里面的内容由别人托管。这意味着:第一,你永远无法保证该网页的可用性——如果对方服务宕机,你的功能就彻底消失;第二,你无法做任何深度定制——想给右键菜单加一个“导出为 CSV 并加密”的选项?抱歉,除非对方开放 API,否则你只能干瞪眼;第三,也是最致命的,数据主权风险。用户在你系统里编辑的敏感表格,其内容是否会被上传到第三方服务器进行“智能分析”?你根本无法审计,也无法在合同中明确约束。这在金融、医疗、政务等强监管领域,是绝对的红线。

Univer 的架构选择,恰恰是针对这两个痛点的精准回应。它采用Pure Web Stack(纯 Web 技术栈) + Plugin Architecture(插件化架构)。所有核心逻辑(公式计算、渲染、事件分发)都在你的应用进程内运行,所有的网络请求(比如加载远程模板、保存文件到你的后端)都由你完全掌控——你写fetch('/api/save', { body: workbook.toJson() }),就只会发出这一条请求,不会有隐藏的“心跳”或“遥测”。它的插件机制(Plugin System)不是噱头,而是工程化的基石:每个功能(如 PDF 导出、打印预览、协作光标)都是一个独立的、可开关的插件。你可以禁用所有网络相关插件,只保留本地计算和渲染能力,实现真正的离线工作流。它的构建产物是标准的 ES Module,可以无缝集成进 Vite、Webpack、甚至 Deno 环境,没有私有运行时,没有神秘的二进制依赖。

提示:很多团队在初期会忽略 Univer 的 “Plugin Lifecycle”(插件生命周期)设计。它不是简单的“注册即生效”,而是严格遵循onInstall->onMounted->onUninstall的状态流转。例如,PDF 插件的onMounted阶段,会动态注入 WebAssembly 模块(用于 PDF 字体子集化),如果你在onInstall阶段就尝试调用导出方法,会得到Plugin not ready错误。这个细节,在官方文档里藏得很深,但却是避免线上报错的关键。

3. 从零开始:一个可运行的 Sheets 实例,每一步背后的意图

现在,让我们动手搭建一个最简但完全可运行的 Univer Sheets 实例。这不是照抄官方 Quick Start,而是聚焦于每一个命令、每一行代码背后的真实意图,让你明白“为什么必须这样写”,而不是“怎样让它跑起来”。我会用 Vite + React 作为基础环境,这是目前最主流、也最能体现 Univer 设计哲学的组合。

3.1 初始化项目与核心依赖安装

首先,创建一个干净的 Vite 项目:

npm create vite@latest univer-demo -- --template react cd univer-demo npm install

接着,安装 Univer 的核心包和 Sheets 插件:

npm install @univerjs/core @univerjs/sheets @univerjs/ui @univerjs/design @univerjs/plugin-sheets-ui @univerjs/plugin-sheets-data-validation @univerjs/plugin-export-pdf

注意这里安装的包名:@univerjs/core是引擎骨架,@univerjs/sheets是电子表格能力本体,@univerjs/ui和@univerjs/design提供 UI 组件和主题系统,而@univerjs/plugin-sheets-ui才是真正把@univerjs/sheets的能力“画出来”的桥梁。这个分层设计,意味着你可以用@univerjs/sheets做纯后台计算(比如批量处理 Excel 文件),完全不引入 UI 依赖,这对构建 Serverless 函数极其友好。

3.2 构建 Univer 实例:Univer类与IUniverInstanceService

在src/main.tsx中,我们需要创建一个全局的 Univer 实例。关键代码如下:

import { Univer } from '@univerjs/core'; import { defaultTheme } from '@univerjs/design'; import { UniverSheets } from '@univerjs/sheets'; import { UniverSheetsUI } from '@univerjs/plugin-sheets-ui'; import { UniverExportPdf } from '@univerjs/plugin-export-pdf'; // 创建 Univer 实例 const univer = new Univer({ theme: defaultTheme, }); // 注册核心插件 univer.registerPlugin(UniverSheets); univer.registerPlugin(UniverSheetsUI); univer.registerPlugin(UniverExportPdf); // 启动实例 const app = univer.createUniverSheetInstance('sheet-1');

这段代码的精妙之处在于createUniverSheetInstance。它不是创建一个“窗口”,而是创建一个独立的、可并存的文档实例。你可以调用多次,生成sheet-1、sheet-2,它们彼此隔离,互不影响。这为多标签页、多文档协同提供了天然支持。'sheet-1'这个 ID,是后续在 React 组件中定位该实例的唯一钥匙。

3.3 在 React 组件中挂载:UniverInstanceProvider与UniverSheetsCanvasView

现在,创建一个Spreadsheet.tsx组件:

import { UniverInstanceProvider, useUniverInstance } from '@univerjs/react'; import { UniverSheetsCanvasView } from '@univerjs/plugin-sheets-ui'; export function Spreadsheet() { const univerInstance = useUniverInstance(); return ( <div style={{ width: '100%', height: '600px', border: '1px solid #ccc' }}> <UniverInstanceProvider univer={univerInstance}> <UniverSheetsCanvasView /> </UniverInstanceProvider> </div> ); }

这里有两个关键点:第一,UniverInstanceProvider是一个 React Context Provider,它将前面创建的univer实例注入到组件树中;第二,UniverSheetsCanvasView是一个“画布视图”,它内部使用 Canvas 进行高性能渲染(而非 DOM 表格),这是 Univer 能流畅处理万行数据的基础。你可能会疑惑:为什么不用更熟悉的div+table?因为 Canvas 可以精确控制每一个像素,实现平滑滚动、虚拟滚动、以及复杂的单元格边框渲染(比如双线边框、斜线表头),这些在 DOM 中要么性能极差,要么根本无法实现。

3.4 添加一个实用功能:PDF 导出按钮

最后,我们给这个表格加一个“导出为 PDF”的按钮。在Spreadsheet.tsx中添加:

import { useCommand, ICommandService } from '@univerjs/core'; import { ExportPdfCommandId } from '@univerjs/plugin-export-pdf'; export function Spreadsheet() { const univerInstance = useUniverInstance(); const commandService = useCommand(); const handleExportPdf = () => { commandService.executeCommand(ExportPdfCommandId, { unitId: 'sheet-1', // 必须指定要导出的文档 ID title: '我的报表', fileName: 'report.pdf', }); }; return ( <div style={{ width: '100%', height: '600px', border: '1px solid #ccc' }}> <button onClick={handleExportPdf} style={{ marginBottom: '10px' }}> 导出为 PDF </button> <UniverInstanceProvider univer={univerInstance}> <UniverSheetsCanvasView /> </UniverInstanceProvider> </div> ); }

这个commandService.executeCommand是 Univer 的核心交互范式。它不是直接调用某个函数,而是发送一个“命令”(Command),由系统内部的 Command Service 分发给所有监听该命令的插件。ExportPdfCommandId这个命令,会被@univerjs/plugin-export-pdf插件捕获,并执行其内部的 PDF 生成逻辑。这种设计的好处是:功能解耦。如果你想替换 PDF 生成引擎(比如换成 Puppeteer 服务端渲染),你只需要提供一个监听ExportPdfCommandId的新插件,完全不用修改 UI 层代码。

注意:实测中发现,首次导出 PDF 时会有约 300ms 的延迟,这是因为 WebAssembly 模块需要动态加载和初始化。如果你的应用对首屏体验要求极高,可以在应用启动时,就预先调用univer.getPluginManager().getPlugin('ExportPdf')?.preload()来触发预热,将延迟转移到初始化阶段。

4. 深度定制实战:修改默认字体、禁用特定菜单项、注入自定义命令

Univer 的强大,不在于它开箱即用的功能有多丰富,而在于它为你预留了从 UI 到内核的全链路定制入口。很多团队在评估时,只看到它默认的 UI,就认为“不够美观”或“不符合我们的设计规范”,从而放弃。实际上,它的 UI 层是高度可替换的,而内核层则提供了丰富的 Hook(钩子)机制。下面,我以三个真实项目中的定制需求为例,展示如何“动手术”。

4.1 需求一:将默认字体从“Microsoft YaHei”改为“HarmonyOS Sans”

这是一个典型的 UI 主题定制。Univer 使用 CSS-in-JS 的方式管理主题,但字体设置并不在defaultTheme对象里,而是在@univerjs/design的FontManager中。正确做法是:

import { FontManager } from '@univerjs/design'; // 在创建 Univer 实例之后,注册前 const fontManager = new FontManager(); fontManager.addFont('HarmonyOS Sans', [ { weight: 'normal', url: '/fonts/HarmonyOS_Sans_Nor.woff2' }, { weight: 'bold', url: '/fonts/HarmonyOS_Sans_Bol.woff2' }, ]); // 将 FontManager 注入到 Univer 实例 const univer = new Univer({ theme: defaultTheme, fontManager, });

然后,在你的 CSS 中,覆盖 Univer 的字体声明:

.univer-sheets-canvas-view { font-family: 'HarmonyOS Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; }

这个方案的优势在于:它只影响 Univer 的渲染区域,不会污染全局 CSS,也不会影响你应用其他部分的字体。更重要的是,FontManager支持字体回退链(fallback chain),当HarmonyOS Sans加载失败时,会自动降级到Segoe UI,确保文本始终可读。

4.2 需求二:禁用“插入图片”菜单项,但保留“插入形状”

Univer 的菜单系统是插件化的,@univerjs/plugin-sheets-ui提供了默认菜单,但你可以通过IMenuService完全接管。关键代码如下:

import { IMenuService, MenuGroup, MenuItem, MenuItemType } from '@univerjs/core'; import { SheetMenuPosition } from '@univerjs/plugin-sheets-ui'; // 在插件注册之后,获取菜单服务 const menuService = univer.getPluginManager().getPlugin('Menu') as IMenuService; // 移除“插入图片”菜单项 menuService.removeMenuItem('insertImage'); // 如果你想添加一个自定义菜单项,比如“插入水印” menuService.addMenuItem({ id: 'insertWatermark', type: MenuItemType.BUTTON, group: MenuGroup.TOOLBAR_INSERT, icon: 'WatermarkIcon', title: '插入水印', positions: [SheetMenuPosition.INSERT_MENU], onClick: (context) => { // 这里写你的水印逻辑 }, });

removeMenuItem和addMenuItem是原子操作,它们直接修改内存中的菜单配置对象,无需重启实例。SheetMenuPosition.INSERT_MENU指定了该菜单项应出现在“插入”下拉菜单中。这个机制比传统的“CSS display: none”强大得多,因为它从源头上移除了该命令的注册,连快捷键Alt+I, P都会失效,彻底杜绝了用户通过键盘触发的可能性。

4.3 需求三:实现“一键清除所有批注”,并绑定到 Ctrl+Shift+C 快捷键

这是对内核能力的深度调用。Univer 的批注(Comment)数据存储在Workbook的CommentModel中,而快捷键绑定则通过ICommandService的registerCommand完成。完整代码如下:

import { ICommandService, ICommand, ICommandContext } from '@univerjs/core'; import { IAccessor } from '@univerjs/core'; import { Workbook } from '@univerjs/core'; // 定义一个新命令 const ClearAllCommentsCommand: ICommand = { id: 'clear-all-comments', type: CommandType.COMMAND, handler: async (accessor: IAccessor, ...params: any[]) => { const workbook = accessor.get(Workbook); const commentModel = workbook.getCommentModel(); // 获取所有批注的 ID const allCommentIds = Array.from(commentModel.getAllComments().keys()); // 批量删除 allCommentIds.forEach(id => commentModel.deleteComment(id)); return true; }, }; // 注册命令 commandService.registerCommand(ClearAllCommentsCommand); // 绑定快捷键 commandService.registerShortcut({ id: 'clear-all-comments', description: 'Clear all comments', binding: 'Ctrl+Shift+C', preconditions: (accessor) => { // 只有在当前激活的文档是 Sheets 时才生效 const currentUnit = accessor.get(IUniverInstanceService).getCurrentUnit(); return currentUnit?.getType() === UnitType.SHEET; }, });

这个例子展示了 Univer 的“命令-快捷键-业务逻辑”三层解耦。handler里写的不是 UI 操作,而是纯粹的数据操作;preconditions确保了快捷键的安全边界;而registerShortcut则将物理按键映射到逻辑命令。这种设计,使得同一个命令,既可以被快捷键触发,也可以被工具栏按钮触发,还可以被自动化脚本调用,复用性极高。

5. 生产环境避坑指南:内存泄漏、PDF 导出失败、协作光标错位

在将 Univer 接入多个百万级 DAU 的 SaaS 产品后,我们踩过不少坑。有些问题在 Demo 里永远不会出现,只有在真实用户的复杂操作下才会暴露。以下是我总结的三个最高频、最棘手的生产环境问题,以及经过验证的解决方案。

5.1 问题:切换 Tab 页时,Univer 实例未销毁,导致内存持续增长

现象:用户在你的应用里频繁打开/关闭包含 Univer 表格的 Tab 页,一段时间后,Chrome 任务管理器显示该页面内存占用飙升至 2GB+,最终卡死。根源在于:Univer 实例是一个重型对象,它内部持有 Canvas 上下文、WebGL 渲染器、Formula Engine 的缓存、以及大量的事件监听器。如果你只是简单地unmountReact 组件,而没有显式销毁 Univer 实例,这些资源将永远驻留在内存中。

解决方案:必须在组件卸载(useEffect cleanup)时,调用univer.dispose()。但要注意,dispose()是异步的,需要等待其完成才能确保资源释放。正确写法如下:

useEffect(() => { return () => { if (univerInstance) { // 等待 dispose 完成 univerInstance.dispose().then(() => { console.log('Univer instance disposed'); }); } }; }, [univerInstance]);

更进一步,如果你的应用支持“Tab 页懒加载”,建议在 Tab 切换到后台时,就主动调用univerInstance.hide()(隐藏画布,释放 GPU 资源),而不是等到unmount。hide()是同步的,开销极小,能显著降低后台 Tab 的内存占用。

5.2 问题:PDF 导出时,中文乱码或字体缺失

现象:导出的 PDF 中,中文全部变成方块或空格。这是 Web 环境下 PDF 渲染的经典难题。Univer 的 PDF 插件默认使用pdfmake库,它需要提前注册中文字体。但很多团队只注册了字体文件,却忽略了pdfmake的字体映射规则。

解决方案:必须在pdfmake的vfs(Virtual File System)中,同时注册字体文件和字体别名。关键代码如下:

import pdfMake from 'pdfmake/build/pdfmake'; import pdfFonts from 'pdfmake/build/vfs_fonts'; // 设置 vfs (pdfMake as any).vfs = pdfFonts; // 注册中文字体(以 Noto Sans CJK SC 为例) (pdfMake as any).vfs['NotoSansCJKSC-Regular.ttf'] = /* base64 字体文件 */; (pdfMake as any).vfs['NotoSansCJKSC-Bold.ttf'] = /* base64 字体文件 */; // 关键:告诉 pdfmake,'NotoSansCJKSC' 这个字体族,对应哪些文件 (pdfMake as any).fonts = { NotoSansCJKSC: { normal: 'NotoSansCJKSC-Regular.ttf', bold: 'NotoSansCJKSC-Bold.ttf', } };

然后,在 Univer 的 PDF 导出配置中,指定fontFamily: 'NotoSansCJKSC'。这个步骤缺一不可。我们曾因漏掉pdfMake.fonts的映射,调试了整整两天。

5.3 问题:多人协作时,光标位置错乱,显示在错误的单元格

现象:两个用户同时编辑同一张表,A 用户的光标在 A1,B 用户看到的却是显示在 Z100。这并非网络延迟导致,而是 Univer 的协作光标插件(@univerjs/plugin-collaboration-cursor)与你的自定义滚动逻辑冲突。

根源:Univer 的光标渲染是基于 Canvas 的绝对坐标。当你对表格容器(.univer-sheets-canvas-view)应用了transform: scale(0.8)或overflow: auto的滚动,而没有同步通知光标插件,它就会按照原始尺寸计算坐标,导致错位。

解决方案:在你的自定义滚动容器上,监听scroll事件,并手动调用光标插件的updatePosition方法:

const container = document.querySelector('.my-custom-scroll-container'); container?.addEventListener('scroll', () => { // 获取当前光标插件实例 const cursorPlugin = univer.getPluginManager().getPlugin('CollaborationCursor'); if (cursorPlugin) { // 强制刷新光标位置 cursorPlugin.updatePosition(); } });

更优雅的做法是,重写@univerjs/plugin-collaboration-cursor的render方法,使其自动适配父容器的getBoundingClientRect(),但这需要 fork 插件源码。对于大多数项目,上面的scroll事件监听已足够稳定。

最后分享一个心得:Univer 的文档质量,与其代码质量并不完全匹配。很多关键 API(如dispose()的返回 Promise)在文档里一笔带过,但实际使用中却是救命稻草。我的建议是,养成随时console.log(univerInstance)的习惯,直接在浏览器控制台里探索其属性和方法,往往比查文档更快找到答案。毕竟,它是一个开源项目,源码就在那里,诚实且透明。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进