Web 前端本地存储:localStorage 与 IndexedDB

Web 前端本地存储:localStorage 与 IndexedDB

本文面向日常业务开发,说明两种浏览器内置「落盘」能力的差异、适用场景与写法。文中会穿插真实项目里的用法作为对照,但结论适用于任意前端应用,不限于某一产品。


一、先建立直觉:它们解决的不是同一类问题

可以把浏览器里的「本地存东西」想成三个层次:

层次 典型 API 一句话
记一串字 localStorage / sessionStorage 像记事本:键值对、同步读写、容量小
存结构化/大对象 IndexedDB 像小型数据库:对象、索引、异步、容量大
存二进制/离线资源 IndexedDBCache API、File System Access API 视频片段、字体包、大型 SVG 文档等

localStorageIndexedDB 都是「关掉页面再打开,数据还在」的持久化手段,但:

  • localStorage 适合 小、简单、读写在主线程立刻完成 的配置与元数据;
  • IndexedDB 适合 大、复杂、需要按 key 查询或存 Blob/ArrayBuffer 的缓存与离线数据。

并不是「IndexedDB 更高级,所以全部换成它」。选型要看:数据有多大、访问模式是什么、能不能接受异步 API、出错时业务能不能降级。


二、技术对比(一张表看懂)

维度 localStorage IndexedDB
数据模型 字符串键值对(值必须是字符串) 对象仓库(Object Store),可存结构化对象、BlobArrayBuffer
容量 通常约 5MB 量级(因浏览器/域名而异) 通常 数百 MB~数 GB(受磁盘与用户设置影响)
API 风格 同步getItem / setItem 会阻塞主线程 异步 :基于事件或 Promise 封装
查询能力 只能按 key 读;要自己做 JSON 解析与遍历 可按主键、index 范围查询、游标遍历
事务 transaction,可多 store 原子操作
同源策略 源(协议+域名+端口) 隔离 同上
生命周期 除非用户清站点数据或代码 removeItem,否则长期保留 同上
结构化克隆 自己 JSON.stringify / parse 浏览器对可克隆类型直接存;不能存 函数、Proxy、部分 DOM 节点
典型坑 超配额抛错、同步阻塞、存了 Blob URL 刷新失效 升级 schema、并发 tab、写入需防抖、API 冗长

还有一个容易混淆的兄弟:sessionStorage 。它和 localStorage API 几乎一样,但 只在当前标签页会话内有效,关 tab 就没了。适合「表单草稿、一次性向导状态」,不适合跨会话的用户偏好。


三、项目里是怎么分工的(作对照,不是本文边界)

下面用两个子项目说明 同一套前端里往往会两种都用,而且分工清晰。

3.1 适合 localStorage 的例子

① 用户界面偏好(小对象、高频读、要同步恢复)

典型 SPA 里,Pinia 用 persist 把主题、布局、面板宽度等写入 localStorage,刷新后立刻恢复,无需等异步 DB:

typescript 复制代码
// 模式示意:pinia-plugin-persistedstate
persist: {
  key: 'my-app:ui-settings:v1',
  storage: localStorage,
  pick: ['viewMode', 'theme', 'panelWidth'],
}

② 编辑器布局与开关(标量 + 少量 JSON)

视频编辑器里,暗色主题、侧栏宽度、GIF 解码开关等,用 watchEffect 同步写入 localStorage,逻辑直白:

typescript 复制代码
const isDark = ref(localStorage.getItem('theme') === 'true')
watchEffect(() => {
  localStorage.setItem('theme', isDark.value ? 'true' : 'false')
  localStorage.setItem('attrW', String(attrWidth.value))
})

③ 媒体池「目录」元数据(JSON 列表,不含大文件本体)

资源 store 把素材的 id、名称、URL 等 元数据 JSON 进 localStorage;真正的 Blob 放在 IndexedDB,刷新后再用 id 从 IndexedDB 取 blob 并 createObjectURL。这是典型的 「清单在 localStorage,实体在 IndexedDB」 分层。

3.2 适合 IndexedDB 的例子

① 大体积二进制:视频/音频/图片 Blob

MediaStorage 类用 IndexedDB 存 blob,并按 originalUrl 建索引,避免重复下载:

typescript 复制代码
interface MediaData {
  id: string
  originalUrl: string
  type: string
  blob: Blob
  timestamp: number
}
// indexedDB.open('mediaPool', 1) → objectStore 'media', keyPath: 'id'

② 大型文档/画布快照缓存(多页内容、标注结构)

例如可视化编辑器、报表设计器:单条缓存可能包含多页 SVG/HTML 片段、大量标注坐标、派生数据------体积和嵌套深度都远超 localStorage 能稳定承载的范围,因此用独立 IndexedDB 库,按 cacheKey 读写整条记录。

③ 静态资源二进制 ArrayBuffer

wasm、字体包、压缩包等用 IndexedDB 按 URL 缓存 ArrayBuffer,避免每次从 CDN 重复拉取数 MB 文件。

④ 高频写入要防抖

画布标注、分页预览、连续编辑等会频繁触发「写缓存」。实践上应对 IndexedDB 做 短延迟合并写入 (例如 180ms debounce),否则主线程与 IO 压力都会上来------这是 IndexedDB 在真实业务里几乎必做的工程细节;localStorage 若被同样频率 setItem 更容易卡顿甚至配额爆掉。

3.3 从对照里得到的规则(普适)

数据特征 更合适的存储
小于几十 KB 的配置、token 旁路状态、UI 尺寸 localStorage(或 sessionStorage)
需要存 Blob / ArrayBuffer / 大 JSON 文档 IndexedDB
既要列表又要大文件 元数据 localStorage + 二进制 IndexedDB(项目里媒体池即如此)
仅当前 tab 有效 sessionStorage
静态资源 HTTP 缓存语义 Cache API(Service Worker 场景)

四、什么业务场景适合哪一种?

4.1 优先用 localStorage 的场景

  1. 用户偏好与本地开关

    主题、语言、表格列宽、上次选中的 tab、功能开关。数据小、读多写少、希望首屏同步读完。

  2. 简单草稿或表单暂存(跨会话)

    几 KB 以内的 JSON,例如筛选条件、未提交的备注。注意:敏感信息不要明文长期放本地。

  3. 与第三方库默认集成

    很多状态持久化插件默认就是 localStorage,在数据量可控时不必强行换 IndexedDB。

  4. 需要「立刻读到」且能接受阻塞极短时间

    例如路由守卫里同步读一个 flag(仍建议控制体积,避免大 JSON)。

4.2 优先用 IndexedDB 的场景

  1. 离线或大缓存

    已下载的视频片段、音频采样、字体、wasm、大图缓存。

  2. 结构化数据量大

    聊天记录归档、工单列表缓存、复杂文档树、游戏存档。

  3. 需要按字段查询

    userIdupdatedAt 范围查,而不是每次 getItemJSON.parse 全表扫描。

  4. 写入二进制且不想 Base64 膨胀

    Blob 直接存;若硬塞进 localStorage 只能 Base64,体积大约膨胀 33%,且同步 stringify 更卡。

  5. 后台同步 / 队列

    待上传队列、增量同步游标,条目多时用 IndexedDB 更稳。

4.3 两者都不合适时

  • 会话级临时态sessionStorage 或内存(Vue/Pinia state)。
  • 敏感凭证:HttpOnly Cookie(防 XSS 读)或安全后端会话;不要把长期 refresh token 只放 localStorage。
  • 静态资源版本化缓存:Service Worker + Cache API。
  • 超大单文件、需要文件系统语义:File System Access API、OPFS(Origin Private File System)等(能力更新,需查目标浏览器)。

五、「全都用 IndexedDB 是不是更好?」

结论:不是。 盲目统一会带来这些问题:

  1. 开发成本

    IndexedDB 原生 API 冗长,要写 open、upgrade、transaction、错误处理;小配置也要 async,首屏与单元测试都更麻烦。

  2. 过度设计

    存一个 'theme': 'dark' 用 IndexedDB,好比用数据库记开关------能跑,但读写路径、监控、迁移都更重。

  3. 主线程并非绝对更安全

    IndexedDB 虽异步,但 反序列化大对象、渲染缓存仍可能在回调里占主线程;大对象照样要控制单次读写体积。

  4. 配额与清理策略更复杂

    库大了要做 LRU、按版本清库、迁移 schema;localStorage 清一个 key 往往就够。

  5. 仍要处理「不能存 Proxy」等限制

    从 Vue/Pinia 直接 put 会失败,必须先转成 plain object(大型 IndexedDB 缓存模块里常见这类转换)。

推荐策略:按数据分层,而不是按技术站队。

javascript 复制代码
用户偏好、布局、小 JSON 清单  →  localStorage(或 sessionStorage)
Blob / 大文档 / 需索引的表     →  IndexedDB
敏感登录态                     →  Cookie(HttpOnly)+ 后端

六、代码示例

以下示例为 可独立运行的模式代码,不依赖具体框架,便于复制到任意项目。

6.1 localStorage:类型安全的小封装

typescript 复制代码
const STORAGE_PREFIX = 'my-app:'

export function lsGet<T>(key: string, fallback: T): T {
  try {
    const raw = localStorage.getItem(STORAGE_PREFIX + key)
    if (raw == null) return fallback
    return JSON.parse(raw) as T
  } catch {
    return fallback
  }
}

export function lsSet<T>(key: string, value: T): void {
  try {
    localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(value))
  } catch (e) {
    // 常见:QuotaExceededError ------ 需要裁剪数据或改走 IndexedDB
    console.warn('localStorage 写入失败', e)
  }
}

// 使用
interface UiPrefs {
  theme: 'light' | 'dark'
  sidebarWidth: number
}
const prefs = lsGet<UiPrefs>('ui-prefs', { theme: 'light', sidebarWidth: 280 })
prefs.sidebarWidth = 320
lsSet('ui-prefs', prefs)

注意:

  • 只存可 JSON 序列化的数据;不要blob: URL 当永久地址存进去(刷新后 URL 会失效)。
  • 键名带版本前缀(如 v1),以后改结构便于迁移或丢弃旧 key。

6.2 localStorage:与 Vue 响应式同步(编辑器布局类)

typescript 复制代码
import { ref, watch } from 'vue'

function usePersistedRef(key: string, initial: string) {
  const data = ref(localStorage.getItem(key) ?? initial)
  watch(data, (v) => {
    try {
      localStorage.setItem(key, v)
    } catch {
      /* 忽略配额错误,或提示用户清理 */
    }
  })
  return data
}

const panelWidth = usePersistedRef('panel-width', '400')

6.3 IndexedDB:最小 Promise 封装

typescript 复制代码
const DB_NAME = 'app-cache'
const DB_VERSION = 1
const STORE = 'items'

let dbPromise: Promise<IDBDatabase> | null = null

function openDb(): Promise<IDBDatabase> {
  if (dbPromise) return dbPromise
  dbPromise = new Promise((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, DB_VERSION)
    req.onupgradeneeded = () => {
      const db = req.result
      if (!db.objectStoreNames.contains(STORE)) {
        db.createObjectStore(STORE, { keyPath: 'id' })
      }
    }
    req.onsuccess = () => resolve(req.result)
    req.onerror = () => reject(req.error)
  })
  return dbPromise
}

function idbRequest<T>(request: IDBRequest<T>): Promise<T> {
  return new Promise((resolve, reject) => {
    request.onsuccess = () => resolve(request.result)
    request.onerror = () => reject(request.error)
  })
}

export async function idbPut<T extends { id: string }>(record: T): Promise<void> {
  const db = await openDb()
  const tx = db.transaction(STORE, 'readwrite')
  await idbRequest(tx.objectStore(STORE).put(record))
}

export async function idbGet<T extends { id: string }>(id: string): Promise<T | undefined> {
  const db = await openDb()
  const tx = db.transaction(STORE, 'readonly')
  return idbRequest<T | undefined>(tx.objectStore(STORE).get(id))
}

6.4 IndexedDB:存 Blob(媒体缓存模式)

typescript 复制代码
interface MediaRecord {
  id: string
  originalUrl: string
  blob: Blob
  updatedAt: number
}

export async function saveMedia(id: string, url: string, blob: Blob) {
  await idbPut<MediaRecord>({
    id,
    originalUrl: url,
    blob,
    updatedAt: Date.now(),
  })
}

export async function loadMedia(id: string): Promise<Blob | null> {
  const row = await idbGet<MediaRecord>(id)
  return row?.blob ?? null
}

// 页面里使用
const blob = await loadMedia('clip-001')
if (blob) {
  const objectUrl = URL.createObjectURL(blob)
  videoElement.src = objectUrl
  // 组件卸载时 URL.revokeObjectURL(objectUrl)
}

6.5 分层:清单在 localStorage,文件在 IndexedDB

typescript 复制代码
interface MediaEntry {
  id: string
  name: string
  originalUrl: string
  // 不在这里放 blob,避免 JSON 巨大
}

function loadCatalog(): MediaEntry[] {
  const raw = localStorage.getItem('media-catalog')
  return raw ? JSON.parse(raw) : []
}

function saveCatalog(list: MediaEntry[]) {
  localStorage.setItem('media-catalog', JSON.stringify(list))
}

async function hydrateBlobUrls(entries: MediaEntry[]) {
  for (const item of entries) {
    const blob = await loadMedia(item.id)
    if (blob) {
      item.previewUrl = URL.createObjectURL(blob)
    }
  }
}

这正是「资源元数据 JSON + IndexedDB 存 blob,刷新后按 id 重建 blob URL」的通用写法。

6.6 IndexedDB:高频写入要防抖

typescript 复制代码
let writeTimer: ReturnType<typeof setTimeout> | null = null

function scheduleSave(getSnapshot: () => unknown) {
  if (writeTimer) clearTimeout(writeTimer)
  writeTimer = setTimeout(async () => {
    writeTimer = null
    const plain = JSON.parse(JSON.stringify(getSnapshot())) // 去掉 Proxy
    await idbPut({ id: 'autosave', data: plain, updatedAt: Date.now() })
  }, 180)
}

画布标注、富文本草稿等 连续编辑 场景应合并写入,而不是每次 inputput

6.7 使用社区库简化 IndexedDB(可选)

生产环境常用 idb 等薄封装,减少样板代码:

typescript 复制代码
import { openDB } from 'idb'

const db = await openDB('app-cache', 1, {
  upgrade(db) {
    db.createObjectStore('items', { keyPath: 'id' })
  },
})

await db.put('items', { id: '1', name: 'demo' })
const row = await db.get('items', '1')

选型原则不变:库只解决 API 难用,不解决「该不该用 IndexedDB」


七、工程实践清单

7.1 键名与版本

  • 使用命名空间:product:module:key:v2
  • 结构变更时升版本或做一次性迁移,避免静默读错旧格式。

7.2 错误与降级

typescript 复制代码
async function getWithFallback<T>(idbLoad: () => Promise<T | null>, lsKey: string): Promise<T | null> {
  try {
    const fromIdb = await idbLoad()
    if (fromIdb) return fromIdb
  } catch (e) {
    console.warn('IndexedDB 不可用,尝试 localStorage', e)
  }
  try {
    const raw = localStorage.getItem(lsKey)
    return raw ? (JSON.parse(raw) as T) : null
  } catch {
    return null
  }
}

私有模式、Safari 智能防跟踪、企业策略都可能让存储失败,功能应能继续,只是不缓存

7.3 安全

  • 不要把密码、长期 refresh token、未加密 PII 当「方便」扔 localStorage。
  • XSS 能读 localStorage / IndexedDB;防 XSS 仍是根本。

7.4 多标签页

  • storage 事件可监听 其他 tab 对 localStorage 的修改。
  • IndexedDB 多 tab 可同时读写,但要注意 版本升级 时关闭旧连接(versionchange 事件里 db.close())。

7.5 测试与清理

  • 提供「清除本地缓存」入口(设置页),开发时便于复现冷启动。
  • E2E 里可用 Playwright 的 storageState 或启动前清站点数据。

八、决策流程

8.1 文字版

javascript 复制代码
开始:数据需要关掉页面后仍保留吗?
  │
  ├─ 否 → 用内存状态,或 sessionStorage(仅当前标签页)
  │
  └─ 是 → 单条数据是否含 Blob/ArrayBuffer,或体积明显超过约几百 KB?
         │
         ├─ 是 → 优先 IndexedDB(或 OPFS / Cache API 等更贴合场景的 API)
         │        └─ 是否还有「小清单」元数据(id、名称、URL 列表)?
         │              ├─ 是 → 清单放 localStorage,大文件放 IndexedDB
         │              └─ 否 → 仅 IndexedDB
         │
         └─ 否 → 是否需要按字段查询,或条目数量上千?
                │
                ├─ 是 → IndexedDB
                │
                └─ 否 → 是否需要首屏同步、立刻读到(可接受极小阻塞)?
                       ├─ 是 → localStorage + JSON
                       └─ 否 → 可用 IndexedDB,但需权衡开发与维护成本

8.2 Mermaid 版(需预览器支持 Mermaid)

节点文案避免 <br/> 和未转义的 ?,以提升 GitHub、VS Code、语雀等环境的兼容性:

flowchart TD A["需要持久化到浏览器"] --> B{"含 Blob 或 ArrayBuffer,或单条超过约几百 KB"} B -->|是| C["IndexedDB,或 OPFS / Cache API"] B -->|否| D{"需要按字段查询,或上千条记录"} D -->|是| C D -->|否| E{"仅当前标签页有效"} E -->|是| F["sessionStorage 或内存"] E -->|否| G{"需要首屏同步立刻读取"} G -->|是| H["localStorage 加 JSON"] G -->|否| I["可用 IndexedDB,评估复杂度"] C --> J{"还有小清单元数据"} J -->|是| K["元数据 localStorage,大对象 IndexedDB"] J -->|否| L["仅 IndexedDB"]

九、和其他存储怎么选(扩展阅读)

API 适用
sessionStorage 单 tab 会话内的临时状态
Cookie 需要随 HTTP 请求带给服务端、或 HttpOnly 安全会话
Cache API Service Worker 控制的静态资源与请求响应缓存
OPFS 大文件、流式读写、接近文件系统语义(新能力,需查兼容性)

十、总结

问题 答案
核心区别? localStorage:小字符串 KV、同步;IndexedDB:大对象/二进制、异步、可索引
业务怎么选? 偏好与小品 JSON → localStorage;媒体与大型缓存 → IndexedDB;常两者组合
全用 IndexedDB? 不必,也不划算;简单场景用 localStorage 更清晰、更省事
项目实践启示? 媒体池、大型文档缓存、静态资源包等大对象走 IndexedDB;主题、布局、资源清单走 localStorage

存储技术是为 数据形态与访问模式 服务的。先问「有多大、会不会阻塞、要不要查表、刷新后 blob URL 还有效吗」,再选 API,比统一押注某一种更靠谱。


相关推荐
小强19881 小时前
CSS 布局进化史:从 Float 到 Flexbox 再到 Grid
前端
AKA__老方丈1 小时前
删除确认 Hook - 统一管理单删/批量删除的确认弹窗与执行
前端·javascript·vue.js
云间寄信1 小时前
JS:数据结构与集合
javascript
假如让我当三天老蒯1 小时前
React+TS 项目结构(自学项目用)
前端·react.js
yingyima1 小时前
Celery 分布式任务队列:我差点被这行代码坑死
前端
用户125758524361 小时前
XYGo Admin 即时通讯模块解析:基于 WebSocket 的企业级消息架构实践
前端
铁皮饭盒1 小时前
彩色命令行,Node21自带函数1行实现 ,Bun也兼容, 附Bun.color实现渐变色的代码
前端·后端
零度晚风2 小时前
JS:基础语法与控制结构
javascript
锋行天下2 小时前
关于websocket,真实场景踩坑经验
前端·后端