Excalidraw 白板 JSON 导入导出与配置持久化

发布时间:2026/11/4 18:49:26
Excalidraw 白板 JSON 导入导出与配置持久化

周五下午四点半,你把一张三十多个节点的手绘架构图在浏览器里画完,顺手点了根烟回来,发现标签页被系统回收了。刷新之后,白色画布上空空如也,那种感觉比丢代码还难受——代码好歹还有 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(外观样式)。这些基本是所见即所得,不用多解释。

真正值得注意的是这几组:

字段组作用处理要点
seed+versionNonce手绘风格的随机种子、版本冲突抖动必须原样保留,改了会导致图形外观细微变化
version+updated元素级版本号与时间戳协作和增量同步依赖它,别自作主张重写
boundElements该元素上绑定了哪些箭头/文本双向引用,另一端箭头里有startBinding/endBinding
groupIds编组关系链数组顺序代表嵌套层级,不能打乱
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,用户会以为文件坏了——明明画了东西,打开一片空白。所以这三个值必须一起存、一起还原。

第二类是主题与背景:theme(light / 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 ms
1.5 MB(3 张截图)210约 2.3 MB约 400 ms
12 MB(8 张长图)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 系列和部分老版本浏览器不支持,得做降级,回退到普通canvas+toBlob。

第二种,导出时把 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,它只处理元素数组,不碰appState:

import { 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的 schema,restore会走迁移路径。绝大多数情况下能平滑迁移,但总有边缘情况。

我的实践是三层防护:

第一层,解析前置校验。读文件后先检查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 里我一般开两个 store:

const 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是个 Map,Map里有指向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和一个updatedAt:

async 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 这套数据模型设计得已经相当克制了:三个字段、一套还原函数,没有绕弯的地方。真正的复杂度全在业务侧——什么时候存、存到哪、存多大、冲突了怎么办。把这几件事想明白,代码量其实不大,但每一行都得想清楚为什么这么写。