浏览器存储方案

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 文件系统

方案对比:

维度CookielocalStoragesessionStorageIndexedDBCache API
容量~4KB5-10MB5-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
隐身模式存储受限隐身窗口关闭即清除检测隐身模式,降低离线功能预期
多标签页数据冲突同时写入同一 keystorage 事件或 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() 请求持久化存储避免被清理。


相关链接: