Excalidraw 这类白板工具最容易被低估的一点,是它的数据其实非常“干净”——一张画布上所有的图形、文字、箭头、图片,全都能被序列化成一份结构清晰的 JSON。这份 JSON 既是导出文件的本质,也是配置持久化的载体。我做过几个需要把白板嵌进业务系统的项目,核心诉求几乎都一样:用户画完的东西不能因为刷新页面就没了,导出的文件换台设备还能原样打开,团队之间能传来传去。听起来简单,真正动手就会发现坑集中在三个地方:appState 该存哪些字段、图片为什么导入后不见了、localStorage 什么时候会悄悄写满。这篇就把导入导出 JSON 和配置持久化这两件事拆开讲透,从数据结构、API 选型、自动保存实现到排查清单,代码可以直接抄,参数怎么定我也会把计算过程写出来。适合已经能把白板跑起来、但被“数据留不住”困扰的开发者,也适合刚接触这类工具、想搞明白它数据模型的人。
1. 先看清一份场景 JSON 里到底装了什么
1.1 三个顶层字段:elements、appState、files
导出文件的扩展名是.excalidraw,很多人以为它是某种私有二进制格式,其实用文本编辑器打开就是 JSON。顶层结构基本长这样:
{ "type": "excalidraw", "version": 2, "source": "https://excalidraw.com", "elements": [], "appState": {}, "files": {} }type是身份标识,导入时必须校验它等于excalidraw,否则说明拿错文件了。version是文件格式版本,不是元素修改次数,这两者在后面的内容里会反复出现,别混。elements是图形数组,每一个矩形、每一段文字、每一条箭头都是数组里的一个对象。appState是视图与偏好状态。files是图片资源字典,键是文件 id,值是包含 dataURL 的对象。
这个四段式结构的价值在于:它把“内容”和“呈现”彻底分开了。内容在elements,呈现偏好在appState,二进制资源在files。分离带来的直接好处是合并、比对、增量更新都变得可行——你可以只同步 elements,让每个客户端用自己的主题色和缩放;也可以只备份 appState,做一套“界面偏好”的云端同步。我在实际项目里就是这么切的:白板内容走一条存储链路,用户偏好走另一条,互不干扰。
单个 element 的字段值得单独过一遍,因为后面排查问题全靠它:
| 字段 | 作用 | 排查时的意义 |
|---|---|---|
id | 元素唯一标识 | 合并去重的唯一依据 |
type | rectangle / text / arrow / image 等 | 类型不认识会导致整份文件加载失败 |
xywidthheight | 几何位置与尺寸 | 全为 0 常见于元素被误构造 |
angle | 旋转弧度 | 丢失会导致布局错位 |
version | 该元素被修改的次数 | 冲突合并时比大小 |
versionNonce | 平局时用的随机数 | 两个客户端同时改同一元素时的裁决 |
isDeleted | 软删除标记 | 协作场景必须保留,否则删除会被“复活” |
groupIds | 成组关系 | 丢失后组合被打散 |
boundElements | 绑定关系(箭头绑图形、文字绑容器) | 丢失后箭头不跟随移动 |
frameId | 所属画框 | 丢失后画框内元素脱离 |
isDeleted这个软删除设计是很多人第一次看会困惑的点:元素被删了为什么不直接从数组里拿掉?因为在多人协作里,“删除”本身也是一次需要同步的变更。如果 A 删了某个元素、B 的本地副本还有,直接物理删除会让 B 的副本在下次合并时把这个元素“救回来”。软删除加版本号,才能让删除动作像其他修改一样参与合并裁决。
1.2 场景 JSON 和 localStorage 快照不是一回事
初学者容易把“导出文件”和“浏览器里自动保存的那份”当成同一份数据,其实差别不小。导出文件追求完整可移植:全量元素、全量图片、足以还原视图的 appState。自动保存追求快、小、够用:它省略大量运行时字段,图片可能被压缩或截断,某些版本还会限制元素上限。
我自己遇到过的情况:本地自动恢复一切正常,把导出文件发给同事,对方打开发现背景色变成默认白色、缩放回到 100%。原因就是自动保存那份快照里存的 appState 是精简版,而我在导出时用了全量 appState,两边的语义没对齐。后来定了个规矩:任何写入磁盘或发给别人的 JSON,appState 都只写白名单字段,白名单之外的一律不带,这样本地恢复和跨端打开的行为才一致。
白名单我一般取这些:
const APP_STATE_WHITELIST = [ "viewBackgroundColor", "gridSize", "gridStep", "gridModeEnabled", "objectsSnapModeEnabled", "theme", "zoom", "scrollX", "scrollY", "currentItemStrokeColor", "currentItemBackgroundColor", "currentItemFillStyle", "currentItemStrokeWidth", "currentItemRoughness", "currentItemOpacity", "currentItemFontFamily", "currentItemFontSize", "currentItemTextAlign", "currentItemArrowType", "currentItemRoundness", ];为什么必须白名单而不是黑名单?因为 appState 里混着大量运行时状态:collaborators(在线协作者)、editingElement(正在编辑的元素)、openSidebar(侧边栏开合)、cursorButton、selectedElementIds、draggingElement。这些东西一旦被写进存档,轻则下次打开莫名其妙弹出一个侧边栏,重则因为editingElement指向一个已删除的元素,白板直接白屏。黑名单的思路是“排除已知的坏字段”,但这套状态字段会随版本新增,你永远追不上;白名单是“只放我确认安全的”,天然免疫版本升级。
1.3 为什么会出现“导出能打开、导入丢配置”
这个现象背后通常是两种原因叠加。第一种是 appState 语义不同:工具自身导出时会把zoom、scrollX、scrollY换算成一个scrollToContent行为,而程序化导入时如果把这些值原样塞回去,视图会跳到某个奇怪的坐标。第二种是元素版本迁移没走。元素格式在版本迭代中改过多次,比如圆角从布尔值改成对象、箭头绑定关系的存储方式调整过,直接用updateScene塞入老格式元素,渲染层可能不报错但画不出来。
正确姿势是永远走官方的恢复通道,而不是自己拼updateScene。Excalidraw 提供了restore/restoreElements/loadFromJSON这一组函数,内部会做版本迁移、字段补全、非法值修正。这一步是整篇文章最省事的一条经验:别自己造轮子,让库去处理格式演进。
2. 导入导出的四条通道与选型
2.1 官方 API 的分工:谁负责序列化,谁负责落地
先把容易混淆的几个函数摆清楚,用错一个就会多写几十行代码:
| 函数 | 输入 | 输出 | 典型用途 |
|---|---|---|---|
serializeAsJSON | elements, appState, files | JSON 字符串 | 存数据库、塞进请求体 |
exportToJson | elements, appState, files | Blob | 触发浏览器下载 |
exportToBlob | 同上 + mimeType | Blob(图片) | 导出 PNG/SVG |
loadFromJSON | Blob 或字符串 | Promise | 从文件恢复场景 |
restore | 解析后的对象 | 修正后的数据 | 恢复前做迁移和校验 |
restoreElements | 元素数组 | 修正后的元素数组 | 只合并元素时用 |
选型逻辑很简单,按“数据最终去哪”来分:
- 要落到数据库或接口里,用
serializeAsJSON,拿到字符串直接JSON.stringify都不用。 - 要让用户下载成文件,用
exportToJson,省掉手动构造 Blob 和 MIME 类型的活。 - 要从用户选的文件读回来,用
loadFromJSON,它接受File对象,内部处理读取和解析。 - 要在已有场景上做增量合并,用
restoreElements,只处理元素,不动视图。
有个细节值得单独说:exportToJson返回的 Blob 里 MIME 类型是application/json,但如果你想让文件双击后用工具打开,建议自己重命名成.excalidraw后缀。Windows 上有些编辑器会因为后缀不认识而拒绝关联,这不是数据问题,纯属系统行为。
2.2 导出图片时把场景“焊”进去
很多人不知道,导出 PNG 时可以顺带把整份场景 JSON 嵌进图片的元数据里。做法是在导出时把appState.exportEmbedScene设为true:
const blob = await exportToBlob({ elements, appState: { ...appState, exportEmbedScene: true }, files, mimeType: "image/png", });嵌进去之后,用户把这张 PNG 拖回白板,场景会被完整还原,而不是变成一张背景图。这个特性在做“教程截图”“设计稿交付”时特别好用——对方拿到一张图,同时拿到可编辑的源文件。
代价是体积。一张 1200×800 的纯色背景 PNG 原本可能只有 40KB,嵌入场景后涨到 300KB 都算正常。我的经验阈值是:元素超过 500 个或者含图片资源时,不要嵌场景,改成同时给一个.excalidraw文件。用户多下载一个文件的心智负担,远小于打开一张 20MB 图片时的等待。
2.3 文件读取这条链路上的坑
导入的文件来自用户,任何字段都不可信。我踩过的最典型的一个坑是:用户把.excalidraw文件用记事本另存为 UTF-8 with BOM,JSON.parse直接抛Unexpected token。排查了半天才发现文件头多了三个字节。处理方式是读取后先去掉 BOM:
function safeParse(text) { const cleaned = text.replace(/^\uFEFF/, "").trim(); if (!cleaned) throw new Error("文件内容为空"); const data = JSON.parse(cleaned); if (!data || data.type !== "excalidraw") { throw new Error("这不是有效的场景文件"); } if (!Array.isArray(data.elements)) { throw new Error("元素数据缺失或格式错误"); } return data; }还有一种情况是用户传了一个超大文件,比如 200MB 的 JSON。浏览器FileReader读取没问题,但JSON.parse会把主线程卡死好几秒,页面像假死。稳妥做法是读取前先看file.size,超过 20MB 就提示用户拆分,或者丢到 Web Worker 里解析。这个上限不是拍脑袋定的:一般场景 JSON 的体积和元素数量大致线性相关,单个元素平均 200~400 字节,20MB 对应大约 5 万到 10 万个元素,这个量级的白板已经很难在浏览器里流畅渲染了,解析成功也没意义。
3. 配置持久化:从键位设计到容量红线
3.1 键位怎么划,决定了以后好不好维护
持久化第一件事不是写代码,是定键名。我见过项目把场景和偏好塞进同一个 key,结果用户切换主题时整份白板都要重写一次,写入量直接翻几十倍。合理的划分是按“变更频率”切:
| 存储键 | 内容 | 变更频率 | 体积量级 |
|---|---|---|---|
board:scene:<id> | elements + files | 高 | 几十 KB 到几 MB |
board:appstate | 白名单后的 appState | 中 | 1~3 KB |
board:meta | 版本号、最后保存时间、标题 | 低 | 几百字节 |
board:prefs | 主题、语言、快捷键方案 | 极低 | 几百字节 |
分键的核心收益是写入成本与影响面隔离。改个主题只动prefs,几百字节;画一笔只动scene,不会把 appState 一起重序列化。这个差别在自动保存高频触发时非常明显,我实测过一个 800 元素的白板,整体写入一次大约 12ms,拆分后单次只写 scene,降到 8ms 左右,虽然绝对值不大,但在拖动元素连续触发保存的场景下,累积的卡顿感是能感知的。
另外提醒一个细节:localStorage的键是全局共享的同一个命名空间。如果你的白板嵌在别人的系统里,或者一个域名下有多个白板,一定要加前缀和实例 id,否则两个白板会互相覆盖。我一般用board:scene:${boardId}的形式,boardId从路由或配置里取。
3.2 自动保存的时机与节流参数怎么定
直接给结论:不要在每次onChange里写存储。Excalidraw 的onChange在拖动元素时会以每帧的频率触发,60fps 就是每秒 60 次,一次序列化几毫秒,浏览器主线程会被吃满。
我的做法是“防抖 + 上限节流”组合:停止操作 800ms 后写一次,同时保证即使一直在操作,也至少每 5 秒落一次盘。
function createAutosave(save, { debounce = 800, maxWait = 5000 } = {}) { let timer = null; let firstTriggerAt = 0; let pending = false; const doSave = () => { clearTimeout(timer); timer = null; firstTriggerAt = 0; if (!pending) return; pending = false; save(); }; return { trigger() { pending = true; const now = Date.now(); if (!firstTriggerAt) firstTriggerAt = now; if (now - firstTriggerAt >= maxWait) { doSave(); return; } clearTimeout(timer); timer = setTimeout(doSave, debounce); }, flush() { if (pending) doSave(); }, }; }参数为什么是 800ms 和 5s?800ms 是人的操作节奏:松开鼠标、停顿、思考下一步,这个间隔足够把一次连续拖拽合并成一次写入,又不会让用户觉得“我刚画的怎么没保存”。5s 是容灾窗口:万一用户一直在连续画,中间断电或崩溃,最多丢 5 秒的内容,这个损失可以接受。如果你的场景里用户经常做长时间连续标注,把maxWait调到 3s 更保险;如果是移动端、存储写入本身较慢,调到 8s 减少写入次数。
还要处理一个边界:页面卸载。beforeunload里调flush()能救回最后一段未落盘的内容:
window.addEventListener("beforeunload", () => autosave.flush()); document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") autosave.flush(); });visibilitychange这一条很多人漏掉。移动端切到后台时,beforeunload经常不触发,但visibilitychange会。少了这一行,手机上切个应用回来发现最后几笔没了,用户会认为是数据丢失。
3.3 容量红线:base64 是怎么把 localStorage 撑爆的
localStorage 的容量限制各家浏览器不同,主流实现大约在 5MB 到 10MB 之间,而且多数是按字符数计而不是字节数。这一点对含图片的场景是致命的,因为图片是以 dataURL 形式存在files里的。
先算一笔账。一张 1MB 的原始图片,转成 base64 后体积约为1 × 4/3 ≈ 1.33MB,再加data:image/png;base64,前缀的几十字节,以及 JSON 字符串里的转义(一般不会额外膨胀,base64 字符集不用转义)。所以单张 1MB 图片进 JSON 大约占 1.37MB 字符。localStorage 按 5MB 字符计,理论上放得下三张多一点,但这是理想情况,扣掉其他键位占用的空间,实际两张就告急。
更隐蔽的问题是QuotaExceededError不会让页面崩溃,它只是抛出异常,而很多代码里localStorage.setItem外面没有 try/catch,异常被吞掉或者打断后续逻辑,表现就是“保存了但没保存上”,用户完全无感。
function saveScene(key, json) { try { localStorage.setItem(key, json); return { ok: true }; } catch (err) { if (err && /quota/i.test(err.name || err.message)) { return { ok: false, reason: "quota" }; } return { ok: false, reason: "unknown" }; } }拿到quota之后不要静默失败,要做降级。我的处理顺序是:先尝试把files剥离后重存(图片换成占位符,提示“图片未缓存,请重新上传”),还不行就缩短历史记录、只保留最近一次快照,再不行就直接提示用户导出文件。这套降级链在真实项目里救过不少场,比单纯报错友好太多。
还有一个方向是从源头压缩:导出前把图片 canvas 重绘到最大边 1600px、质量 0.8 的 JPEG。一张 4000×3000 的手机截图,原始 3MB,压完通常 300KB 左右,base64 后 400KB,同样的容量能放十几张。代价是图会糊一点,但白板里的参考图本来也不需要印刷精度。
4. 完整实操:做一套能存、能导、能恢复的存档
4.1 读取当前场景的正确姿势
拿到白板实例的 API 引用之后,读数据有三个方法,区别很重要:
const elements = api.getSceneElements(); // 不含已删除元素 const allElements = api.getSceneElementsIncludingDeleted(); // 含已删除 const appState = api.getAppState(); const files = api.getFiles();存档场景用哪个?如果你的白板不涉及多人协作,用getSceneElements()就够了,它返回的是“看得见的东西”,JSON 更小。但只要涉及协作、合并、离线编辑后同步,就必须用getSceneElementsIncludingDeleted(),否则删除信息丢失,下次合并时被删的元素会从别人的副本里回来。
getFiles()返回的字典在元素里被fileId引用,导出时两个必须成对出现。我见过有人只导出了elements忘了files,导入后图片位显示为空白框加一个文件名,看起来像加载失败,其实就是资源没带过来。
4.2 导出:一句话下载,细节在文件名
import { exportToJson } from "@excalidraw/excalidraw"; async function downloadScene(api, name = "board") { const blob = await exportToJson({ elements: api.getSceneElementsIncludingDeleted(), appState: pick(api.getAppState(), APP_STATE_WHITELIST), files: api.getFiles(), }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; // 文件名带时间戳,避免多次下载互相覆盖 a.download = `${name}-${formatStamp(new Date())}.excalidraw`; document.body.appendChild(a); a.click(); a.remove(); // 必须释放,否则 blob 会常驻内存直到页面关闭 setTimeout(() => URL.revokeObjectURL(url), 1000); } function formatStamp(d) { const p = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`; }revokeObjectURL这一行不是可有可无。每次导出都创建一个 blob URL,不释放的话在长时间使用的页面里会持续占内存,导个十来次就能看到内存曲线明显抬升。延迟 1 秒释放是为了兼容 Safari 的下载时序,立即释放偶发下载失败。
文件命名带时间戳是我踩坑后加的。用户连续导出两次,浏览器会存成board.excalidraw和board (1).excalidraw,第二次自动加了后缀,但用户以为是同一个文件,回头找“最新版”的时候经常拿错。时间戳前缀排序天然正确。
4.3 导入:先迁移再落场景
import { restore } from "@excalidraw/excalidraw"; async function importScene(file, api) { const text = await file.text(); const raw = safeParse(text); // restore 负责版本迁移、字段补全、非法值修复 const { elements, appState, files } = restore( raw, null, // localAppState 传 null,不用本地状态覆盖文件里的视图 true // 第三个参数控制是否保留元素的时间戳等信息 ); api.updateScene({ elements, appState: pick(appState, APP_STATE_WHITELIST), files: files ?? {}, }); // 视口需要单独处理,别指望 appState 里的 scrollX/scrollY 能准确还原 api.scrollToContent(elements, { fitToContent: true, animate: false }); }这段代码里有两处值得展开。第一处是restore的第二个参数localAppState。传null表示完全不使用本地的视图状态,文件里是什么就用什么;传本地 appState 则是“以本地偏好为准,只恢复内容”。我倾向传null,因为用户打开别人发来的文件时,期望看到的是对方画的样子,而不是被自己的主题色覆盖。但如果你的产品逻辑是“统一企业主题”,那就该传本地 appState,把theme强制成公司配置。
第二处是scrollToContent。很多人以为文件里存了scrollX、scrollY、zoom就能还原视口,实测经常偏。原因是这几个值是相对于当时的画布容器尺寸算的,换一个屏幕宽度就全错位了。scrollToContent加fitToContent: true是按内容自适应,视觉上最接近“打开就能看到全部”,比精确还原坐标更实用。如果场景里有画框,可以加上fitToFrame参数按画框取景。
4.4 把自动保存、恢复提示、手动导出串起来
最后一步是把前面几块拼成一个闭环。我一般的结构是:加载时先读本地快照,读到了就恢复但不立即回写,同时挂一个“从本地恢复”的提示条,让用户有机会选择“恢复”还是“重新开始”。
function bootBoard(api, boardId) { const sceneKey = `board:scene:${boardId}`; const cached = localStorage.getItem(sceneKey); if (cached) { try { const data = restore(JSON.parse(cached), null, true); api.updateScene({ elements: data.elements, files: data.files ?? {} }); api.scrollToContent(data.elements, { fitToContent: true }); showRestoreBanner(); // 给用户一个“撤销恢复”的出口 } catch (e) { // 快照损坏不能让白板起不来,清掉坏数据继续走空白流程 localStorage.removeItem(sceneKey); } } const autosave = createAutosave(() => { const json = JSON.stringify({ type: "excalidraw", version: 2, elements: api.getSceneElementsIncludingDeleted(), appState: pick(api.getAppState(), APP_STATE_WHITELIST), files: api.getFiles(), }); const result = saveScene(sceneKey, json); if (!result.ok && result.reason === "quota") { handleQuotaExceeded(api, sceneKey); } }); api.onChange((elements, appState) => { // 只关心内容变更,光标移动、选中变化不触发保存 if (appState.cursorButton === "down") { autosave.trigger(); } }); }onChange里的过滤条件很关键。Excalidraw 的onChange在选中元素、移动光标、展开菜单时都会触发,这些都不是内容变更。只判断cursorButton === "down"能过滤掉大部分噪音,代价是键盘输入(比如改文字)不触发保存,靠maxWait兜底。更精细的做法是比较前后elements的长度和version之和,变了才算真改动,但那需要额外维护一份快照,成本更高。我目前的取舍是:宁可多存几次,也不要漏存。
handleQuotaExceeded我写成降级链:把files换成只保留id和mimeType的轻量对象再试一次,如果还失败,就清掉 scene 键、写一个board:meta标记提示用户“本地缓存已满,建议导出保存”。
5. 常见问题与排查速查表
5.1 导入后白板一片空白
按这个顺序查,基本能定位:
| 现象 | 可能原因 | 验证方式 | 处理 |
|---|---|---|---|
| 无报错但空白 | elements 数组为空 | 打印raw.elements.length | 确认导出时取的是正确 API |
| 有元素但不显示 | 坐标在视口外 | 调用scrollToContent | 加自适应视口 |
| 控制台警告未知类型 | 元素 type 不认识 | 看警告文本 | 升级库版本或过滤该元素 |
| 整页白屏 | appState 带了运行时字段 | 二分注释 appState | 改白名单写入 |
第四种最凶险,因为它不是“没画出来”,而是渲染组件崩了。判断方法是打开控制台看有没有 React 错误边界捕获的异常。一旦确认,把 appState 清成{}再恢复元素,能立刻验证。
5.2 图片变成空白框
九成是files没带过来。验证很快:元素里有fileId,去files里查这个键在不在。不在就是导出时漏了。还有一种情况是fileId在、dataURL 也在,但图片还是不显示,这通常是 dataURL 前缀被截断,比如存储时做了长度限制,或者后端接口有字段长度上限把字符串截了。把 dataURL 长度打印出来,对比原始文件的 base64 长度,一眼就能看出来。
这里补充一个后端存储的实操建议:不要用VARCHAR(255)之类的短字段存 dataURL,也别用文本字段无脑存整份 JSON。我一般的做法是把场景 JSON 整体存成一列长文本(或对象存储的 key),图片资源单独走对象存储,JSON 里只放 URL 引用。这样单条记录体积可控,图片也能走 CDN,加载快很多。代价是导出时要重新把图片拉回来转 base64,多一次网络请求,但换来的容量弹性和加载速度是值得的。
5.3 两个人的版本互相覆盖
这是“配置持久化”和“多人协作”交界处最典型的坑。假设 A 和 B 各自打开同一份存档,A 加了三个矩形,B 改了两个文字,最后谁后保存谁覆盖,另一人的工作就没了。
如果暂时不上实时协作,最低成本的方案是按元素合并,而不是整份覆盖:
function mergeElements(localList, remoteList) { const map = new Map(); for (const el of [...localList, ...remoteList]) { const prev = map.get(el.id); if (!prev) { map.set(el.id, el); continue; } if (el.version > prev.version) { map.set(el.id, el); } else if (el.version === prev.version && (el.versionNonce ?? 0) > (prev.versionNonce ?? 0)) { map.set(el.id, el); } } return [...map.values()].filter((el) => !el.isDeleted); }逻辑是:同一id比version,大的赢;version相同(说明是并发修改,都没看到对方)就用versionNonce这个随机数比大小,谁大谁赢。这个规则不完美——它是“最后写入者胜”的变体,并发修改同一元素时仍会丢掉一方的改动。但相比于整份覆盖,它把损失从“丢掉所有改动”缩小到“丢掉同一元素的并发改动”,实用价值高得多。真要彻底解决,得上 CRDT 或者服务端定序,那是另一个量级的工程。
用这套合并时有个前提必须满足:两份数据的元素都来自getSceneElementsIncludingDeleted()。如果一边用了过滤版本,isDeleted的信息缺失,合并后删掉的元素会被复活。
5.4 存档越存越大,最后写不进去
前面算过 base64 的账,这里给一套组合拳。第一,自动保存前先估算体积,超过阈值(我设的是 3MB 字符)就不存全景,只存元素骨架并标记“图片未缓存”。第二,图片入库存之前统一压到最大边 1600px。第三,给用户一个“清理本地缓存”的入口,很多人不知道自己攒了几十个白板的本地副本。第四,定期把最老的快照删掉,只保留最近 N 份。
估算体积有个便宜办法,不用真的序列化:
function estimateSize(elements, files) { let size = 0; for (const el of elements) size += 300; // 元素平均开销 for (const key in files) { const len = files[key]?.dataURL?.length ?? 0; size += len; } return size; }元素按 300 字节估是偏保守的,实际简单矩形可能只有 200 出头,带绑定关系的箭头能到 500 以上。这个估算只用做“要不要降级”的触发器,不需要精确。
5.5 我踩过的几个小坑,一并记下
第一个是JSON.parse之后直接改对象。如果你把解析出来的 elements 塞进updateScene,之后又对同一份对象做修改,可能影响到别处持有的引用。养成习惯:进updateScene前做一次深拷贝,或者至少不要把存档对象复用到别的地方。
第二个是theme字段。文件里存的是dark,但用户系统是浅色,导入后白板背景变黑,用户以为出 bug。如果你不希望文件里的主题覆盖用户偏好,就在 pick 的时候把theme排除掉,让它跟随本地设置。
第三个是时间戳。元素上的updated字段是修改时间,有些场景下你可能会想用它做“最近编辑”排序。但这个字段在版本迁移后可能被重置,不能当作可靠依据。要排序就用你业务侧自己维护的lastModified,存在board:meta里。
第四个是键的脏数据。开发过程中我改过键名,老版本的数据就永远躺在 localStorage 里没人管。后来加了个迁移逻辑:启动时扫一遍键前缀,发现旧版本的键就转成新格式,转完删掉。虽然只写一次,但省掉了很多“为什么这个用户的配置没生效”的排查。
最后一个提醒:localStorage 是同步 API,写入会短暂阻塞主线程。白板这类交互密集的页面,尽量不要在onChange里直接写,哪怕数据量只有几 KB。我实测过 3KB 写入大约 0.3ms,感觉不到,但如果元素多、appState 字段多,序列化本身才是大头。真正省时间的是前面说的白名单和防抖,而不是去纠结是否用IndexedDB。等你的场景真到了需要存二进制大文件或者几十万字符的规模,再考虑换IndexedDB也不迟,那时候瓶颈会从“序列化”变成“单条记录太大”,IndexedDB的分片能力才真正有用武之地。