强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

Chrome 扩展开发完全指南 / 第 8 章:存储 API(Storage API)

第 8 章:存储 API(Storage API)

数据存储是几乎所有扩展的核心需求。Chrome 提供了三种存储区域——local、sync 和 session,各有特点和适用场景。本章将深入讲解每种存储的用法、限制和最佳实践。


8.1 三种存储区域

特性localsyncsession
存储位置本地磁盘Google 云端内存
容量限制10 MB100 KB10 MB
单项限制无特别限制8 KB无特别限制
跨设备同步
Service Worker 重启后保留❌(MV3 有限制)
写入频率限制120 次/分钟
需要权限"storage""storage""storage"
适用场景大量数据、缓存用户设置、偏好临时状态

存储架构

┌──────────────────────────────────────────────────┐
│                 Chrome 扩展                       │
│                                                   │
│  ┌─────────────┐  ┌─────────────┐  ┌───────────┐ │
│  │   local      │  │   sync      │  │  session  │ │
│  │  10 MB       │  │  100 KB     │  │  10 MB    │ │
│  │  本地持久化   │  │  跨设备同步  │  │  内存     │ │
│  └──────┬──────┘  └──────┬──────┘  └─────┬─────┘ │
│         │                │                │       │
│  ┌──────▼───────────────▼────────────────▼─────┐ │
│  │              chrome.storage API              │ │
│  └─────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘

8.2 基本操作

8.2.1 写入数据

// 写入单个值
await chrome.storage.local.set({ key: 'value' });

// 写入多个值
await chrome.storage.local.set({
  username: 'user123',
  theme: 'dark',
  settings: {
    notifications: true,
    fontSize: 14
  },
  recentItems: [1, 2, 3, 4, 5]
});

// sync 存储
await chrome.storage.sync.set({ preferredLanguage: 'zh-CN' });

// session 存储
await chrome.storage.session.set({ currentPage: 1 });

8.2.2 读取数据

// 读取单个值
const result = await chrome.storage.local.get('username');
console.log(result.username); // 'user123'

// 读取多个值
const data = await chrome.storage.local.get(['theme', 'settings']);
console.log(data.theme);    // 'dark'
console.log(data.settings); // { notifications: true, fontSize: 14 }

// 读取全部数据
const allData = await chrome.storage.local.get(null);
console.log(allData); // { username: '...', theme: '...', ... }

// 带默认值的读取
async function getWithDefault(key, defaultValue) {
  const result = await chrome.storage.local.get(key);
  return result[key] ?? defaultValue;
}

const theme = await getWithDefault('theme', 'light');

8.2.3 删除数据

// 删除单个键
await chrome.storage.local.remove('username');

// 删除多个键
await chrome.storage.local.remove(['theme', 'settings']);

// 清空整个存储区域
await chrome.storage.local.clear();

8.2.4 获取存储信息

// 获取已用字节数
const localBytes = await chrome.storage.local.getBytesInUse();
console.log(`local 已用: ${localBytes} / ${10 * 1024 * 1024} bytes`);

// 获取特定键的字节数
const themeBytes = await chrome.storage.local.getBytesInUse('theme');
console.log(`theme 占用: ${themeBytes} bytes`);

// sync 存储
const syncBytes = await chrome.storage.sync.getBytesInUse();
console.log(`sync 已用: ${syncBytes} / ${100 * 1024} bytes`);

8.3 存储变更监听

8.3.1 监听变化事件

// 监听所有存储区域的变化
chrome.storage.onChanged.addListener((changes, areaName) => {
  console.log(`存储区域 "${areaName}" 发生变化:`);

  for (const [key, { oldValue, newValue }] of Object.entries(changes)) {
    console.log(`  ${key}: ${JSON.stringify(oldValue)}${JSON.stringify(newValue)}`);
  }
});

// 只监听特定区域
chrome.storage.onChanged.addListener((changes, areaName) => {
  if (areaName !== 'sync') return;

  if (changes.theme) {
    applyTheme(changes.theme.newValue);
  }

  if (changes.fontSize) {
    document.documentElement.style.fontSize = changes.fontSize.newValue + 'px';
  }
});

8.3.2 Service Worker 中的响应式更新

// background/service-worker.js

chrome.storage.onChanged.addListener(async (changes, areaName) => {
  // 通知所有打开的 UI 页面
  const views = chrome.runtime.getContexts({
    contextTypes: ['POPUP', 'SIDE_PANEL', 'TAB']
  });

  for (const view of views) {
    try {
      await chrome.tabs.sendMessage(view.tabId || 0, {
        type: 'STORAGE_CHANGED',
        changes,
        areaName
      });
    } catch (e) {
      // 页面可能已关闭
    }
  }
});

8.4 高级存储模式

8.4.1 类型安全的存储封装

// lib/storage.js

class TypedStorage {
  constructor(area = 'local') {
    this.area = chrome.storage[area];
    this.validators = new Map();
  }

  // 注册键的类型验证器
  register(key, validator, defaultValue) {
    this.validators.set(key, { validator, defaultValue });
    return this;
  }

  async get(key) {
    const result = await this.area.get(key);
    const config = this.validators.get(key);

    if (result[key] === undefined && config) {
      return config.defaultValue;
    }

    return result[key];
  }

  async set(key, value) {
    const config = this.validators.get(key);
    if (config && !config.validator(value)) {
      throw new Error(`Validation failed for key "${key}"`);
    }

    await this.area.set({ [key]: value });
  }

  async getMultiple(keys) {
    const result = await this.area.get(keys);
    const config = this.validators;

    for (const key of keys) {
      if (result[key] === undefined && config.has(key)) {
        result[key] = config.get(key).defaultValue;
      }
    }

    return result;
  }
}

// 使用示例
const storage = new TypedStorage('local')
  .register('theme', v => ['light', 'dark', 'system'].includes(v), 'system')
  .register('fontSize', v => typeof v === 'number' && v >= 12 && v <= 24, 14)
  .register('username', v => typeof v === 'string' && v.length > 0, '');

// 类型安全的读写
await storage.set('theme', 'dark');       // ✅
await storage.set('theme', 'invalid');    // ❌ 抛出错误
const theme = await storage.get('theme'); // 'dark'

8.4.2 带过期时间的缓存

// lib/cache.js

class CacheStorage {
  constructor(ttl = 3600000) { // 默认 1 小时
    this.ttl = ttl;
  }

  async set(key, value, ttl = this.ttl) {
    const entry = {
      value,
      expiresAt: Date.now() + ttl
    };
    await chrome.storage.local.set({ [key]: entry });
  }

  async get(key) {
    const result = await chrome.storage.local.get(key);
    const entry = result[key];

    if (!entry) return undefined;

    if (Date.now() > entry.expiresAt) {
      await chrome.storage.local.remove(key);
      return undefined; // 已过期
    }

    return entry.value;
  }

  async has(key) {
    return (await this.get(key)) !== undefined;
  }

  async clear() {
    const all = await chrome.storage.local.get(null);
    const keys = Object.keys(all);
    await chrome.storage.local.remove(keys);
  }

  // 清理过期缓存
  async cleanup() {
    const all = await chrome.storage.local.get(null);
    const now = Date.now();
    const expiredKeys = [];

    for (const [key, entry] of Object.entries(all)) {
      if (entry?.expiresAt && now > entry.expiresAt) {
        expiredKeys.push(key);
      }
    }

    if (expiredKeys.length > 0) {
      await chrome.storage.local.remove(expiredKeys);
    }

    return expiredKeys.length;
  }
}

// 使用示例
const cache = new CacheStorage(30 * 60 * 1000); // 30 分钟 TTL

// 缓存 API 响应
async function fetchWithCache(url) {
  const cacheKey = `cache:${url}`;

  let data = await cache.get(cacheKey);
  if (data) return data; // 命中缓存

  const response = await fetch(url);
  data = await response.json();
  await cache.set(cacheKey, data);

  return data;
}

8.4.3 数据迁移

// lib/migration.js

class StorageMigration {
  constructor(storageKey = 'dbVersion') {
    this.storageKey = storageKey;
    this.migrations = [];
  }

  addMigration(version, migrateFn) {
    this.migrations.push({ version, migrateFn });
    this.migrations.sort((a, b) => a.version - b.version);
    return this;
  }

  async run() {
    const { [this.storageKey]: currentVersion = 0 } =
      await chrome.storage.local.get(this.storageKey);

    const pending = this.migrations.filter(m => m.version > currentVersion);

    if (pending.length === 0) {
      console.log('无需迁移');
      return;
    }

    for (const migration of pending) {
      console.log(`执行迁移 v${migration.version}...`);
      try {
        await migration.migrateFn();
        await chrome.storage.local.set({
          [this.storageKey]: migration.version
        });
      } catch (error) {
        console.error(`迁移 v${migration.version} 失败:`, error);
        throw error;
      }
    }

    console.log('迁移完成');
  }
}

// 使用示例
const migrator = new StorageMigration();

migrator
  .addMigration(1, async () => {
    // v1: 将 settings 从 flat 结构改为嵌套结构
    const { theme, fontSize } = await chrome.storage.local.get(
      ['theme', 'fontSize']
    );
    await chrome.storage.local.set({
      settings: { theme: theme || 'light', fontSize: fontSize || 14 }
    });
    await chrome.storage.local.remove(['theme', 'fontSize']);
  })
  .addMigration(2, async () => {
    // v2: 添加 bookmarks 字段
    const { bookmarks } = await chrome.storage.local.get('bookmarks');
    if (!bookmarks) {
      await chrome.storage.local.set({ bookmarks: [] });
    }
  });

// Service Worker 安装时执行迁移
chrome.runtime.onInstalled.addListener(async () => {
  await migrator.run();
});

8.5 业务场景

场景一:表单自动保存

// content/auto-save.js

class FormAutoSave {
  constructor() {
    this.debounceTimer = null;
    this.init();
  }

  init() {
    document.querySelectorAll('textarea, input[type="text"]').forEach(input => {
      this.restoreValue(input);
      input.addEventListener('input', () => this.saveValue(input));
    });
  }

  getElementKey(element) {
    return `autosave:${window.location.pathname}:${element.name || element.id}`;
  }

  async saveValue(element) {
    clearTimeout(this.debounceTimer);
    this.debounceTimer = setTimeout(async () => {
      const key = this.getElementKey(element);
      await chrome.storage.session.set({ [key]: element.value });
    }, 500);
  }

  async restoreValue(element) {
    const key = this.getElementKey(element);
    const result = await chrome.storage.session.get(key);
    if (result[key] && !element.value) {
      element.value = result[key];
    }
  }
}

new FormAutoSave();

场景二:浏览历史统计

// background/stats.js

class BrowsingStats {
  async recordVisit(url, title) {
    const { visits = {} } = await chrome.storage.local.get('visits');

    const domain = new URL(url).hostname;

    if (!visits[domain]) {
      visits[domain] = { count: 0, lastVisit: null, pages: {} };
    }

    visits[domain].count++;
    visits[domain].lastVisit = Date.now();

    if (!visits[domain].pages[url]) {
      visits[domain].pages[url] = { title, count: 0 };
    }
    visits[domain].pages[url].count++;

    await chrome.storage.local.set({ visits });
  }

  async getTopDomains(limit = 10) {
    const { visits = {} } = await chrome.storage.local.get('visits');

    return Object.entries(visits)
      .sort(([, a], [, b]) => b.count - a.count)
      .slice(0, limit)
      .map(([domain, data]) => ({
        domain,
        count: data.count,
        lastVisit: data.lastVisit
      }));
  }
}

8.6 注意事项

问题原因解决方案
sync 写入失败超过频率限制(120 次/分钟)合并写入、使用 debounce
sync 数据丢失超过 100KB 限制大数据用 local,小配置用 sync
session 数据丢失Service Worker 重启关键数据改用 local
深层对象覆盖set() 不会合并嵌套对象手动合并后写入
读取到 undefined键不存在提供默认值

8.7 扩展阅读