周五下午四点半你把一张三十多个节点的手绘架构图在浏览器里画完顺手点了根烟回来发现标签页被系统回收了。刷新之后白色画布上空空如也那种感觉比丢代码还难受——代码好歹还有 git白板上的东西是真的没地方找回来。后来我给团队内部的画板工具补上了导入导出和自动持久化才把这类事故彻底堵死。这篇聊的就是Excalidraw 导入导出 JSON 数据、实现配置持久化这一整套东西。它不是简单地调一个exportToBlob存成图片那么粗暴而是要把白板里的图形元素、视图状态、图片资源原封不动地序列化成一份可二次编辑的.excalidraw文件再在需要的时候完整还原回来。做这套东西的人通常是正在给自家产品嵌入白板能力的前端或者需要把画板内容存进自己后端、做版本管理的团队。不管你是刚接触 Excalidraw 包的新手还是已经能用initialData把画布跑起来、但一碰到刷新就丢就抓瞎的老哥下面这些内容应该都能直接抄。1. 先撕开 .excalidraw 这个 JSON看清里面装了什么很多人第一次打开.excalidraw文件的时候会有点意外就这一个纯文本 JSON没有二进制魔数没有压缩包用编辑器就能改。这是好事意味着持久化的门槛其实很低但也是坑因为随手改一个字段就可能让整个场景加载失败。1.1 顶层四个字段各管一摊事一个标准的导出文件长这样结构非常朴素{ type: excalidraw, version: 2, source: https://excalidraw.com, elements: [], appState: {}, files: {} }type是固定值用来做快速格式识别读到不是excalidraw的就可以直接提示这不是画板文件。version是 schema 版本号不是你的业务版本——这点特别容易搞混后面第 5 节会专门说。source标识产出方做二次分发的时候建议改成你自己的域名或者产品标识方便排查线上问题来自哪个入口。真正干活的是后面三个字段。我习惯把它们理解成画了什么怎么看用了哪些素材三层elements是内容的全部appState是视角和默认配置files是外挂的图片资源池。这三个字段必须成套处理缺了任何一个还原出来的场景都会缺一块。提示elements、appState、files三者在 Excalidraw 里是强关联的。比如一条 arrow 的终点绑定在某个 rectangle 上它们的 id 在boundElements里互相引用再比如一个 image 元素的fileId指向files里的某个键。单独拿其中一个字段去存、去还原必然出问题。1.2 elements 数组每个图形都是一条自描述的记录elements是一个扁平数组画布上每一个图形——不管是矩形、椭圆、箭头、手绘笔迹还是文本框——都是数组里的一个对象。它没有树结构图层关系靠groupIds数组和元素在数组里的顺序来表达后出现的盖住先出现的。一条最小可用的 rectangle 大概这些字段是绕不开的id唯一标识绑定的锚点、type图形类型、x/y/width/height几何位置、angle旋转弧度、strokeColor/backgroundColor/fillStyle/strokeWidth/strokeStyle/roughness/opacity外观样式。这些基本是所见即所得不用多解释。真正值得注意的是这几组字段组作用处理要点seedversionNonce手绘风格的随机种子、版本冲突抖动必须原样保留改了会导致图形外观细微变化versionupdated元素级版本号与时间戳协作和增量同步依赖它别自作主张重写boundElements该元素上绑定了哪些箭头/文本双向引用另一端箭头里有startBinding/endBindinggroupIds编组关系链数组顺序代表嵌套层级不能打乱frameId所属画框画框是容器语义删元素时要注意孤立引用customData业务自定义载荷官方不解析但会原样保留是做扩展的入口seed和roughness配合决定了手绘线条那种抖的效果。我踩过一次坑为了减小存储体积把seed当作无用字段裁掉了结果重新加载后所有线条的抖动形态都变了虽然图形位置没错但视觉上手感明显不对——原来那版是随手画的松弛感裁完变成了偏规整的机械感。所以凡是看起来没什么用的随机类字段一律保留。customData是我最推荐的一个扩展点。它不会被官方的渲染逻辑消费导出导入全程原样透传。我们在项目里把业务侧的节点 ID、审批状态、评论锚点全塞进customData这样一份 JSON 既是画板文件又带着业务元数据回传后端时不用再做元素 id 到业务 id 的映射表。1.3 appState被九成开发者忽略的视角配置绝大多数教程讲到elements和files就收尾了appState一笔带过结果用户反馈我明明存了怎么打开之后位置全乱了。原因就在这里。appState里跟持久化强相关的可以分成三类。第一类是视图状态scrollX、scrollY、zoom。这三个决定了用户打开文件时看到画布的哪一块、放大到多少。一份导出时视口在右下角、放大到 200% 的文件如果导入时scrollX/scrollY归零、zoom回到 1用户会以为文件坏了——明明画了东西打开一片空白。所以这三个值必须一起存、一起还原。第二类是主题与背景themelight / dark、viewBackgroundColor、gridSize、gridStep、gridModeEnabled。这些决定了文件长什么样。特别是viewBackgroundColor如果有人做了深色底板配浅色线条还原时背景色丢了线条直接看不见。第三类是绘制默认值currentItemStrokeColor、currentItemBackgroundColor、currentItemFontSize这一类currentItem*开头的字段。它们记录的是用户上一次选了什么笔不影响已有图形的渲染但影响用户接下来画的东西。做工具型产品的时候把这些一起持久化体验会连贯很多——用户昨天调好的品牌色今天打开还在。这里有个必须处理的细节appState里存在不可序列化的字段典型的是collaborators一个 Map 结构以及各种回调和内部引用。直接JSON.stringify(appState)要么报循环引用要么把函数变成undefined。官方提供了清理函数导出侧要用它import { serializeAsJSON } from excalidraw/excalidraw; // excalidrawAPI 来自 Excalidraw ref{...} / 或者 useExcalidrawAPI() function dumpScene(api) { const elements api.getSceneElements(); const appState api.getAppState(); const files api.getFiles(); // 第四个参数 local 表示这是本地文件会走 cleanAppStateForExport 清理 return serializeAsJSON(elements, appState, files, local); }serializeAsJSON内部会做两件事把appState裁剪成白名单字段以及把files里的资源整理成可落盘的形态。不要自己手写JSON.stringify({ elements, appState, files })那是 90% 的导出的文件导入后状态不对的根源。1.4 files图片资源单独开的一个桶如果是纯手绘图形files就是空对象。但只要画布里贴了图片files就会膨胀起来。结构是这样的键是文件 id跟 image 元素的fileId对应值包含id、mimeType、data、created、lastRetrieved。其中data是一段完整的 base64 data URL形如data:image/png;base64,iVBOR...。这个设计有利有弊。好处是单文件自包含——一个.excalidraw文件发出去对方打开就能看到全部图片不用附带资源目录。坏处是体积膨胀得厉害base64 编码本身就有约 33% 的膨胀率一张 2MB 的截图编码进去接近 2.7MB再乘上 N 张。我实测过一组数据可以做个参考原始图片体积元素数量导出 JSON 体积首次加载耗时中端笔记本0 KB200约 120 KB 100 ms1.5 MB3 张截图210约 2.3 MB约 400 ms12 MB8 张长图240约 16.5 MB约 2.8 s所以后面第 4 节讲存储的时候我会特别强调承载大文件不要用 localStorage那不是它该干的活。2. 导出这一端官方函数之外你还得补的手工活导出看起来是最简单的一步调个函数就完事。但真到产品里你会陆续遇到要不要带视口图片要不要压缩导出文件怎么区分版本这些问题官方函数一个都不管。2.1 serializeAsJSON 的边界在哪里先说清楚它能干什么把当前场景序列化成一份符合 schema 的 JSON 字符串保证导入侧能被官方还原逻辑正确解析。就这些。它不负责的事包括不负责挑文件保存浏览器下载、上传后端都得你自己接不负责把视口重置成整图适配它老老实实存你当前的scrollX/scrollY/zoom不负责压缩不负责加业务元数据也不负责帮你做版本号管理。我第一次做的时候以为传进去什么就存什么结果用户在画布右下角缩放到 300% 存了一份发给同事同事打开直接懵了——满屏只有一个小角。后来我在导出前加了一步视口归一化function exportWithFitViewport(api) { const elements api.getSceneElements(); const appState api.getAppState(); // 不带视口导出时把视图状态重置为内容自适应的起点 const appStateForExport { ...appState, scrollX: 0, scrollY: 0, zoom: { value: 1 }, // 注意 zoom 在部分版本里是 { value: number } 对象 }; return serializeAsJSON(elements, appStateForExport, api.getFiles(), local); }这里有个版本差异要提醒zoom字段在不同版本里形态不一样早期是纯数字后来变成了{ value: number }的包装对象。写这段代码之前先console.log打印一下你实际用的版本里getAppState().zoom长什么样不要照抄。我因为这个问题在白板上排查了小半天最后发现存进去的zoom结构不对导入后还原逻辑直接忽略了它。至于到底该不该保留视口取决于文件用途归档/交付场景建议重置让对方打开就看到全部内容会话恢复场景必须原样保留因为用户就是在那个位置继续工作的。这两个场景要用不同的导出参数别一套配置打天下。2.2 只导出画框内内容与全量导出的取舍画框frame是 Excalidraw 里很实用的容器概念做汇报或者分章节的时候经常用。导出的时候经常有这样一个需求我只想导出某一个画框里的内容。官方导出没有直接的按 frame 过滤参数得自己筛。元素的frameId字段指向所属画框的 id所以思路是function exportFrameOnly(api, frameId) { const all api.getSceneElements(); const inFrame all.filter( (el) el.frameId frameId || el.id frameId ); // 注意绑定到外部元素的箭头可能会指向画框外需要决定是否保留 const ids new Set(inFrame.map((el) el.id)); const cleaned inFrame.map((el) ({ ...el, boundElements: el.boundElements?.filter((b) ids.has(b.id)) ?? null, })); return serializeAsJSON(cleaned, api.getAppState(), api.getFiles(), local); }这段代码里最容易翻车的是boundElements。假设画框里有一个矩形矩形上绑了一条箭头指向画框外的另一个矩形。你按frameId一筛矩形留下了箭头留下了如果箭头本身也在画框内但箭头终点的目标元素被筛掉了。这时场景里就出现一个断头箭头——渲染不会崩但点击它、拖动它的时候行为会很怪。我的处理原则是筛选后统一清理悬空引用把boundElements、startBinding、endBinding里指向不存在 id 的条目全部去掉。宁可让箭头变成自由箭头也不要留悬空绑定。这一点在官方restoreElements里也会做部分修复但不能指望它兜住所有情况导出侧先清干净更稳。2.3 图片资源的体积控制策略前面说了 base64 会膨胀。如果你们的产品允许用户贴截图导出体积很容易失控。我试过几种办法按效果排序第一种导出前统一压缩。在图片进入画布之前就压比导出时再压更划算。用 canvas 重绘一遍控制长边不超过 1600px质量 0.8 输出 webp 或 jpeg。这一步能把一张 3MB 的手机截图压到 200KB 以内肉眼几乎看不出差别。async function compressImage(file, maxEdge 1600, quality 0.8) { const bitmap await createImageBitmap(file); const scale Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height)); const canvas new OffscreenCanvas( Math.round(bitmap.width * scale), Math.round(bitmap.height * scale) ); const ctx canvas.getContext(2d); ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height); const blob await canvas.convertToBlob({ type: image/webp, quality }); return new File([blob], file.name, { type: image/webp }); }OffscreenCanvas的好处是不占 DOM在 Worker 里也能跑不会阻塞主线程。IE 系列和部分老版本浏览器不支持得做降级回退到普通canvastoBlob。第二种导出时把 files 剥离出去单独存。如果你们后端有对象存储完全可以把files里的每张图上传换成一个短 URL在 JSON 里只留引用。这样导出的 JSON 主体可能只有几十 KB加载速度提升非常明显。代价是这个文件不再自包含发给外部的时候得打成一个 zip或者保证对方能访问你们的图床。内部系统用这套很舒服对外交付我建议还是自包含优先。第三种导出时提示体积。在触发下载前算一下JSON.stringify出来的长度超过阈值比如 5MB弹个提示告诉用户文件较大加载可能较慢建议减少图片或压缩后重试。这不是技术方案但对降低投诉率非常有效。2.4 挂载业务元数据别另外开一个文件很多人做持久化的时候习惯一个 JSON 存图形一个 JSON 存业务信息结果两份数据不同步删了文件没删元数据乱七八糟。appState里有个name字段可以存文件标题。而更自由的扩展位置是元素级的customData以及场景级的……说实话场景级没有一个官方的自定义字段。我常用的做法是在导出后包一层信封function buildEnvelope(api, meta) { const sceneJSON JSON.parse(dumpScene(api)); return JSON.stringify({ // 官方场景字段原样保留保证能被原生方式导入 type: sceneJSON.type, version: sceneJSON.version, source: sceneJSON.source, elements: sceneJSON.elements, appState: sceneJSON.appState, files: sceneJSON.files, // 业务信封放在额外字段里 _meta: { schema: 1, title: meta.title, ownerId: meta.ownerId, updatedAt: Date.now(), revision: meta.revision, }, }); }关键在于不要改动顶层六个字段的语义。多出来的_meta在官方导入逻辑里会被忽略不影响兼容性而你们自己的后端在读取时可以优先看_meta拿不到就降级当普通文件处理。这样同一份文件既能被原生 Excalidraw 打开也能被你们的系统识别出业务属性一举两得。3. 导入这一端restore 不是可选项导出只是把数据存下来导入才是决定成败的地方。我在代码评审里见过最多的错误写法就是把后端返回的 JSON 直接JSON.parse之后塞给组件的initialData然后祈祷它能正常显示。3.1 为什么不能 JSON.parse 之后直接塞进场景Excalidraw 加载数据时有一整套修复与迁移流程要跑。跳过这套流程会踩到至少四类问题第一类是缺失字段补全。老版本导出的文件可能没有customData、没有frameId、boundElements的形态也不一样。渲染层读这些字段时会按新结构假设读到undefined就崩或者渲染异常。第二类是数值合法性校正。width为负数、opacity超出 0-1、angle不是有限数这些在外部手工编辑过的文件里非常常见。修复逻辑会做 clamp。第三类是绑定关系重建。箭头和图形的绑定依赖双向引用如果其中一个方向在传输过程中被裁掉比如后端做了字段白名单修复逻辑会尝试根据几何位置重新推断绑定关系。第四类是版本迁移。excalidraw 的 schema 迭代过好几轮restore会按version字段走对应的迁移分支。所以导入必须走官方还原入口import { restore } from excalidraw/excalidraw; function loadScene(rawText, currentAppState) { const parsed JSON.parse(rawText); // 如果是信封格式先拆出场景部分 const sceneData { elements: parsed.elements, appState: parsed.appState, files: parsed.files, }; const { elements, appState, files } restore( sceneData, { ...currentAppState }, // 本地当前状态作为兜底默认值 null ); return { elements, appState, files }; }第二个参数localAppState很关键它是兜底值。文件里的appState只覆盖它能覆盖的部分没覆盖到的字段用本地当前状态填。这样就不会出现导入一个老文件把当前的主题、语言、默认笔全冲掉了的情况。我一开始直接传null结果每次导入文件都感觉像是重置了全部设置用户体验很割裂。注意restore的函数签名在不同版本间有过调整第三个参数是自定义的元素还原函数通常传null用内置实现即可。升级依赖的时候务必看一眼CHANGELOG这类签名变化不会报编译错因为参数可选但行为会变。3.2 restoreElements 单独用增量合并的场景有一种情况restore不够用你不想覆盖当前画布而是要把一份文件的内容合并进来。比如从模板库插入一个图块、协作时接收对方的一段改动、版本回滚只回滚某几个元素。这时候要用restoreElements它只处理元素数组不碰appStateimport { restoreElements } from excalidraw/excalidraw; function mergeFromFile(api, rawText) { const parsed JSON.parse(rawText); if (!Array.isArray(parsed.elements)) return; const repaired restoreElements( parsed.elements, api.getSceneElements(), // 传入本地元素用于冲突判定 { repairBindings: true } ); // 关键给新来的元素换 id避免和本地 id 撞车 const idMap new Map(); const remapped repaired.map((el) { const newId nanoid(); idMap.set(el.id, newId); return { ...el, id: newId }; }); // 同步重写绑定引用 const fixed remapped.map((el) ({ ...el, boundElements: el.boundElements?.map((b) ({ ...b, id: idMap.get(b.id) ?? b.id, })) ?? null, })); api.updateScene({ elements: [...api.getSceneElements(), ...fixed], }); // 图片资源也要一起并进来 if (parsed.files) api.addFiles(Object.values(parsed.files)); }这段代码里idMap那一步是血的教训。我第一次做合并的时候没换 id结果本地正好有个同名 id 的矩形从同一份模板复制出来的合并完直接出现两个一模一样的图形叠在一起选中其中一个另一个也跟着高亮用户以为工具出 bug 了。repairBindings这个选项也值得开。它会在元素被重新分配 id 之后尝试修复绑定关系。但我实测下来它修得不完全尤其是箭头指向文本容器这种复杂情况所以上面代码里还是手动重写了一遍boundElements。3.3 版本不匹配时怎么做降级version字段不是闹着玩的。假设用户手上有一份两年前导出的文件version: 1你现在用的库已经是version: 2的 schemarestore会走迁移路径。绝大多数情况下能平滑迁移但总有边缘情况。我的实践是三层防护第一层解析前置校验。读文件后先检查type excalidraw、elements是不是数组。不满足就明确报错别硬着头皮往下走。第二层try/catch包裹restore。它内部如果有未捕获的异常会直接打断整个加载流程用户那边表现为点了导入没反应。必须捕获并把原始错误打到日志里。第三层降级恢复。如果restore失败尝试只恢复elements丢掉appState再不行就尝试逐元素restoreElements把能救的救回来。虽然会丢失部分信息但总比一片空白强。function safeLoad(rawText) { try { const parsed JSON.parse(rawText); if (parsed?.type ! excalidraw) throw new Error(FORMAT_MISMATCH); return { ok: true, data: restore(parsed, null, null) }; } catch (err) { console.warn([scene] restore failed, fallback, err); try { const parsed JSON.parse(rawText); const elements restoreElements(parsed.elements ?? [], null, {}); if (elements.length 0) { return { ok: true, data: { elements, appState: {}, files: {} }, degraded: true }; } } catch (err2) { console.error([scene] fallback failed, err2); } return { ok: false, error: String(err) }; } }返回结果里带一个degraded标记UI 上就能提示文件版本较旧部分样式可能丢失。这种坦诚的提示比静默失败或者静默丢数据要好得多。3.4 导入失败的错误分类与用户提示用户看到导入失败这四个字第一反应永远是你的产品有 bug。所以错误信息要分层让用户知道下一步该干什么。错误类型触发条件给用户的提示你能做什么格式不符type字段不对该文件不是画板文件请确认后缀为 .excalidraw检查是不是选错了文件JSON 语法错误JSON.parse抛异常文件内容已损坏无法读取引导用户从备份恢复元素全被过滤restore后elements为空文件中没有可显示的图形提示可能是空文件部分元素丢失校验后有异常元素已加载但有 N 个元素无法还原输出警告日志记录元素 id体积超限超过前端设定的阈值文件较大加载可能需要几秒展示进度条别让用户干等我把这套提示做进产品之后客服那边的导入失败投诉量降了一大截——因为大部分情况用户自己就能判断了比如选了张 png 当画板文件。4. 配置持久化localStorage 还是 IndexedDB这是个真问题聊完导入导出回到标题后半段配置持久化。很多人一上来就localStorage.setItem(excalidraw, JSON.stringify(scene))跑通 demo 没问题上生产第二天就收到Safari 报错 Exceeded the quota。4.1 存储选型的硬约束对比先摆数据再讲结论。维度localStorageIndexedDB结论容量上限通常 5 MB部分浏览器按域名共享按可用磁盘配额通常几百 MB 起大场景必须 IndexedDB读写方式同步阻塞主线程异步事务式大 JSON 用 IndexedDB数据类型只能存字符串支持 Blob、ArrayBuffer、对象图片资源用 IndexedDB写入性能序列化写盘同步10MB 级会明显卡顿批量事务性能好得多—清理策略用户清缓存就没了同样会被清可主动申请持久化都要做备份提示结论很清楚元数据和最近一次小快照放 localStorage完整场景和图片资源放 IndexedDB。这种混合方案我用在好几个项目里很稳。localStorage 那部分只存最后打开的是哪个文件 ID上次的缩放级别主题偏好这类小字段加起来不到 2KB永远不会超限。真正的场景数据全部走 IndexedDB。4.2 自动保存的节流与去抖设计Excalidraw 的场景变化非常频繁拖动一个矩形会产生几十上百次状态更新。如果每次变化都写一遍 IndexedDB浏览器会被写操作拖慢。我用的策略是去抖 空闲写入 强制落盘兜底三层组合function createAutoSaver(api, store) { let timer null; let pending false; const flush async () { if (!pending) return; pending false; const text dumpScene(api); await store.put(scene:latest, text); }; const schedule () { pending true; clearTimeout(timer); timer setTimeout(() { if (requestIdleCallback in window) { requestIdleCallback(() flush(), { timeout: 3000 }); } else { flush(); } }, 1200); }; // 页面隐藏时强制落盘这是最后一道保险 document.addEventListener(visibilitychange, () { if (document.visibilityState hidden) flush(); }); window.addEventListener(pagehide, () flush()); return schedule; }几个参数的理由去抖窗口 1200ms是因为正常人画图的停顿时长中位数大概在 1 秒左右太短会把连续操作拆成多次写入太长又容易丢数据。requestIdleCallback的timeout: 3000是保底——如果浏览器一直忙3 秒后强制执行不让数据一直悬着。visibilitychange和pagehide这两个监听是必须的没有任何商量余地。移动端切后台、用户直接关标签页这两种情况下只有这两个事件能救你。beforeunload不能做异步写入靠不住。4.3 多画板管理与索引表设计只存一个场景是不够的实际产品里用户会有多个画板。IndexedDB 里我一般开两个 storeconst DB_NAME whiteboard; const DB_VERSION 1; function openDB() { return new Promise((resolve, reject) { const req indexedDB.open(DB_NAME, DB_VERSION); req.onupgradeneeded () { const db req.result; if (!db.objectStoreNames.contains(scenes)) { db.createObjectStore(scenes, { keyPath: id }); } if (!db.objectStoreNames.contains(assets)) { db.createObjectStore(assets, { keyPath: fileId }); } }; req.onsuccess () resolve(req.result); req.onerror () reject(req.error); }); }scenesstore 里每条记录包含id、title、updatedAt、elementCount、thumbnail一个小的 dataURL 缩略图和payload完整 JSON 字符串。首页列出所有画板时只查索引字段不读payload这样即使有 50 个画板也能秒开。这里有个小技巧把 payload 单独拆一张表。列表页只查元数据表打开某个画板时才去 payload 表读完整数据。否则一次getAll会把几十 MB 全拉进内存页面直接卡死。我就是在这个点上栽过一次首页加载从 200ms 涨到 4 秒最后发现是索引查询把整个对象都带出来了。4.4 崩溃恢复会话快照的滚动保留自动保存只留最新一份是不够的。如果用户误删了一大片内容自动保存会在一秒内把好数据覆盖掉撤销栈又刚好被清空那就真没救了。所以我会额外维护三个滚动快照槽async function rotateSnapshot(store, text) { const slots [snap:1, snap:2, snap:3]; const current await store.get(slots[0]); if (current text) return; // 内容没变不占槽位 // 整体后移最老的被挤掉 for (let i slots.length - 1; i 0; i--) { const prev await store.get(slots[i - 1]); if (prev ! null) await store.put(slots[i], prev); } await store.put(slots[0], text); }三个槽配合一个时间间隔比如每 3 分钟或者每 20 次操作轮转一次能给用户提供大约 10 分钟的后悔窗口。产品里加一个恢复历史版本的入口用户会非常有安全感。这个功能实现成本不到半天但价值远超半天。提示快照轮转判断要基于内容的哈希而不是时间戳。如果用户只是滚动了一下视口appState里的scrollX/scrollY变了内容哈希也会变白占一个槽位。更细的做法是对elements部分单独计算哈希appState的变化不触发轮转。5. 我在实测中踩到的几个坑以及它们的排查链路前面几节讲的是应该怎么做这一节讲讲为什么我一开始做错了。这些坑基本都不是看文档能提前避开的。5.1 appState 里的不可序列化字段从报错到定位现象点导出没反应控制台没有明显报错只在被打断的 Promise 里看到一句Converting circular structure to JSON。第一反应是场景里有循环引用。我把elements单独 stringify 了一遍没问题单独 stringifyfiles也没问题。那问题只能在appState里。接下来我把appState逐 key 打印用二分法定位function findUnserializable(appState) { for (const key of Object.keys(appState)) { try { JSON.stringify(appState[key]); } catch (e) { console.warn(不可序列化字段:, key, e.message); } } }结果一下就打出来了collaborators是个 MapMap里有指向appState自身的引用。另外还有几个字段是函数类型事件回调stringify 会静默变成undefined——这个更阴险因为它不报错只是默默丢失。修复方式很简单别自己写序列化用serializeAsJSON。但这一轮的排查价值在于后来我在做场景 diff判断内容有没有变化的时候同样的问题又出现了一次那次我用的是JSON.stringify做深比较同样炸在collaborators上。所以现在我的 diff 工具函数里固定先做一个stripAppState的清洗。5.2 中文文本与换行符导致的 diff 误判这个坑很隐蔽。我做了个内容变化才触发自动保存的优化用字符串长度和哈希做判断。上线后发现有部分用户明明只改了一个中文词的意思系统却认为没变化不保存。排查过程拉了一份出问题的场景数据在本地复现把elements的text字段单独拿出来对比。发现两个文本长度一样哈希也不一样说明检测逻辑应该触发……那问题在别处。往回倒查发现问题出在比较的是清洗后的字符串我当时为了统一格式对文本做了trim()和换行符归一化把\r\n换成\n。但 Excalidraw 内部对文本的换行处理方式会在用户编辑后产生微妙的空白差异。我的归一化把用户真的改了和格式微调两种情况合并了。最终方案不做任何文本归一化直接对原始elements序列化后算哈希。多花一点 CPU但判断绝对准确。这次教训之后我对为了性能做的预处理都格外小心凡是会改变数据本身的优化都要有明确的收益证明。5.3 旧文件导入后元素不见了一次真实的排查复盘用户反馈一份三个月前存的画板文件今天导入后只剩零星几个图形大部分内容消失。拿到文件后第一步JSON.parse看elements数组长度——340 个元素都在。说明数据没丢是加载环节出问题。第二步跑一遍restoreElements输出前后长度进 340出 340。元素数量没变。第三步看渲染。把还原后的元素按isDeleted过滤发现 300 多个元素的isDeleted是true。真相浮出水面用户在删除内容时用的是软删除标记isDeleted而不是从数组移除存文件时这些被标记的元素被一起序列化了但导入后渲染层按isDeleted过滤没渲染出来。等等那不对啊用户说的是以前能看到现在看不到了。继续查用户当初导出的那次是在删除操作之前。也就是说这个文件本身就有问题——它包含了大量被删除的元素。再深一层问题出在导出时机上。原来那份文件不是用户主动导出的是我做的自动快照在某个时点抓的。而那时候用户正好在批量删除图形。所以文件里全是isDeleted: true的残骸。修复方案有两个一是在导出时过滤掉isDeleted的元素二是接受它们存在但不渲染。我选了第一个理由很直接——没人需要自己的文件里躺着几百个看不见的墓碑。加了一行.filter(el !el.isDeleted)问题解决。但这个坑还带出一个连锁反应isDeleted的元素可能是某些boundElements的目标直接过滤会让箭头断绑。所以过滤完还得跑一次悬空引用清理跟第 2.2 节里说的一样。这一圈下来我才明白为什么官方文档反复强调导出要成套处理。5.4 大文件导入卡死主线程一份 20MB 的场景文件JSON.parse加上restore在主线程跑了 4 秒多期间页面完全无响应用户以为浏览器崩了。排查下来耗时分布大概是JSON.parse占 1.2 秒restore占 2.5 秒剩下的是渲染首次绘制。优化路径我试了两条。第一条把解析和还原搬进 Web Worker。JSON.parse天然可以在 Worker 里跑restoreElements是纯函数也能搬。只有最后一步api.updateScene需要回主线程。实测下来主线程无响应时间从 4 秒降到 300 毫秒左右。// worker 侧 self.onmessage (e) { const { rawText } e.data; try { const parsed JSON.parse(rawText); const elements restoreElements(parsed.elements ?? [], null, {}); self.postMessage({ ok: true, elements, appState: parsed.appState }); } catch (err) { self.postMessage({ ok: false, error: String(err) }); } };第二条对于超大的files先不加载图片只加载图形骨架。图片资源按需懒加载——用户滚动到可视区域附近再请求。files里的lastRetrieved字段本来就是给这种场景用的。两条路可以叠加。我把 Worker 方案先上了效果已经够用懒加载就没做。这里的原则是先解决卡顿再解决流量别一上来就把两件事一起优化容易把自己绕进去。5.5 协作场景下的本地覆盖远端最后一坑也是最疼的。多人协作的时候甲在本地自动保存了一份场景乙修改了远端版本甲刷新页面本地快照把远端更新直接盖掉了。这里的核心问题是没有版本向量。修复方式是给每一份持久化的场景加一个单调递增的revision和一个updatedAtasync function saveWithConflictCheck(store, sceneId, payload, baseRevision) { const remote await fetchRemote(sceneId); if (remote remote.revision ! baseRevision) { return { conflict: true, remote, local: payload }; } const nextRevision (remote?.revision ?? 0) 1; await fetchRemoteSave(sceneId, { payload, revision: nextRevision }); return { conflict: false, revision: nextRevision }; }检测到冲突之后不要自动合并——白板场景的自动合并几乎必然产出一坨垃圾因为元素位置和绑定关系是相互关联的。正确做法是把选择权交给用户展示本地版本和远端版本两份缩略图让用户挑一个或者导出本地版本后手动对照。这套机制加上去之后协作冲突从数据丢失事故变成了一次简单的二选一性质完全不一样了。6. 收尾前再补三个直接能用的经验第一个导出文件命名带上 revision 和时间戳。whiteboard-20240512-r18.excalidraw这样的名字出问题时用户能自己回滚你不用陪着一起翻日志。第二个给 IndexedDB 申请持久化权限。navigator.storage.persist()在支持的环境下能防止浏览器在磁盘压力大时清理你的数据一行代码的事收益很高if (navigator.storage?.persist) { const granted await navigator.storage.persist(); console.log(持久化存储:, granted ? 已授权 : 未授权可能被清理); }第三个永远保留一份最近成功保存时间给用户看。用户对自动保存的信心来自那个小小的已保存于 14:32提示而不是你的技术实现多牛。我做过一期用户调研加了时间戳提示之后担心数据丢失的反馈下降了将近一半——人对不确定性的恐惧远比我们想象的大。我个人在实际操作中的体会是Excalidraw 这套数据模型设计得已经相当克制了三个字段、一套还原函数没有绕弯的地方。真正的复杂度全在业务侧——什么时候存、存到哪、存多大、冲突了怎么办。把这几件事想明白代码量其实不大但每一行都得想清楚为什么这么写。