浏览器存储选型:IndexedDB与localStorage

做前端久了你会发现,几乎每个项目都绕不开浏览器端存储:登录态要留、用户偏好要记、离线缓存要做、表单草稿别丢。但 localStorage、sessionStorage、Cookie、IndexedDB 到底该用谁?选型错了,轻则刷新丢数据,重则遭遇存储型 XSS、写下阻塞主线程的同步代码。本文用一张对比表加两个可直接抄的封装,把IndexedDBlocalStorage 的边界、实战与选型讲透。

一、四种存储方案一览

浏览器原生提供了四种主要存储能力,它们的容量、生命周期和读写方式差异巨大:

方案容量读写生命周期典型用途
localStorage约 5MB同步永久(手动清)用户偏好、Token、小配置
sessionStorage约 5MB同步标签页关闭即清单会话临时状态
Cookie约 4KB同步可设过期鉴权凭证、服务端可读
IndexedDB数百 MB+异步永久(手动清)结构化数据、离线库、大对象

可以看到,localStorage 适合小、简单、同步读写的键值;而一旦你要存结构化对象、做离线数据库或缓存大文件,就该上 IndexedDB。关于网络层缓存,推荐延伸阅读 前端缓存策略实战:强缓存、协商缓存与 Service Worker,它解决的是「请求结果复用」,与本文的「应用数据落地」互补。

二、localStorage:最简单的关键值存储

localStorage 的 API 只有 getItem / setItem / removeItem / clear 四个方法,所有值都以字符串存储,且同源可读、永久保留

2.1 它的问题:没有过期、只能存字符串

原生 API 不支持过期时间,存对象还要手动 JSON.stringify。一个健壮的做法是封装一层,自动加过期时间:

// 带 TTL 的 localStorage 封装
function setLSCache(key, value, ttlSeconds) {
  const payload = {
    v: value,
    exp: ttlSeconds ? Date.now() + ttlSeconds * 1000 : 0,
  };
  localStorage.setItem(key, JSON.stringify(payload));
}

function getLSCache(key) {
  const raw = localStorage.getItem(key);
  if (!raw) return null;
  try {
    const { v, exp } = JSON.parse(raw);
    if (exp && Date.now() > exp) {
      localStorage.removeItem(key);
      return null;
    }
    return v;
  } catch {
    return null; // 解析失败直接丢弃,避免脏数据
  }
}

// 用法:缓存用户偏好 7 天
setLSCache("theme", "dark", 7 * 24 * 3600);
const theme = getLSCache("theme");

2.2 避坑:同步读写会阻塞主线程

localStorage 是同步的,一次写入会锁住主线程。虽然 5MB 很小,但当你在循环里频繁写入(例如埋点、滚动监听里写坐标),就可能造成可感知的卡顿。高频写入请节流,或改用 IndexedDB。更多首屏与性能细节可看 前端性能优化:Lighthouse 90+ 到首屏 1s

三、sessionStorage 与 Cookie 的边界

sessionStorage 与 localStorage API 完全一致,区别在于生命周期随标签页:关掉页面数据就清空,适合「单会话临时态」,比如多步表单的进度、一次性验证码状态。

Cookie 则是一枚老兵:它每次请求自动携带,服务端可读,因此天然适合放鉴权凭证。但容量只有约 4KB,且必须通过 Secure / HttpOnly / SameSite 等属性加固。关于 HttpOnly 如何挡住脚本读取、SameSite 如何防 CSRF,推荐延伸阅读 前端安全实战:XSS 与 CSRF 防御全指南

四、IndexedDB:浏览器里的结构化数据库

当你需要存用户草稿、离线消息、长列表缓存、甚至图片二进制时,localStorage 的 5MB 和字符串限制就不够了。IndexedDB 是一个异步、事务化、支持索引的 NoSQL 数据库,容量可达数百 MB 甚至更多。

4.1 核心概念

理解三个概念即可上手:

  • 数据库(DB):通过版本号创建与升级;
  • 对象仓库(objectStore):类似一张表,存放键值对象;
  • 索引(index):在对象字段上建索引,支持按非主键查询。

4.2 实战:Promise 化封装

原生 IndexedDB 回调嵌套很深,工程里建议先包一层 Promise。下面这个封装覆盖「开库 / 写 / 读 / 删 / 按索引查」:

function openDB(name, version, upgrade) {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(name, version);
    req.onupgradeneeded = () => upgrade(req.result);
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

function tx(db, store, mode, fn) {
  return new Promise((resolve, reject) => {
    const t = db.transaction(store, mode);
    const os = t.objectStore(store);
    const res = fn(os);
    t.oncomplete = () => resolve(res && res.result);
    t.onerror = () => reject(t.error);
  });
}

// 开库并在 user 仓库上建 email 索引
const db = await openDB("app", 1, (db) => {
  const os = db.createObjectStore("user", { keyPath: "id" });
  os.createIndex("email", "email", { unique: false });
});

// 写
await tx(db, "user", "readwrite", (os) =>
  os.put({ id: 1, name: "Aki", email: "aki@example.com" })
);

// 按主键读
const u = await tx(db, "user", "readonly", (os) => os.get(1));

// 按索引查
await tx(db, "user", "readonly", (os) =>
  os.index("email").get("aki@example.com")
);

4.3 存二进制:Blob、File、ArrayBuffer 直接进库

IndexedDB 支持结构化克隆,能直接存 Blob / File / ArrayBuffer,无需 base64 转字符串。这意味着你可以把用户头像、离线 HTML、导出的 JSON 原样落地:

// 把一张图片 Blob 存进 assets 仓库
await tx(db, "assets", "readwrite", (os) =>
  os.put({ id: "avatar-1", blob: fileBlob, type: fileBlob.type })
);

// 读取后通过 URL.createObjectURL 直接渲染
const asset = await tx(db, "assets", "readonly", (os) => os.get("avatar-1"));
const url = URL.createObjectURL(asset.blob);

五、选型决策矩阵

面对具体需求,用下面这张表快速决策:

你的需求首选方案理由
记住主题 / 语言偏好localStorage小、永久、同步即可
多步表单临时态sessionStorage关页即清,不污染全局
鉴权 Token(服务端校验)Cookie自动携带、可 HttpOnly
离线草稿 / 长列表缓存IndexedDB大容量、结构化、异步
缓存头像 / 文件二进制IndexedDB原生支持 Blob

六、三个高频踩坑

6.1 隐私模式写入会抛异常

Safari 无痕模式、部分浏览器的隐私模式下,localStorage / IndexedDB 写入会直接抛错或静默失败。所有写操作务必 try/catch,并准备降级(如内存 Map)。

6.2 存储型 XSS 借 localStorage 长期潜伏

localStorage 里的数据会在页面上下文被 JS 读取,若你直接把其中内容 innerHTML 到页面,攻击者注入的脚本就会被持久执行。永远不要信任存储里的内容,输出到 DOM 前做转义。更多加固手段见 前端安全实战:XSS 与 CSRF 防御全指南

6.3 别把大对象塞进 localStorage

超过 5MB 或需要频繁更新的数据,请直接上 IndexedDB。把整页状态序列化进 localStorage 是常见的性能反模式。

七、落地建议:组合使用

真实项目往往是组合拳:用 Cookie 存鉴权凭证,用 localStorage 存用户偏好,用 IndexedDB 做离线数据与草稿,再叠一层 Service Worker 网络缓存 提速请求。单页应用里,路由切换时想保持登录态,可参考 前端路由原理与实战:Hash 与 History 模式选型 中关于状态保持的写法;若把存储封装放进 Vue3 + TypeScript 工程化 的统一工具层,还能顺便获得类型安全。

八、容量、配额与清理策略

浏览器存储并非无限。IndexedDB 的可用空间由各浏览器的「站点存储配额」决定,通常只占磁盘的一小部分,且不同源之间互不干扰。你可以用 navigator.storage.estimate() 在运行时读取已用空间与配额上限,实现「空间不足时主动清理最旧缓存」的自我保护,而不是等到写入抛错才处理。

另一个常被忽视的点是清理时机。localStorage 不会自动过期,长期堆积会逐渐拖慢首次读取;IndexedDB 的草稿表如果不设上限,也可能在低端机上占满配额,导致后续写入静默失败。建议给每个仓库设定最大条数或最大保留时间,超过阈值就按时间顺序淘汰。对于离线场景,还可以在「仅 WiFi 且空闲」时做一次全量瘦身。

最后,涉及用户隐私的数据(如定位轨迹、聊天记录、表单草稿)在用户注销或撤回同意时应主动删除,这既是体验问题,也是合规底线。存储方案选型时把「能否被干净清除」也纳入考量,会让你的前端在审计时少很多麻烦。

小结

浏览器端存储没有「最好」的方案,只有「最合适」的选型:localStorage 管小偏好、sessionStorage 管临时态、Cookie 管凭证、IndexedDB 管结构化与离线。先想清楚容量、生命周期和是否异步,再动手封装一层带过期与容错的薄壳,就能避开绝大多数坑。把这篇文章的封装抄进项目,你的存储层就稳了一大半。

上一篇 MySQL EXPLAIN 实战:读懂执行计划优化慢查询
下一篇 Java 日志体系实战:SLF4J + Logback 配置与最佳实践