Web 前端本地存储:localStorage 与 IndexedDB

Web 前端本地存储:localStorage 与 IndexedDB

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


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

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

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

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

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

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


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

维度 localStorage IndexedDB
数据模型 字符串键值对(值必须是字符串) 对象仓库(Object Store),可存结构化对象、Blob、ArrayBuffer 等
容量 通常约 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. 需要按字段查询

    按 userId、updatedAt 范围查,而不是每次 getItem 再 JSON.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)
}

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

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,比统一押注某一种更靠谱。


相关推荐
羲云5 小时前
给 Vue 页面加个 Markdown 编辑器:ME.js 的接入、图片粘贴与音视频
javascript·vue.js
zhangzeyuaaa5 小时前
Ruby 方法参数完全指南:默认值、可变参数与关键字参数
前端·python·ruby
粥里有勺糖5 小时前
视野修炼-技术周刊第135期 | 鼠标跟随宠物
前端·github·aigc
粥里有勺糖6 小时前
拿 Codex 协助清理磁盘,Nice!
前端·后端·github
Highcharts.js6 小时前
可视化商用图表库对比,如何选择?
前端·javascript·学习·信息可视化·highcharts·前端可视化
TheITSea7 小时前
JavaScript Web APIs
开发语言·前端·javascript
恋猫de小郭7 小时前
Android CLI 支持 AI Agent 通过 Device Streaming 调试云真机
android·前端·flutter
Wang's Blog7 小时前
Java 项目实战: 外卖平台优化-前端dist部署与Nginx反向代理rewrite
java·前端·nginx
鬓戈7 小时前
Claude Code frontend-design 插件调研 与 Vue 旧系统风格一致性方案
前端·vue.js·人工智能
开开心心就好7 小时前
以图搜图找重复图片,本地工具离线就能用
javascript·智能手机·ffmpeg·c#·ocr·word·音视频