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 的场景
-
用户偏好与本地开关
主题、语言、表格列宽、上次选中的 tab、功能开关。数据小、读多写少、希望首屏同步读完。
-
简单草稿或表单暂存(跨会话)
几 KB 以内的 JSON,例如筛选条件、未提交的备注。注意:敏感信息不要明文长期放本地。
-
与第三方库默认集成
很多状态持久化插件默认就是 localStorage,在数据量可控时不必强行换 IndexedDB。
-
需要「立刻读到」且能接受阻塞极短时间
例如路由守卫里同步读一个 flag(仍建议控制体积,避免大 JSON)。
4.2 优先用 IndexedDB 的场景
-
离线或大缓存
已下载的视频片段、音频采样、字体、wasm、大图缓存。
-
结构化数据量大
聊天记录归档、工单列表缓存、复杂文档树、游戏存档。
-
需要按字段查询
按
userId、updatedAt范围查,而不是每次getItem再JSON.parse全表扫描。 -
写入二进制且不想 Base64 膨胀
Blob 直接存;若硬塞进 localStorage 只能 Base64,体积大约膨胀 33%,且同步 stringify 更卡。
-
后台同步 / 队列
待上传队列、增量同步游标,条目多时用 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 是不是更好?」
结论:不是。 盲目统一会带来这些问题:
-
开发成本
IndexedDB 原生 API 冗长,要写 open、upgrade、transaction、错误处理;小配置也要 async,首屏与单元测试都更麻烦。
-
过度设计
存一个
'theme': 'dark'用 IndexedDB,好比用数据库记开关------能跑,但读写路径、监控、迁移都更重。 -
主线程并非绝对更安全
IndexedDB 虽异步,但 反序列化大对象、渲染缓存仍可能在回调里占主线程;大对象照样要控制单次读写体积。
-
配额与清理策略更复杂
库大了要做 LRU、按版本清库、迁移 schema;localStorage 清一个 key 往往就够。
-
仍要处理「不能存 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、语雀等环境的兼容性:
九、和其他存储怎么选(扩展阅读)
| API | 适用 |
|---|---|
| sessionStorage | 单 tab 会话内的临时状态 |
| Cookie | 需要随 HTTP 请求带给服务端、或 HttpOnly 安全会话 |
| Cache API | Service Worker 控制的静态资源与请求响应缓存 |
| OPFS | 大文件、流式读写、接近文件系统语义(新能力,需查兼容性) |
十、总结
| 问题 | 答案 |
|---|---|
| 核心区别? | localStorage:小字符串 KV、同步;IndexedDB:大对象/二进制、异步、可索引 |
| 业务怎么选? | 偏好与小品 JSON → localStorage;媒体与大型缓存 → IndexedDB;常两者组合 |
| 全用 IndexedDB? | 不必,也不划算;简单场景用 localStorage 更清晰、更省事 |
| 项目实践启示? | 媒体池、大型文档缓存、静态资源包等大对象走 IndexedDB;主题、布局、资源清单走 localStorage |
存储技术是为 数据形态与访问模式 服务的。先问「有多大、会不会阻塞、要不要查表、刷新后 blob URL 还有效吗」,再选 API,比统一押注某一种更靠谱。