1. 项目概述Univer 是什么它解决的到底是什么问题Univer 这个名字乍一听容易让人联想到“universe”宇宙但实际它是一个开源、可嵌入、高度可定制的现代文档协同处理框架。它不是 Office 的替代品也不是某个大厂推出的闭源商业套件而是一套面向开发者、专为 Web 应用深度集成设计的 SDK 工具集——核心目标非常明确让任何 Web 产品无论你是做在线教育平台、低代码平台、CRM 系统、还是内部知识库都能在 3 天内不依赖 Office Online Server 或第三方云服务原生支持电子表格spreadsheets、文档documents、幻灯片presentations的渲染、编辑、协作与导出能力。关键词里反复出现的univer、SDK、Office、spreadsheets、PDF恰恰勾勒出它的技术坐标它站在 Web 前端技术栈的最前沿用 TypeScript 重写底层引擎把传统桌面级 Office 的复杂能力拆解成模块化、可插拔、可配置的 JavaScript 组件再通过标准化 API 暴露给业务层。我第一次接触 Univer 是在做一个企业级合同管理系统时。客户要求所有合同模板必须支持在线填写、公式计算、版本留痕、导出 PDF 归档并且不能跳转到外部编辑器——这意味着我们不能再用 iframe 嵌套 Office Online也不能让用户下载后本地编辑再上传。当时试了几个方案用 SheetJS 渲染 Excel 只能看不能算用 Handsontable 功能单薄不支持 Word/PPT自研光是公式引擎和 PDF 导出就预估要 6 个月。直到发现 Univer实测下来一个标准 React 项目npm install univerjs/core univerjs/engine-sheets univerjs/ui三行命令加 200 行初始化代码就能跑起一个带公式、条件格式、数据透视表雏形的在线表格还能一键导出 PDF。它解决的从来不是“怎么打开一个 Excel 文件”而是“如何让我的业务系统天然具备专业级文档生产力”。对开发者来说Univer 的价值在于它把“Office 能力”从黑盒变成了白盒。你不需要理解 COM 接口或 Windows API也不用部署复杂的服务器集群你只需要把它当成一组 UI 组件 一套数据模型 一个插件系统来使用。比如spreadsheets不是简单的 grid 表格它的单元格数据模型支持 rich text、formula、merge、style、comment 全维度PDF 导出不是截图拼接而是基于 Canvas 和 PDFKit 的矢量级渲染保留字体、公式、分页逻辑而所谓“Office 安装包安卓”“office永久激活”这类热搜词恰恰反衬出 Univer 的差异化定位——它压根不碰客户端安装、授权激活、系统兼容这些历史包袱只专注在浏览器这一层把文档能力做到极致轻量、极致可控。适合谁前端工程师、SaaS 产品经理、ToB 系统架构师以及所有厌倦了被 Office 生态绑架、想真正掌控文档体验的人。2. 核心架构与设计思路为什么是 Univer而不是其他方案2.1 三层解耦架构从“能用”到“可控”的根本差异Univer 的底层设计哲学可以用三个词概括Core-Engine-UI。这不是营销话术而是它区别于所有竞品的硬核结构。Core 层这是整个框架的“心脏”。它不包含任何 UI 代码只定义抽象的数据模型如Workbook,Worksheet,Cell,Range、状态管理机制基于 RxJS 的响应式流、以及跨平台通用的业务逻辑如公式解析引擎FormulaEngine、样式计算引擎StyleManager。你可以把它理解成 Excel 的“内存数据库”“计算内核”。所有操作——输入值、拖拽填充、插入行列——最终都转化为对 Core 层数据模型的原子化变更mutation并通过统一的状态快照snapshot机制保证可回溯、可协作。Engine 层这是“能力中枢”。它承接 Core 层的抽象指令将其翻译为具体平台的实现。目前官方提供了engine-sheets电子表格、engine-docs文字处理、engine-slides演示文稿三个引擎。每个引擎都封装了该类型文档特有的渲染逻辑、交互行为、导出能力。比如engine-sheets内置了完整的 Excel 兼容公式函数库SUM、VLOOKUP、XIRR 全部支持并实现了类似 Excel 的“智能填充”算法——当你拖拽一个含公式的单元格时它能自动识别引用模式并调整相对地址这个细节背后是上百个边界 case 的测试覆盖。UI 层这是“用户界面”。它完全与 Engine 解耦只负责接收 Engine 提供的视图数据view model并渲染成 DOM 或 Canvas。官方 UI 库univerjs/ui提供了一套 Material Design 风格的组件但你完全可以自己用 Vue 重写一套或者用纯 Canvas 实现高性能渲染——只要你的 UI 组件能订阅 Core 的状态流并调用 Engine 的 command API 即可。这种设计意味着你想换主题改 UI 层 CSS想支持暗色模式在 UI 层监听系统偏好想接入自己的权限系统在 Core 层拦截 mutation 请求。控制权始终在你手上。对比一下其他常见方案SheetJS 是纯解析库没有 UI没有实时协作Handsontable 是 UI 组件但公式、样式、导出能力弱且闭源高级功能收费Monaco Editor 改造成表格那得自己从零实现公式引擎和渲染管线。Univer 的三层架构本质上是把“文档能力”像操作系统一样分层——Core 是内核Engine 是驱动UI 是外壳。这种设计不是为了炫技而是为了解决一个现实痛点企业级应用需要稳定、可审计、可定制的文档能力而不是一个黑盒的“功能开关”。2.2 插件化系统为什么说 Univer 是“可生长”的 SDKUniver 的插件系统Plugin System是它作为 SDK 的灵魂所在。它不是简单的“添加按钮”而是提供了一套完整的生命周期管理、依赖注入、命令注册、事件总线机制。一个插件可以注册新的 UI 组件如一个“插入财务图表”的侧边栏扩展命令集如新增InsertChartCommand监听并拦截核心事件如onBeforeSetCellValue用于实现字段级权限控制提供新的数据服务如对接企业微信通讯录实现“同事”自动补全。我曾为一个 HR 系统开发过一个考勤统计插件。需求是在表格中选中某列点击插件按钮自动调用后端 API 获取该部门当月打卡数据并以柱状图形式插入到右侧空白区域。整个过程只用了 150 行代码先用registerCommand定义新命令再用createPlugin创建插件实例在onCommandExecuted里发起 API 请求最后调用getRenderManager().getScene().insertShape()把 SVG 图表插入画布。关键在于这个插件完全独立于 Univer 主体代码可以单独打包、灰度发布、按需加载。当客户提出“希望图表支持导出到 PDF”时我只需在插件里监听onExportPdf事件注入自己的图表渲染逻辑即可无需修改 Univer 一行源码。这种插件能力直接回应了热搜词里反复出现的“SDK”本质——它不是一个静态库而是一个运行时环境。你交付给客户的不是一个“带表格的网页”而是一个“可被持续增强的文档工作台”。这解释了为什么 Univer 在 ToB 领域快速渗透对于 SaaS 厂商它意味着产品能力可以模块化售卖基础版含表格高级版加 PDF 批注旗舰版集成 AI 总结对于 ISV它意味着一次集成多年演进不用每次升级都重写文档模块。2.3 PDF 导出的底层逻辑不是截图而是“重绘”热搜词中高频出现的 “PDF”、“web页面pdf打印”暴露了一个普遍痛点Web 页面导出 PDF 往往失真、分页错乱、中文乱码。Univer 的 PDF 导出方案彻底绕开了html2canvasjsPDF这种“截图式”方案的固有缺陷。它的原理是在engine-sheets中每一个单元格、每一段文本、每一个图形都有精确的坐标、尺寸、字体、颜色等元数据。导出时Univer 不会去抓取 DOM而是直接遍历 Engine 层的渲染树render tree将这些元数据逐条转换为 PDFKit 的绘图指令。例如一个合并单元格DOM 渲染时它可能是一个div包裹着spanPDF 导出时Univer 会读取其colSpan3, rowSpan2, contentQ3 销售额, fontMicrosoft YaHei, fontSize12然后调用pdfDoc.text(Q3 销售额, x, y, {font: yaheiFont, size: 12})并手动计算x,y位置确保居中。更关键的是分页处理。Univer 内置了智能分页算法它会预先计算每一页能容纳多少行遇到长文本或图片时自动判断是否需要断行、是否需要跨页甚至支持“标题行重复”即每页顶部显示表头。我在一个招投标系统中实测一份 50 页的工程量清单导出 PDF 后页眉页脚、页码、表格线、中文字体全部精准还原打印出来与 Excel 原生导出效果几乎无差别。这背后是大量针对中文排版的特殊处理字体回退当指定字体缺失时自动切换到 Noto Sans CJK、CJK 字符宽度校准中文字符占两个英文字符宽度、行高自适应避免中英文混排时行距过大。这种“元数据驱动”的 PDF 导出成本更高需要维护两套渲染逻辑但换来的是绝对的可控性。你可以在导出前通过插件动态修改某列的字体大小或隐藏某几行敏感数据这些操作都作用于数据模型而非 DOM因此 PDF 输出必然与你在界面上看到的一致。这正是企业级应用最需要的“所见即所得”保障。3. 核心功能实现与实操细节从零开始搭建一个可用的在线表格3.1 环境准备与依赖安装避开那些“已知不支持”的坑Univer 基于现代前端工具链对 Node.js 和构建工具版本有明确要求。根据官方文档和我踩过的坑强烈建议Node.js 版本必须 ≥ 18.0.0。低于此版本univerjs/core中使用的globalThis和AbortController会导致运行时错误。我曾在一个遗留项目中强行降级到 16.x结果FormulaEngine初始化失败报错ReferenceError: globalThis is not defined折腾半天才发现是 Node 版本问题。包管理器推荐 pnpmv8。Univer 依赖树极深超过 300 个子包npm 或 yarn 安装时极易出现ENOTEMPTY或ETARGET错误。pnpm 的硬链接机制能显著提升安装速度和磁盘利用率。构建工具Vitev4.5是最佳选择。Webpack 配置复杂且对 Univer 的 ESM 模块支持不佳而 Vite 的按需编译特性能让univerjs/ui这类大型 UI 库的 HMR热更新响应速度提升 3 倍以上。安装命令如下以 React Vite 为例# 创建项目 npm create vitelatest my-univer-app -- --template react cd my-univer-app pnpm install # 安装 Univer 核心依赖 pnpm add univerjs/core univerjs/engine-sheets univerjs/ui univerjs/protocol univerjs/undo-redo # 安装样式依赖Univer UI 使用 CSS-in-JS pnpm add emotion/react emotion/styled提示不要安装univerjs/engine-docs或univerjs/engine-slides除非你确实需要文字或 PPT 功能。它们会增加约 1.2MB 的打包体积。一个纯表格应用只装engine-sheets就够了。一个常被忽略的关键点是字体配置。Univer 默认使用sans-serif但在中文环境下这会导致 PDF 导出时字体缺失显示为方框。必须显式配置中文字体// src/main.tsx import { createUniver } from univerjs/core; import { UniverSheets } from univerjs/engine-sheets; import { UniverUI } from univerjs/ui; // 加载中文字体文件需提前放在 public/fonts/ 下 const loadChineseFont async () { const font await fetch(/fonts/NotoSansCJKsc-Regular.woff2).then(r r.arrayBuffer()); return new FontFace(Noto Sans CJK SC, font, { weight: 400 }); }; // 初始化 Univer 实例 async function initUniver() { const univer createUniver(); // 注册引擎 univer.installPlugin(new UniverSheets()); univer.installPlugin(new UniverUI()); // 注册中文字体必须在 render 前执行 const font await loadChineseFont(); document.fonts.add(font); await font.load(); // 确保字体加载完成 // 渲染到 DOM univer.render(document.getElementById(root)!); } initUniver();3.2 初始化与基础配置让表格“活”起来的 10 行代码初始化 Univer 的核心是创建Workbook工作簿并注入数据。这 10 行代码决定了你的表格是“空壳”还是“开箱即用”import { Workbook } from univerjs/core; import { Sheets } from univerjs/engine-sheets; // 1. 创建一个新的工作簿实例 const workbook new Workbook(); // 2. 获取默认工作表第一个 sheet const worksheet workbook.getActiveSheet(); // 3. 设置工作表名称 worksheet.setName(销售数据); // 4. 设置列宽单位像素 worksheet.setColumnWidth(0, 120); // A列 worksheet.setColumnWidth(1, 150); // B列 // 5. 设置行高单位像素 worksheet.setRowHeight(0, 40); // 第1行表头 // 6. 批量设置单元格值高效 worksheet.getRange(A1:C1).setValue([ [产品, 销量, 销售额] ]); // 7. 设置单元格样式加粗、居中、背景色 worksheet.getRange(A1:C1).setStyle({ bg: { rgb: f0f0f0 }, bl: { b: true }, // 加粗 al: { ha: center, va: middle } // 水平垂直居中 }); // 8. 插入公式注意公式字符串必须以 开头 worksheet.getCell(1, 0)?.setFormula(A2-B2); // C2单元格A2-B2 // 9. 设置数据验证下拉列表 worksheet.getDataValidationRule(D2:D100)?.setListData([合格, 待复核, 不合格]); // 10. 将工作簿注入 Univer 实例 univer.registerUnit(workbook);这段代码展示了 Univer 的几个关键优势批量操作getRange(A1:C1).setValue()比循环getCell(i,j).setValue()快 10 倍以上因为它直接操作底层数据矩阵公式即时生效setFormula后getCell(1,1)的getValue()会立即返回计算结果无需手动触发重算样式继承setStyle作用于 Range其子单元格会自动继承且支持 CSS-like 的简写bg,bl,al数据验证即插即用setDataValidationRule一行代码就启用了 Excel 级别的下拉校验。注意getCell(row, col)的索引从 0 开始row0是第 1 行col0是 A 列。这个设计与 Excel 的Cells(1,1)一致但与 HTML 表格的tr[0] td[0]相同新手容易混淆。我建议在团队内统一使用getRange(A1)这种 A1 记法避免索引错误。3.3 自定义命令与快捷键让业务逻辑无缝融入编辑体验Univer 的命令系统Command System是连接业务逻辑与 UI 的桥梁。它比 React 的useCallback更强大因为命令可以被菜单、工具栏、右键菜单、快捷键、甚至插件内部统一调用。假设你的业务需要一个“一键生成周报”的功能选中某行数据按CtrlShiftR自动在新 sheet 中生成汇总图表。实现步骤如下第一步定义命令// commands/generate-weekly-report.command.ts import { ICommand, ICommandService, IUniverInstanceService } from univerjs/core; import { Sheets } from univerjs/engine-sheets; export const GENERATE_WEEKLY_REPORT_COMMAND_ID sheets.generate-weekly-report; export const GenerateWeeklyReportCommand: ICommand { id: GENERATE_WEEKLY_REPORT_COMMAND_ID, type: command, handler: async (accessor) { const commandService accessor.get(ICommandService); const univerInstanceService accessor.get(IUniverInstanceService); const sheets accessor.get(Sheets); // 获取当前活动工作表和选区 const workbook univerInstanceService.getCurrentUnitForType(Workbook); const worksheet workbook?.getActiveSheet(); const selection worksheet?.getSelections()?.[0]; if (!worksheet || !selection) return false; // 从选区提取数据例如选中第3行 const dataRange worksheet.getRange(selection.startRow, 0, 1, 5); // A3:E3 const rowData dataRange.getValues()[0]; // [产品, 销量, 销售额, ...] // 创建新工作表 const newSheet workbook?.insertSheet(); newSheet?.setName(周报- new Date().toISOString().slice(0,10)); // 写入汇总数据 newSheet?.getRange(A1).setValue(本周汇总); newSheet?.getRange(A2:B2).setValue([[总销量, rowData[1]], [总销售额, rowData[2]]]); return true; } };第二步注册命令与快捷键// main.tsx import { GenerateWeeklyReportCommand, GENERATE_WEEKLY_REPORT_COMMAND_ID } from ./commands/generate-weekly-report.command; import { ShortcutKey } from univerjs/core; // 在 univer 实例创建后注册 univer.registerCommand(GenerateWeeklyReportCommand); // 绑定快捷键CtrlShiftR univer.registerShortcut({ shortcutKey: { key: r, ctrl: true, shift: true, }, commandId: GENERATE_WEEKLY_REPORT_COMMAND_ID, });第三步添加到 UI可选// components/CustomToolbar.tsx import { useCommand } from univerjs/core; import { Button } from univerjs/ui; export function CustomToolbar() { const executeCommand useCommand(GENERATE_WEEKLY_REPORT_COMMAND_ID); return ( Button onClick{() executeCommand()}生成周报/Button ); }这套流程的精妙之处在于命令的handler是纯函数不依赖任何 React state因此它可以被任何上下文调用——无论是用户点击按钮、按下快捷键还是另一个插件在后台自动触发。这保证了业务逻辑的纯粹性和可测试性。我在一个审计系统中就用这种方式实现了“自动标记高风险字段”当用户双击某个单元格时命令会检查其值是否超过阈值如果是则自动添加红色批注并高亮整行。整个逻辑与 UI 渲染完全解耦单元测试覆盖率高达 95%。3.4 PDF 导出与定制超越“一键导出”的精细控制Univer 的 PDF 导出 API (exportToPdf) 看似简单但其参数选项之丰富足以满足企业级定制需求。一个典型的导出调用如下import { PdfExport } from univerjs/engine-sheets; // 获取当前工作簿 const workbook univerInstanceService.getCurrentUnitForType(Workbook); const pdfExport new PdfExport(workbook); // 配置导出选项 const options { // 页面设置 pageSize: A4, // A3, A4, Letter orientation: portrait, // landscape margin: { top: 20, right: 15, bottom: 20, left: 15 }, // 单位毫米 // 内容范围 range: A1:G50, // 只导出指定区域 includeGridlines: true, // 是否显示网格线 includeHeaders: true, // 是否包含列标题和行号 // 字体与样式 font: Noto Sans CJK SC, // 必须是已加载的字体名 fontSize: 10, // 高级选项 compress: true, // 启用 PDF 压缩 embedFonts: true, // 将字体嵌入 PDF确保跨设备显示一致 }; // 执行导出 pdfExport.export(options).then((blob) { const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download 销售报表.pdf; a.click(); URL.revokeObjectURL(url); });其中embedFonts: true是中文 PDF 不乱码的关键。Univer 会将Noto Sans CJK SC字体的 WOFF2 数据 Base64 编码后嵌入 PDF这样即使用户电脑没有安装该字体PDF 也能正确显示。但这也带来一个权衡嵌入字体会使 PDF 体积增大 1-2MB。如果导出的是纯数字报表无中文可以关闭embedFonts并指定系统字体SimSun体积能减少 80%。另一个实用技巧是动态水印。很多企业需要在导出的 PDF 上添加“机密”、“草稿”等水印。Univer 允许在导出前注入自定义绘制逻辑options.customDraw (pdfDoc, page, width, height) { // 在每页底部中央绘制半透明水印 pdfDoc.save(); pdfDoc.setFillOpacity(0.1); pdfDoc.setFontSize(60); pdfDoc.text(机密, width / 2, height / 2, { align: center, baseline: middle, angle: -30, }); pdfDoc.restore(); };这段代码会在 PDF 的每一页中心以 30 度倾斜、10% 透明度绘制“机密”二字。customDraw回调函数接收 PDFKit 的pdfDoc实例你可以调用 PDFKit 的任意 API绘制文本、线条、图片甚至插入二维码。这比在导出后用第三方库二次处理 PDF 要高效得多因为它是导出流水线的一部分无需额外 IO。4. 常见问题排查与避坑指南那些文档里不会写的实战经验4.1 公式计算异常为什么SUM(A1:A10)返回#VALUE!这是新手最常遇到的问题原因往往不是公式写错而是数据类型不匹配。Univer 的公式引擎严格遵循 Excel 规范SUM函数只对数值型单元格求和。如果 A1:A10 中混有文本哪怕只是空格、日期、布尔值SUM就会返回#VALUE!。排查步骤选中报错单元格右键 → “查看公式”检查A1:A10区域用getCell(i,0).getType()查看每个单元格类型如果发现type CellValueType.STRING但内容是数字说明它被当作文本存储了。解决方案方法一推荐强制转换。在公式中使用SUM(VALUE(A1:A10))VALUE函数会尝试将文本转为数字方法二批量修正。选中 A1:A10右键 → “设置单元格格式” → “数值”然后按F2Enter重新确认每个单元格方法三预防导入时指定类型。如果你是通过importFromJson加载数据确保value字段是数字类型而不是字符串123。实操心得我在一个物流系统中客户上传的 Excel 文件里运单号列全是文本因为含字母但运费列却被误设为文本格式。结果SUM全部失效。后来我们加了一个预处理插件在文件导入后自动扫描列名如“运费”、“重量”对匹配列的所有单元格调用setValue(Number(value))彻底杜绝此类问题。4.2 PDF 导出中文乱码字体加载时机的致命陷阱乱码问题 90% 源于字体加载时机错误。常见错误写法// ❌ 错误在 render 之后才加载字体 univer.render(root); document.fonts.load(Noto Sans CJK SC).then(() { // 此时 PDF 导出可能已经触发字体未就绪 });正确做法字体加载必须在univer.render()之前完成必须调用await font.load()而不仅仅是document.fonts.add(font)对于多字体场景如同时需要中英日韩要等待所有字体加载完毕。// ✅ 正确Promise.all 确保所有字体就绪 const fonts [ fetch(/fonts/NotoSansCJKsc-Regular.woff2).then(r r.arrayBuffer()).then(buf new FontFace(Noto Sans CJK SC, buf)), fetch(/fonts/Roboto-Regular.woff2).then(r r.arrayBuffer()).then(buf new FontFace(Roboto, buf)), ]; await Promise.all(fonts.map(font { document.fonts.add(font); return font.load(); })); univer.render(root); // 此时才渲染4.3 性能瓶颈当表格超过 10 万单元格时怎么办Univer 的默认渲染策略是“全量渲染”即把整个工作表的单元格都生成 DOM。这对于小表格 5000 单元格毫无压力但一旦达到 100x100010 万单元格页面就会明显卡顿。优化方案启用虚拟滚动Virtual Scrolling这是最有效的方案。Univer 的univerjs/engine-sheets内置了VirtualScrollingPlugin只需一行代码启用import { VirtualScrollingPlugin } from univerjs/engine-sheets; univer.installPlugin(new VirtualScrollingPlugin());启用后Univer 只渲染可视区域内的单元格约 20x20滚动时动态替换 DOM内存占用从 300MB 降至 50MBFPS 稳定在 60。禁用非必要功能关闭includeGridlines、showRowHeader、showColumnHeader等视觉元素能减少 30% 渲染开销分页加载数据不要一次性setRangeValue10 万行而是分批次如每次 1000 行用requestIdleCallback控制节奏。注意虚拟滚动会禁用某些依赖完整 DOM 的功能如“查找替换”Find Replace的高亮显示。如果业务强依赖此功能建议采用“分 sheet 策略”将大数据集拆分为多个工作表如按月份每个 sheet 控制在 1 万单元格以内。4.4 插件开发调试如何快速定位“命令不生效”的问题插件命令失效通常有三个层级的原因层级检查点快速验证方法注册层命令 ID 是否拼写一致是否在univer.registerCommand()中注册在浏览器控制台执行univer._commandRegistry._commands.size看数量是否增加绑定层快捷键是否被浏览器或其他插件拦截在命令handler开头加console.log(command triggered)看是否输出执行层handler函数是否返回true是否抛出未捕获异常将handler包裹在try/catch中catch里console.error(e)一个真实案例某客户反馈“导出 PDF 按钮点击无反应”。我检查发现按钮的onClick调用了executeCommand但控制台没有任何日志。深入调试后发现executeCommand返回的是一个Promise而按钮的onClick是同步函数Promise 被静默丢弃了。修复方案很简单onClick{async () await executeCommand()}。4.5 与现有系统集成如何让 Univer 表格“看起来像你家的产品”Univer UI 库默认是 Material Design 风格但企业系统往往有自己的设计规范。定制主题有两种方式CSS 变量覆盖轻量级Univer UI 大量使用 CSS 变量只需在全局 CSS 中覆盖:root { --univer-color-primary: #1890ff; /* 主色 */ --univer-color-bg: #f8f9fa; /* 背景色 */ --univer-font-family: PingFang SC, Microsoft YaHei, sans-serif; }主题插件深度定制创建一个ThemePlugin在onReady生命周期中动态注入自定义 CSS并重写部分 UI 组件的渲染逻辑。例如将默认的圆形图标按钮改为方形带边框样式。最后分享一个小技巧Univer 的IUndoRedoService撤销重做服务默认保存 50 步。在长时间编辑的场景下这可能导致内存泄漏。我通常会根据业务需求在初始化时动态调整univer.installPlugin(new UndoRedoPlugin({ maxStep: 20 })); // 降低到 20 步这样既保证了基本的撤销能力又避免了内存持续增长。毕竟用户很少需要撤销到 30 步之前的操作。