浏览器存储方案
What — 是什么
浏览器提供多种客户端存储机制,从简单的 key-value 到完整的客户端数据库,覆盖不同数据规模和场景需求。
核心方案:
- Cookie:4KB 限制,随请求自动发送,主要用于认证(详见 Cookie与认证)
- localStorage:5-10MB,持久化 key-value,同步 API
- sessionStorage:5-10MB,标签页关闭即清除,同步 API
- IndexedDB:无硬性上限(通常数百 MB),事务型 NoSQL 数据库,异步 API
- Cache API:配合 Service Worker 的 Request/Response 缓存
- OPFS(Origin Private File System):高性能文件系统,Worker 内可用
关键特性:
- 所有存储同源隔离,跨域无法访问
- localStorage/sessionStorage 同步阻塞主线程,大数据用 IndexedDB
- IndexedDB 支持索引、事务、游标,是客户端唯一”数据库”
Why — 为什么
适用场景:
- localStorage:主题偏好、小型配置、Token
- sessionStorage:表单临时数据、标签页状态
- IndexedDB:离线数据、草稿、大文件分片、客户端缓存
- Cache API:HTTP 请求/响应缓存
- OPFS:大文件读写、WASM 文件系统
方案对比:
| 维度 | Cookie | localStorage | sessionStorage | IndexedDB | Cache API |
|---|---|---|---|---|---|
| 容量 | ~4KB | 5-10MB | 5-10MB | 数百MB+ | 数百MB+ |
| 生命周期 | 可设过期 | 永久 | 标签页关闭 | 永久 | 永久 |
| API | 同步 | 同步 | 同步 | 异步 | 异步 |
| 数据类型 | 字符串 | 字符串 | 字符串 | 结构化数据 | Request/Response |
| 随请求发送 | 是 | 否 | 否 | 否 | 否 |
| Web Worker | ❌ | ❌ | ❌ | ✅ | ✅ |
How — 怎么用
localStorage / sessionStorage
// 封装带类型的 localStorage
const storage = {
get<T>(key: string, fallback?: T): T | undefined {
try {
const raw = localStorage.getItem(key);
return raw ? JSON.parse(raw) : fallback;
} catch {
return fallback;
}
},
set(key: string, value: unknown): void {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
// StorageFull:清理或提示
console.error('存储空间已满', e);
}
},
remove(key: string): void {
localStorage.removeItem(key);
},
};
// 使用
storage.set('theme', 'dark');
storage.set('user', { id: 1, name: 'Alice' });
const theme = storage.get<string>('theme', 'light');
const user = storage.get<User>('user');
IndexedDB
基础封装:
// 封装 IndexedDB 操作
class DB {
private db: IDBDatabase | null = null;
constructor(private name: string, private version: number) {}
async open(stores: Record<string, string | string[]>) {
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(this.name, this.version);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
for (const [storeName, keyPath] of Object.entries(stores)) {
if (!db.objectStoreNames.contains(storeName)) {
db.createObjectStore(storeName, {
keyPath,
autoIncrement: keyPath === undefined,
});
}
}
};
request.onsuccess = () => {
this.db = request.result;
resolve(request.result);
};
request.onerror = () => reject(request.error);
});
}
async add<T>(storeName: string, data: T) {
const tx = this.db!.transaction(storeName, 'readwrite');
tx.objectStore(storeName).add(data);
return this.wrapTransaction<T>(tx);
}
async get<T>(storeName: string, key: IDBValidKey): Promise<T | undefined> {
const tx = this.db!.transaction(storeName, 'readonly');
const request = tx.objectStore(storeName).get(key);
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async getAll<T>(storeName: string): Promise<T[]> {
const tx = this.db!.transaction(storeName, 'readonly');
const request = tx.objectStore(storeName).getAll();
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async put<T>(storeName: string, data: T) {
const tx = this.db!.transaction(storeName, 'readwrite');
tx.objectStore(storeName).put(data);
return this.wrapTransaction<T>(tx);
}
async delete(storeName: string, key: IDBValidKey) {
const tx = this.db!.transaction(storeName, 'readwrite');
tx.objectStore(storeName).delete(key);
return this.wrapTransaction(tx);
}
private wrapTransaction<T>(tx: IDBTransaction): Promise<T> {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve(undefined as T);
tx.onerror = () => reject(tx.error);
});
}
}
使用示例:
// 初始化
const db = new DB('myApp', 1);
await db.open({
notes: 'id', // keyPath = 'id'
drafts: 'id',
files: 'id',
});
// 增
await db.add('notes', { id: '1', title: '会议记录', content: '...', updatedAt: Date.now() });
// 查
const note = await db.get<Note>('notes', '1');
const allNotes = await db.getAll<Note>('notes');
// 改
await db.put('notes', { id: '1', title: '会议记录(更新)', content: '...', updatedAt: Date.now() });
// 删
await db.delete('notes', '1');
使用索引查询:
// 创建索引(在 onupgradeneeded 中)
const store = db.createObjectStore('contacts', { keyPath: 'id' });
store.createIndex('by_email', 'email', { unique: true });
store.createIndex('by_city', 'city', { unique: false });
// 通过索引查询
async function getByIndex(storeName: string, indexName: string, value: IDBValidKey) {
const tx = db.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
const index = store.index(indexName);
const request = index.getAll(value);
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
const contacts = await getByIndex('contacts', 'by_city', 'Beijing');
Cache API
// 缓存 HTTP 请求/响应
async function cacheRequest(url: string) {
const cache = await caches.open('api-v1');
let response = await cache.match(url);
if (!response) {
response = await fetch(url);
cache.put(url, response.clone());
}
return response;
}
// 清理旧缓存
async function cleanOldCaches(keepName: string) {
const keys = await caches.keys();
await Promise.all(
keys.filter(key => key !== keepName).map(key => caches.delete(key))
);
}
存储空间查询与清理
// 查询存储用量
async function getStorageEstimate() {
if (!navigator.storage?.estimate) return null;
const { usage, quota } = await navigator.storage.estimate();
return {
usageMB: (usage! / 1024 / 1024).toFixed(2),
quotaMB: (quota! / 1024 / 1024).toFixed(2),
usagePercent: ((usage! / quota!) * 100).toFixed(2),
};
}
// 请求持久化存储(避免浏览器自动清理)
async function requestPersistentStorage() {
if (navigator.storage?.persist) {
const granted = await navigator.storage.persist();
console.log('持久化存储:', granted ? '已授予' : '未授予');
}
}
常见问题与踩坑
| 问题 | 原因 | 解决方案 |
|---|---|---|
| localStorage 写入失败 | 存储空间已满(5-10MB) | try/catch 捕获,大数据用 IndexedDB |
| IndexedDB 事务自动提交 | 事件循环中未同步操作 | 事务内操作必须同步执行,不要混入 await |
| 隐身模式存储受限 | 隐身窗口关闭即清除 | 检测隐身模式,降低离线功能预期 |
| 多标签页数据冲突 | 同时写入同一 key | 用 storage 事件或 BroadcastChannel 同步 |
| Safari 存储限制严格 | 7 天后清空未使用站点的存储 | 请求持久化存储 + 用户交互触发 |
最佳实践
- 小配置用 localStorage,大数据用 IndexedDB
- localStorage 封装 JSON 序列化 + try/catch
- IndexedDB 事务内操作保持同步,不要混入异步
- 隐私数据不要存 localStorage(XSS 可读取)
- 大型离线应用调用
navigator.storage.persist()防止浏览器清理
面试题
Q1: localStorage 和 sessionStorage 的区别是什么?
生命周期不同:localStorage 持久存储,手动删除才清除;sessionStorage 标签页关闭即清除。作用域不同:localStorage 同源所有标签页共享;sessionStorage 仅当前标签页可见,不同标签页独立。两者 API 相同、容量相同(5-10MB)、都为同步 API、都只能存字符串。
Q2: IndexedDB 事务使用时有哪些注意事项?
事务内操作必须同步执行,不能混入
await或异步回调(否则事务会自动提交并关闭);事务完成通过oncomplete监听而非回调返回值;操作失败会回滚整个事务;注意 IndexedDB 的异步 API 设计,需正确处理onsuccess/onerror或封装为 Promise。隐身模式下存储受限,关闭即清除。
Q3: Cookie 和 localStorage 在安全性上有什么差异?
Cookie 可设置
HttpOnly(防 XSS 读取)、Secure(仅 HTTPS)、SameSite(防 CSRF),安全属性丰富;localStorage 无这些保护,任何 JS 都可读取,XSS 攻击可直接窃取。但 Cookie 会随请求自动发送,存在 CSRF 风险;localStorage 不会自动发送,天然防 CSRF。敏感数据不应存 localStorage。
Q4: 浏览器存储空间有什么限制?超限会怎样?
Cookie 约 4KB;localStorage/sessionStorage 约 5-10MB(各浏览器不同);IndexedDB 和 Cache API 无硬性上限,通常可达数百 MB 甚至数 GB,受磁盘空间影响。localStorage 超限写入会抛出
QuotaExceededError;IndexedDB 超限时浏览器可能提示用户或自动清理”最佳努力”(非持久化)存储。可通过navigator.storage.persist()请求持久化存储避免被清理。
相关链接: