浏览器存储 API

深入理解浏览器存储体系:localStorage、sessionStorage、IndexedDB、Cookie 的特性、限制、API 使用、以及各自的适用场景。

浏览器存储 API(Browser Storage APIs)

一、浏览器存储体系概述

1.1 存储金字塔

现代浏览器提供了多层次的存储解决方案,每种方案都有其特定的用途、性能特征和容量限制。理解这些差异是选择正确存储方案的基础。

容量 ↑
      │   IndexedDB
      │   (无限制*)
      │
      ├─ localStorage
      │  (约 5-10MB)
      │
      ├─ Session Storage
      │  (约 5-10MB)
      │
      ├─ Cookie
      │  (约 4KB)
      │
      └─ Cache API
         (无限制*)

*IndexedDB 和 Cache API 的限制取决于可用的磁盘空间和浏览器策略

1.2 选择存储方案的因素

在选择存储方案时,需要考虑以下因素:

数据持久性:数据需要保存多久?

  • Session Storage:标签页关闭后清除
  • localStorage:持久保存,直到手动清除
  • IndexedDB:持久保存,容量更大

数据类型:存储什么类型的数据?

  • 简单键值对:localStorage/sessionStorage
  • 结构化数据:IndexedDB
  • 需要发送服务器:Cookie

同步还是异步:API 特性

  • localStorage/sessionStorage:同步 API
  • IndexedDB:异步 API(但也可以同步访问)

容量需求:需要存储多少数据?

  • Cookie:4KB
  • localStorage/sessionStorage:5-10MB
  • IndexedDB:几百 MB 到无限制

二、Web Storage:localStorage 和 sessionStorage

2.1 基本 API

Web Storage 提供了简洁的键值对存储接口:

// 存储数据
localStorage.setItem('username', '张三');
localStorage.setItem('preferences', JSON.stringify({ theme: 'dark', lang: 'zh' }));

// 读取数据
const username = localStorage.getItem('username');
const preferences = JSON.parse(localStorage.getItem('preferences'));

// 删除数据
localStorage.removeItem('username');

// 清空所有数据
localStorage.clear();

// 获取所有键
for (let i = 0; i < localStorage.length; i++) {
  const key = localStorage.key(i);
  const value = localStorage.getItem(key);
  console.log(`${key}: ${value}`);
}

2.2 sessionStorage 的生命周期

sessionStorage 与 localStorage 的 API 完全相同,但生命周期不同:

// sessionStorage 在新标签页中创建独立的存储
sessionStorage.setItem('tempData', '仅当前标签页有效');

// 关闭标签页后,数据自动清除
// 刷新页面不会清除 sessionStorage
// 但"恢复上次会话"功能可能会保留 sessionStorage

// 在同一标签页的 iframe 之间共享
// 但不同标签页的 sessionStorage 相互隔离

2.3 存储事件

当 localStorage 发生变化时,同源的其他标签页会收到通知:

// 监听存储变化
window.addEventListener('storage', (event) => {
  console.log('Key:', event.key);
  console.log('Old Value:', event.oldValue);
  console.log('New Value:', event.newValue);
  console.log('Storage Area:', event.storageArea);
});

// 注意:设置相同值不会触发事件
// 当前标签页的修改不会触发自身的事件

2.4 容量与限制

// 检测存储是否已满
function isQuotaExceeded(e) {
  return e.name === 'QuotaExceededError' ||
         e.name === 'NS_ERROR_DOM_QUOTA_REACHED';
}

try {
  localStorage.setItem('data', 'some large data');
} catch (e) {
  if (isQuotaExceeded(e)) {
    console.log('Storage quota exceeded');
  }
}

// 估算已用空间
function estimateStorageUsage() {
  let total = 0;
  for (let key in localStorage) {
    if (localStorage.hasOwnProperty(key)) {
      total += localStorage[key].length + key.length;
    }
  }
  return total;
}

2.5 实际应用场景

// 主题偏好
function setTheme(theme) {
  localStorage.setItem('theme', theme);
  document.documentElement.setAttribute('data-theme', theme);
}

// 表单草稿自动保存
function saveFormDraft(formId, data) {
  sessionStorage.setItem(`draft_${formId}`, JSON.stringify(data));
}

function loadFormDraft(formId) {
  const draft = sessionStorage.getItem(`draft_${formId}`);
  return draft ? JSON.parse(draft) : null;
}

function clearFormDraft(formId) {
  sessionStorage.removeItem(`draft_${formId}`);
}

// 多语言支持
function setLanguage(lang) {
  localStorage.setItem('preferredLanguage', lang);
  location.reload();
}

三、IndexedDB:浏览器端数据库

3.1 IndexedDB 的核心概念

IndexedDB 是一个完整的浏览器内数据库系统,包含以下核心概念:

数据库(Database):一个 IndexedDB 数据库可以包含多个对象存储区

对象存储区(Object Store):类似于 SQL 的表,但以对象为单位存储

索引(Index):用于快速查询的索引字段

事务(Transaction):保证数据一致性的操作单位

游标(Cursor):用于遍历数据集

3.2 数据库初始化

// 打开数据库
function openDB(dbName, version = 1) {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(dbName, version);

    request.onerror = () => reject(request.error);
    request.onsuccess = () => resolve(request.result);

    request.onupgradeneeded = (event) => {
      const db = event.target.result;

      // 创建对象存储区
      if (!db.objectStoreNames.contains('users')) {
        // 主键索引
        const store = db.createObjectStore('users', { keyPath: 'id' });

        // 创建普通索引
        store.createIndex('name', 'name', { unique: false });

        // 创建唯一索引
        store.createIndex('email', 'email', { unique: true });

        // 创建复合索引
        store.createIndex('nameAge', ['name', 'age'], { unique: false });
      }
    };
  });
}

// 使用
openDB('myDatabase', 1).then((db) => {
  console.log('Database opened:', db.name, db.version);
}).catch((err) => {
  console.error('Failed to open database:', err);
});

3.3 CRUD 操作

// 添加数据
function addUser(db, user) {
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(['users'], 'readwrite');
    const store = transaction.objectStore('users');
    const request = store.add(user);

    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

// 读取数据
function getUser(db, id) {
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(['users'], 'readonly');
    const store = transaction.objectStore('users');
    const request = store.get(id);

    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

// 更新数据
function updateUser(db, user) {
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(['users'], 'readwrite');
    const store = transaction.objectStore('users');
    const request = store.put(user);  // put 会覆盖已存在的记录

    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

// 删除数据
function deleteUser(db, id) {
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(['users'], 'readwrite');
    const store = transaction.objectStore('users');
    const request = store.delete(id);

    request.onsuccess = () => resolve();
    request.onerror = () => reject(request.error);
  });
}

3.4 查询与索引

// 使用索引查询
function findUserByEmail(db, email) {
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(['users'], 'readonly');
    const store = transaction.objectStore('users');
    const index = store.index('email');
    const request = index.get(email);

    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

// 使用游标遍历所有数据
function getAllUsers(db) {
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(['users'], 'readonly');
    const store = transaction.objectStore('users');
    const request = store.openCursor();
    const users = [];

    request.onsuccess = (event) => {
      const cursor = event.target.result;
      if (cursor) {
        users.push(cursor.value);
        cursor.continue();
      } else {
        resolve(users);
      }
    };
    request.onerror = () => reject(request.error);
  });
}

// 使用索引和游标进行范围查询
function findUsersByAgeRange(db, minAge, maxAge) {
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(['users'], 'readonly');
    const store = transaction.objectStore('users');
    const index = store.index('age');
    const range = IDBKeyRange.bound(minAge, maxAge);
    const request = index.openCursor(range);
    const users = [];

    request.onsuccess = (event) => {
      const cursor = event.target.result;
      if (cursor) {
        users.push(cursor.value);
        cursor.continue();
      } else {
        resolve(users);
      }
    };
    request.onerror = () => reject(request.error);
  });
}

3.5 封装库的选择

直接使用 IndexedDB API 既繁琐又容易出错,实际开发中推荐使用封装库:

// Dexie.js - 简洁的 IndexedDB 封装
const db = new Dexie('myDatabase');

db.version(1).stores({
  users: '++id, name, email, age',
  posts: '++id, title, authorId'
});

async function main() {
  // 添加
  await db.users.add({ name: '张三', email: '[email protected]', age: 25 });

  // 查询
  const user = await db.users.where('email').equals('[email protected]').first();

  // 复杂查询
  const youngUsers = await db.users.where('age').below(30).toArray();

  // 关系查询
  const userPosts = await db.posts.where('authorId').equals(user.id).toArray();
}

四、Cookie 的现代用法

4.1 Cookie 的特点

Cookie 是最老的客户端存储方案有其历史原因,但它在现代 Web 中仍有不可替代的用途:

  • 自动随请求发送:Cookie 会被浏览器自动添加到 HTTP 请求的 Cookie 头中
  • 服务器可读:服务端可以读取和设置 Cookie
  • 容量极小:每个 Cookie 约 4KB
  • 可设置过期时间:可以控制 Cookie 的生命周期

4.2 JavaScript 操作 Cookie

// 读取所有 Cookie
function getCookies() {
  return document.cookie.split(';').reduce((cookies, cookie) => {
    const [name, value] = cookie.trim().split('=');
    cookies[name] = decodeURIComponent(value);
    return cookies;
  }, {});
}

// 设置 Cookie(简单方式)
document.cookie = 'username=张三; path=/; max-age=86400';  // 1 天
document.cookie = 'preferences=dark; path=/; max-age=2592000';  // 30 天

// 设置 Cookie(完整选项)
function setCookie(name, value, options = {}) {
  let cookieString = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;

  if (options.expires) {
    cookieString += `; expires=${options.expires.toUTCString()}`;
  }
  if (options.maxAge) {
    cookieString += `; max-age=${options.maxAge}`;
  }
  if (options.path) {
    cookieString += `; path=${options.path}`;
  }
  if (options.domain) {
    cookieString += `; domain=${options.domain}`;
  }
  if (options.secure) {
    cookieString += '; secure';
  }
  if (options.sameSite) {
    cookieString += `; samesite=${options.sameSite}`;
  }

  document.cookie = cookieString;
}

// 使用
setCookie('sessionId', 'abc123', {
  maxAge: 3600,  // 1 小时
  path: '/',
  sameSite: 'Strict'
});

4.3 HttpOnly 和 Secure Cookie

从安全角度,敏感 Cookie 不应该被 JavaScript 访问:

Set-Cookie: sessionId=abc123; HttpOnly; Secure; SameSite=Strict
  • HttpOnly:JavaScript 无法通过 document.cookie 读取
  • Secure:只通过 HTTPS 发送
  • SameSite:防止 CSRF 攻击

4.4 Cookie 的适用场景

// ✅ 适合:需要发送到服务器的少量数据
// 例如:会话标识、CSRF token、用户偏好(如果服务器需要)

// ❌ 不适合:大量数据(用 localStorage/IndexedDB)
// ❌ 不适合:客户端专用数据(用 localStorage)

五、Storage 进阶主题

5.1 存储事件监听

Web Storage 的 storage 事件只在变化时触发,且只在其他标签页触发:

window.addEventListener('storage', (event) => {
  // event.key: 变化的键
  // event.oldValue: 旧值
  // event.newValue: 新值
  // event.storageArea: localStorage 或 sessionStorage
  // event.url: 触发变化的文档 URL

  if (event.key === 'cart') {
    updateCartUI(JSON.parse(event.newValue));
  }
});

5.2 存储配额查询

// Navigator.storage API
async function getStorageEstimate() {
  if ('storage' in navigator && 'estimate' in navigator.storage) {
    const estimate = await navigator.storage.estimate();
    return {
      usage: estimate.usage,
      quota: estimate.quota,
      percentUsed: (estimate.usage / estimate.quota) * 100
    };
  }
  return null;
}

getStorageEstimate().then(({ usage, quota, percentUsed }) => {
  console.log(`Using ${(usage / 1024 / 1024).toFixed(2)} MB of ${(quota / 1024 / 1024).toFixed(2)} MB (${percentUsed.toFixed(2)}%)`);
});

5.3 持久化存储

默认情况下,存储是"尽力而为"的——浏览器可能在存储压力下清除数据。使用 Persistent Storage API 可以申请更长的保留期:

async function requestPersistentStorage() {
  if ('storage' in navigator && 'persist' in navigator.storage) {
    const persistent = await navigator.storage.persist();
    console.log('Persistent storage granted:', persistent);
    return persistent;
  }
  return false;
}

// 检查是否有持久化存储权限
async function isPersisted() {
  if ('storage' in navigator && 'persisted' in navigator.storage) {
    return await navigator.storage.persisted();
  }
  return false;
}

5.4 数据迁移策略

当应用从 Cookie 迁移到 Web Storage 时:

// 从 Cookie 迁移到 localStorage
function migrateFromCookie() {
  const cookies = getCookies();
  if (cookies.userId && !localStorage.getItem('userId')) {
    localStorage.setItem('userId', cookies.userId);
    // 清除 Cookie
    document.cookie = 'userId=; max-age=0';
  }
}

六、实际应用架构

6.1 分层存储策略

// 存储分层
const StorageManager = {
  // 即时访问:少量配置
  config: {
    get: (key) => JSON.parse(localStorage.getItem(`config_${key}`)),
    set: (key, value) => localStorage.setItem(`config_${key}`, JSON.stringify(value)),
  },

  // 会话数据:当前会话需要
  session: {
    get: (key) => JSON.parse(sessionStorage.getItem(key)),
    set: (key, value) => sessionStorage.setItem(key, JSON.stringify(value)),
  },

  // 持久数据:IndexedDB
  db: {
    async get(store, key) {
      const db = await openDB('appDB');
      return getRecord(db, store, key);
    },
    async set(store, data) {
      const db = await openDB('appDB');
      return putRecord(db, store, data);
    },
  }
};

6.2 离线优先架构

// Service Worker 中的缓存策略
const CACHE_NAME = 'app-cache-v1';
const DATA_CACHE = 'data-cache-v1';

// 缓存应用外壳
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll([
        '/',
        '/index.html',
        '/styles.css',
        '/app.js'
      ]);
    })
  );
});

// IndexedDB 作为数据层
async function fetchAndCache(url) {
  const response = await fetch(url);
  if (response.ok) {
    const db = await openDB('offlineDB');
    await putRecord(db, 'responses', { url, data: await response.clone().json() });
    return response;
  }
  return getFromCache(url);
}

参考资料

延展阅读