目录
一、一个「存点东西」的需求,能踩出三种坑
假设要做一个离线笔记页,需求只有一句话:用户写的内容不能丢。
最直觉的写法是每次输入都 localStorage.setItem('draft', text)。它在开发机上跑得很好,直到文档变成几万字:输入开始一顿一顿的,因为每次按键都在主线程上做一次字符串序列化和同步写入。
换成 IndexedDB,卡顿没了。但另一个问题冒出来:用户在全屏写作时,浏览器因为磁盘紧张回收了站点数据,或者用户顺手点了「清除浏览数据」,草稿照样没了。代码里没有任何报错,因为删除发生在存储层,不经过你的 try/catch。
再换到 OPFS,听说它快、容量大。于是主线程里写:
const handle = await fileHandle.createSyncAccessHandle();// TypeError: fileHandle.createSyncAccessHandle is not a functioncreateSyncAccessHandle() 只在 Worker 里可用。这不是浏览器 bug,而是 OPFS 的设计前提:它把「同步、随机读写」的能力交给 Worker,避免主线程被文件 I/O 阻塞。
三个坑,对应的其实是同一个错误:把「浏览器存储」当成一种东西,只比较容量,却不比较它们各自怎么失效。
这篇文章按失效方式重讲一遍四种存储:localStorage、IndexedDB、OPFS,以及经常被忘掉的 Cache Storage。
二、先看失效模型,而不是容量表
| localStorage | IndexedDB | OPFS | Cache Storage | |
|---|---|---|---|---|
| API 形态 | 同步 | 异步、事件驱动 | 异步;同步句柄仅 Worker | 异步 |
| 存什么 | 字符串键值 | 结构化克隆对象 | 二进制文件 | Request/Response |
| 事务 | 无 | 有(跨 object store) | 无(文件级) | 无 |
| 查询 | 无 | 主键、索引、游标 | 目录遍历 | 按 Request 匹配 |
| 配额 | 独立,通常 MB 量级 | 站点配额池 | 站点配额池 | 站点配额池 |
| 被回收 | 可能 | 可能 | 可能 | 可能 |
| 主线程阻塞 | 会 | 不会 | 异步 API 不会 | 不会 |
| 跨标签页 | storage 事件 | 无事件,需自己通知 | 无事件 | 无事件 |
这张表里最容易被低估的是最后两行。
「异步」不等于「不会丢」。 IndexedDB 的事务提交、OPFS 的 flush(),解决的是「写到存储层」的原子性和时机问题;浏览器仍然可以在配额压力下清掉整个站点的数据。想让数据活下来,除了写进去,还得申请持久化、做导出、或者同步到服务端。
「同一个源」不等于「同一个上下文」。 现代浏览器的存储按源隔离;在第三方 iframe 里,还要按顶层站点做存储分区(storage partitioning)。同一个组件嵌在 A 站和 B 站,看到的 localStorage 可能不是同一份。做嵌入式 widget 时,这一点比容量重要得多。
下面逐个看。
三、localStorage:它快,是因为它把成本转移给了主线程
localStorage 的 API 只有五个方法,值一律是字符串:
localStorage.setItem('count', 1);localStorage.getItem('count'); // "1",不是 1它真正的限制不是「只能存字符串」,而是同步。规范要求 getItem / setItem 立即返回结果,所以浏览器必须在主线程上完成序列化和存储访问。单次几微秒到几百微秒,听起来不多,但放到输入事件里就是另一回事:
// 每次按键都跑一遍,文档越大越慢editor.addEventListener('input', () => { localStorage.setItem('draft', editor.value);});这段代码的成本包含三部分:读取 editor.value、把整段文本交给存储层、以及在存储层里做 UTF-16 编码。文档从 1 万字涨到 10 万字,每次按键都要重复处理全部内容。用户感知到的不是「保存慢」,而是「打字卡」。
先量一下,不要猜:
function measureLocalStorage(chars = 200_000) { const key = '__storage_bench__'; const value = 'x'.repeat(chars);
const t0 = performance.now(); localStorage.setItem(key, value); const t1 = performance.now(); localStorage.getItem(key); const t2 = performance.now(); localStorage.removeItem(key);
return { setMs: +(t1 - t0).toFixed(2), getMs: +(t2 - t1).toFixed(2), chars, };}在控制台里把 chars 从 1 万调到 100 万,你会看到耗时随内容线性增长。这个数字在不同机器、不同浏览器上差别很大,但趋势稳定:localStorage 的成本和你要存的数据量成正比,而且这笔成本记在主线程账上。
它的配额也常被误传。多数浏览器给每个源 MB 量级的额度(常见说法是 5 MB 上下),并且通常按 UTF-16 计费——同样 100 万个字符,占用的字节数大约是 2 MB,而不是 1 MB。具体数值不要背,写探测代码:
function localStorageBudget() { const key = '__quota_probe__'; const chunk = 'x'.repeat(1024 * 256); // 每次 256K 字符 let written = 0;
try { for (;;) { localStorage.setItem(`${key}${written}`, chunk); written += chunk.length; } } catch (err) { // QuotaExceededError(Safari 旧实现可能抛 NS_ERROR_DOM_QUOTA_REACHED) if (err.name !== 'QuotaExceededError' && err.name !== 'NS_ERROR_DOM_QUOTA_REACHED') { throw err; } } finally { for (let i = 0; i < written; i += chunk.length) { localStorage.removeItem(`${key}${i}`); } }
return written; // 大约能写的字符数}这段代码会真的把配额写满,别在用户正在用的页面里跑。它说明了一件事:localStorage 的写入失败是抛异常,不是静默失败。所以线上代码至少要有这个兜底:
function safeSetItem(key, value) { try { localStorage.setItem(key, value); return true; } catch (err) { if (err.name === 'QuotaExceededError' || err.name === 'NS_ERROR_DOM_QUOTA_REACHED') { return false; // 交给上层降级,比如转存 IndexedDB 或提示用户 } throw err; }}还有一个常见误解:storage 事件可以用来同步同一个页面的多个组件。不会。它只在同源的其他标签页/窗口里触发,不触发在做出修改的那个页面。 同一个页面内要通知,得自己发事件。
localStorage 适合什么
适合首屏渲染前必须同步读到、体积只有几百字节到几 KB 的偏好:主题、语言、布局密度、是否已看过引导。这类数据的特点是「读得越早越好」,而 localStorage 的同步性在这里反而成了优点——它可以在 <head> 的内联脚本里立刻返回,避免深色模式闪白。
不适合的东西同样明确:
- 任何会随用户输入增长的内容。草稿、日志、缓存列表都不该放这里。
- 令牌和敏感信息。localStorage 对同源 JavaScript 完全可见,一次 XSS 就能全部读走;这不是 localStorage 独有的问题,但它是被读取成本最低的那个。会话凭证优先放
HttpOnlyCookie。 - 需要事务的多条关联数据。localStorage 没有事务,两次
setItem之间刷新页面,就会留下写了一半的状态。
四、IndexedDB:异步只是入场券,事务才是它的本体
IndexedDB 经常被介绍成「异步版的 localStorage」。这个说法漏掉了它真正有价值的部分:对象存储、索引、游标和事务。
它存的是结构化克隆(structured clone)后的值,所以可以直接放对象、数组、Date、Map、Set、Blob、File、ArrayBuffer。函数、DOM 节点和类实例不行;类实例即使能存,读回来也会丢掉原型,变成普通对象。
一个带版本迁移的打开逻辑长这样:
function openDB() { return new Promise((resolve, reject) => { const req = indexedDB.open('notes', 3);
req.onupgradeneeded = (event) => { const db = req.result; const tx = req.transaction;
if (event.oldVersion < 1) { const drafts = db.createObjectStore('drafts', { keyPath: 'id' }); drafts.createIndex('updatedAt', 'updatedAt'); }
if (event.oldVersion < 2) { const outbox = db.createObjectStore('outbox', { keyPath: 'seq', autoIncrement: true, }); outbox.createIndex('status', 'status'); }
if (event.oldVersion < 3) { // 只使用 IDB 请求做迁移,不要在这里 await 外部 Promise const store = tx.objectStore('drafts'); store.openCursor().onsuccess = (e) => { const cursor = e.target.result; if (!cursor) return; const value = cursor.value; if (value.schemaVersion !== 3) { cursor.update({ ...value, schemaVersion: 3 }); } cursor.continue(); }; } };
req.onblocked = () => { // 另一个标签页还握着旧版本连接,升级被阻塞 console.warn('旧版本连接未关闭,数据库升级被阻塞'); };
req.onsuccess = () => { const db = req.result; db.onversionchange = () => db.close(); // 别挡住别的标签页升级 resolve(db); };
req.onerror = () => reject(req.error); });}事务的生命周期,是 IndexedDB 最大的坑
IndexedDB 事务有一个非常反直觉的规则:当事务创建后,如果事件循环回到浏览器、且没有新的 IDB 请求排队,事务会自动提交。 所以下面这段代码一定会抛 TransactionInactiveError:
const tx = db.transaction('drafts', 'readwrite');const store = tx.objectStore('drafts');
const config = await fetch('/api/config').then((r) => r.json()); // 事务在这期间已提交await store.put({ id: 'draft-1', config }); // TransactionInactiveError正确的边界是:网络请求、文件读取、定时器都放在事务外;事务只包住真正要一起生效的读写。
const config = await fetch('/api/config').then((r) => r.json());
const tx = db.transaction('drafts', 'readwrite');await request(tx.objectStore('drafts').put({ id: 'draft-1', config }));await txDone(tx);
function request(req) { return new Promise((resolve, reject) => { req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); });}
function txDone(tx) { return new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); });}事务让「多条写入要么全成、要么全不成」有了保证。比如保存草稿时同时更新正文和 updatedAt 索引,两条写进同一个 readwrite 事务,就不会出现「正文更新了、排序时间没更新」的中间态。
配额、回收与持久化
IndexedDB 和 Cache Storage、OPFS 共用站点的配额池。先看浏览器愿意给多少、已经用了多少:
async function storageBudget() { if (!navigator.storage?.estimate) return null;
const { usage = 0, quota = 0 } = await navigator.storage.estimate(); let persisted = false;
if (navigator.storage.persisted) { persisted = await navigator.storage.persisted(); }
if (!persisted && navigator.storage.persist) { // 浏览器可以拒绝;false 不是异常,只代表没拿到持久化 persisted = await navigator.storage.persist(); }
return { usage, quota, persisted, ratio: quota > 0 ? usage / quota : 0, };}这里有两个必须说清的细节:
estimate()的quota不是「保证你能写这么多」。它是浏览器基于磁盘余量、站点参与度等因素算出来的上限,会变;usage也只统计浏览器愿意告诉你的部分。persist()的结果是「尽力而为」。它降低被回收的概率,不构成备份承诺。用户清站点数据、换设备、无痕窗口结束,该没还是没。另外,别在首屏无条件调用它——Firefox 会弹权限提示,Chrome 则按站点参与度静默决定;更合适的时机是用户点击「启用离线使用」之后。
Safari 还有一段特殊历史:其 ITP 会对长期没有用户交互的站点的脚本可写存储做清理,规则随版本变化。把「写进 IndexedDB 就永久保存」当设计前提,在 Safari 上尤其危险。
IndexedDB 适合什么
- 需要按主键/索引查询的结构化数据:草稿、离线队列、消息缓存、设置项集合。
- 需要事务保证一致性的多对象写入。
- 中等大小的二进制数据(几 MB 的图片、音频片段)。
它不适合:超大文件(用 OPFS)、需要 JOIN/全文检索的复杂查询(浏览器里没有 SQL,除非上 WASM SQLite)、以及「多个标签页同时做读-改-写」却不加协调的场景。IndexedDB 的事务能保证单次事务内的隔离,但跨标签页的「先读后写」仍然需要 Web Locks 或版本号做乐观锁。
五、OPFS:一个只属于当前源的私有文件系统
OPFS(Origin Private File System)通过 navigator.storage.getDirectory() 拿到一个目录句柄:
const root = await navigator.storage.getDirectory();const file = await root.getFileHandle('draft.bin', { create: true });它叫「私有」是有原因的:这些文件不出现在用户的文件管理器或文件选择器里,只能通过同源 JavaScript 访问。想导出文件给用户,得另外走下载或 File System Access API(showSaveFilePicker)。OPFS 不是「网页版的用户文件夹」。
写文件有两条路。
异步写入,主线程也能用:
const writable = await file.createWritable();await writable.write(new TextEncoder().encode('hello'));await writable.close(); // close 时才真正提交同步随机读写,只能在 Worker 里用:
self.onmessage = async ({ data }) => { const { name, chunks } = data; const root = await navigator.storage.getDirectory(); const file = await root.getFileHandle(name, { create: true }); const access = await file.createSyncAccessHandle();
try { access.truncate(0); let offset = 0;
for (const chunk of chunks) { const bytes = new Uint8Array(chunk); access.write(bytes, { at: offset }); offset += bytes.byteLength; }
access.flush(); // 把缓冲交给存储层 self.postMessage({ ok: true, bytes: access.getSize() }); } catch (err) { self.postMessage({ ok: false, error: String(err) }); } finally { access.close(); // 必须关闭,独占锁才会释放 }};createSyncAccessHandle() 有两个硬约束:
- 仅限 Worker。主线程调用会直接失败,因为同步文件 I/O 会阻塞渲染。
- 同一文件同一时间只能有一个同步句柄。第二个标签页或 Worker 打开同一个文件会失败,直到前一个
close()。这不是 bug,而是 OPFS 用独占锁避免多写者互相踩踏。
flush() 也不是「落盘保证」。它把用户态缓冲交给操作系统的存储层,降低进程崩溃丢数据的概率;断电、浏览器崩溃、用户清站点数据仍然可能让数据消失。
OPFS 适合什么
- 大块二进制:音视频缓存、模型文件、导出包。
- 需要随机读写或流式追加的文件:日志、分片上传的临时文件。
- WASM 版的 SQLite / DuckDB:它们需要真正的文件语义,OPFS 的同步访问句柄是目前浏览器里最接近本地文件系统的方案。
不适合:几十字节的配置项(localStorage 更直接)、需要按字段查询的数据(IndexedDB)、需要用户可见的文件(File System Access API)。
还有一个兼容性提醒:OPFS 的异步 API 支持面已经很广,但 createSyncAccessHandle() 在 Worker 中的可用性和行为在各浏览器上并不完全一致。用之前先探测,不要假设:
function hasOPFS() { return typeof navigator !== 'undefined' && typeof navigator.storage?.getDirectory === 'function';}
function hasSyncAccessHandle() { return typeof FileSystemFileHandle !== 'undefined' && 'createSyncAccessHandle' in FileSystemFileHandle.prototype;}第二个探测要在 Worker 里执行才有意义。主线程即使原型上有这个方法,调用也会因为上下文不对而失败。
六、Cache Storage:它是 HTTP 缓存,不是键值数据库
Cache Storage 经常被顺手拿来当存储用:caches.open('data').then(c => c.put('/key', new Response(JSON.stringify(obj))))。技术上能跑,语义上不对。
它的模型是 Request → Response,为 Service Worker 拦截网络请求而生。它没有索引、没有事务、没有过期时间,也不会帮你做序列化。用它存业务数据,你会很快开始手写一套「键前缀 + 版本清理 + 手动过期」的逻辑,而那正是 IndexedDB 已经提供的东西。
它适合的场景很明确:缓存静态资源、离线页面、以及需要按 URL 精确匹配的响应。清理时按 cache 名整体删除,比逐条遍历更可靠:
const CACHE = 'static-v3';
self.addEventListener('activate', (event) => { event.waitUntil( caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key))) ) );});七、把它们放进同一套决策里
如果只记一条规则:按「数据的生命周期」选,而不是按「容量」选。
- 首屏渲染前就要读到的几十字节偏好 → localStorage。它同步、简单,且这点数据量不会造成可感知的阻塞。
- 结构化、需要查询或事务的数据 → IndexedDB。草稿、离线队列、缓存索引都在这里。
- 大文件、需要随机读写或喂给 WASM 数据库的数据 → OPFS。
- HTTP 响应和静态资源 → Cache Storage。
在此之上,补四个工程动作,缺一个都可能让「存了」变成「以为存了」:
- 写之前探测能力,写失败要能降级。 localStorage 抛异常、IndexedDB 事务 abort、OPFS 句柄打不开,都是正常路径,不是异常路径。降级链可以是
OPFS → IndexedDB → 内存 + 提示。 - 申请持久化,但别把它当备份。
navigator.storage.persist()能降低回收概率;真正重要的数据要有导出/导入功能,或者同步到服务端。 - 给数据加版本号。 不管是 IndexedDB 的
onupgradeneeded,还是 OPFS 文件头里的 magic number,迁移逻辑从第一天就该存在。事后补迁移,往往要面对已经写坏的数据。 - 多标签页写入要有协调。 IndexedDB 事务不覆盖「读出来、在内存里改、再写回去」的整个跨标签页过程。用 Web Locks 串行化关键区,或者用版本号做乐观锁:
await navigator.locks.request(`draft:${id}`, async () => { const current = await loadDraft(id); await saveDraft({ ...current, ...patch, version: current.version + 1 });});最后,一个能力探测函数,可以放在应用启动时跑一次,把结果交给存储层做决策:
async function probeStorage() { const result = { localStorage: false, indexedDB: false, opfs: false, cacheStorage: false, estimate: null, };
try { localStorage.setItem('__probe__', '1'); localStorage.removeItem('__probe__'); result.localStorage = true; } catch { // 无痕模式或配额为 0 时可能直接失败 }
result.indexedDB = typeof indexedDB !== 'undefined'; result.opfs = hasOPFS(); result.cacheStorage = typeof caches !== 'undefined';
if (navigator.storage?.estimate) { result.estimate = await navigator.storage.estimate(); }
return result;}八、结语:存储的可靠性上限,是你愿意写多少恢复逻辑
浏览器存储最容易被误解的地方,是把它当成一个「容量不同的桶」。实际上它们是四套不同的契约:
- localStorage 用同步换简单,代价记在主线程上;
- IndexedDB 用事务和版本换一致性,代价是异步事件模型的心智负担;
- OPFS 用文件语义换大块数据的能力,代价是 Worker 限制和独占锁;
- Cache Storage 用HTTP 语义换离线能力,代价是它并不适合当数据库。
容量只决定你能装多少,失效方式才决定你什么时候会丢。选型时先问三个问题:这份数据多大、谁在什么时候写、丢了以后能不能重新生成。三个答案清楚了,API 基本也就定了。
至于「绝对不能丢」的数据——浏览器从来不是它的最终归宿。本地存储只是前端的缓冲层,真正的持久化仍然要靠服务端和用户手里的导出文件。