news 2026/11/4 10:41:39

Excalidraw白板JSON导入导出与localStorage持久化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Excalidraw白板JSON导入导出与localStorage持久化

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元素唯一标识合并去重的唯一依据
typerectangle / 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 的分工:谁负责序列化,谁负责落地

先把容易混淆的几个函数摆清楚,用错一个就会多写几十行代码:

函数输入输出典型用途
serializeAsJSONelements, appState, filesJSON 字符串存数据库、塞进请求体
exportToJsonelements, appState, filesBlob触发浏览器下载
exportToBlob同上 + mimeTypeBlob(图片)导出 PNG/SVG
loadFromJSONBlob 或字符串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的分片能力才真正有用武之地。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/4 10:38:57

Univer表格引擎深度实践:架构原理、集成方案与踩坑实录

"univer"这个名字我最早是给团队做数据平台的时候搜到的。当时我们想找一套能嵌入自研系统的在线表格组件&#xff0c;市面上的东西不是功能太简陋就是开源协议卡脖子&#xff0c;最后发现 Univer 这套开源办公套件——主打在线电子表格、文档、幻灯片&#xff0c;底…

作者头像 李华
网站建设 2026/11/4 10:38:39

光电报警电路课程设计:NE555调制、LM386放大与9014驱动

简介&#xff1a;面向测控技术与仪器、电子信息等专业学生的课程设计文档&#xff0c;围绕光电式传感器光电报警电路展开&#xff0c;可支撑防盗报警装置的原理设计与报告撰写。文档为单个 doc 文件&#xff0c;压缩包约 150KB&#xff0c;内容按课程设计任务书、方案设计、元件…

作者头像 李华
网站建设 2026/11/4 10:38:07

海湾火灾报警MODBUS RTU规约:寄存器映射与串口对接避坑

简介&#xff1a;面向消防报警系统集成与上位机开发人员的《海湾MODBUS RTU通讯规约》技术文档&#xff0c;聚焦火灾报警控制器与主机之间的数据采集对接。文档围绕MODBUS RTU从站实现展开&#xff0c;给出4800波特率、无奇偶校验、1位停止位的通讯参数配置&#xff0c;说明功能…

作者头像 李华
网站建设 2026/11/4 10:38:06

高校工作总结PPT模板工程化:python-pptx批量生成十套风格

简介&#xff1a;这份面向西北工业大学师生及工作人员的PPT模板压缩包&#xff0c;聚焦工作总结报告这一高频场景&#xff0c;提供10套不同风格的演示模板&#xff0c;便于在学术汇报、项目总结与日常工作中快速搭建结构清晰、带有学校辨识度的汇报页面。压缩包共4个文件&#…

作者头像 李华
网站建设 2026/11/4 10:37:39

电子课本本地化工作流:从爬取到教学封装的全链路解析

1. 这不是“下载器”&#xff0c;而是一套电子课本本地化工作流的起点 “把官方电子课本搬进本地”——这个标题里藏着三个被多数人忽略的关键动作&#xff1a;“搬”、 “进”、 “本地”。它不是简单点一下“另存为”&#xff0c;也不是用浏览器开发者工具扒源码&#xff0c;…

作者头像 李华
网站建设 2026/11/4 10:36:57

前端模块化与部署最佳实践解析

1. 项目概述 "前端&#xff1a;第二十章-导入导出与部署"这个标题看似简单&#xff0c;实则涵盖了现代前端工程化中三个至关重要的环节。作为一名长期奋战在一线的前端开发者&#xff0c;我深知这三个环节在实际项目中的重要性。导入导出关系到模块化开发的规范性和可…

作者头像 李华