首页 / WXT 浏览器扩展框架教程 / 存储:状态放哪才不丢

WXT 浏览器扩展框架教程

存储:状态放哪才不丢

本教程共 45 篇 · 第 18 篇 · 更新于 2026-08-13 · 约 4 分钟阅读

WXT存储storagedefineItem迁移订阅chrome.storage

本节目标:搞懂扩展的数据该往哪存:四个存储区域怎么选,WXT 内置的 storage 封装怎么用,以及 defineItem、watch、版本化迁移这些高级能力分别解决什么问题。

第 10 章说过:后台 worker 会被浏览器随时休眠,内存里的东西说没就没。要跨会话保存状态(主题、登录态、设置),得用浏览器提供的持久化存储。这一章是第 17 章消息通信的姊妹篇:消息负责临时传话,存储负责长期记账。

存储区域:先选房间

浏览器存储分四个区域,WXT 的封装沿用了它们的名字:

前缀区域特点
local:storage.local本机保存,容量大,最常用
session:storage.session会话级,浏览器关了就清
sync:storage.sync跟随浏览器账号同步
managed:storage.managed企业策略写入,只读

原生 chrome.storage API 的用法、各区域的容量和差异,Chrome / Firefox 官方文档讲得很清楚(WXT 选型页也直接指向它们),本节聚焦 WXT 的封装层。

WXT 内置封装:wxt/storage

@wxt-dev/storage 是 WXT 自带的封装,不需要额外安装,通过 #imports 或自动导入使用(自动导入机制见 §29)。使用前记得在 manifest 里加 storage 权限:

export default defineConfig({
  manifest: { permissions: ["storage"] },
});

最重要的一条规则:key 必须带区域前缀,否则直接报错:

await storage.getItem("installDate"); // ❌ 抛错
await storage.getItem("local:installDate"); // ✅

基础读写和原生差不多,但每个方法都可以带类型参数:

await storage.setItem("local:counter", 1);
const count = await storage.getItem<number>("local:counter");
const unwatch = storage.watch<number>("local:counter", (newValue, oldValue) => {
  // 值变化时触发,返回的 unwatch 用于取消监听
});

watch 是原生 API 没有的便利:任何环境改了值,所有订阅方都会收到通知。多环境 UI 同步就靠它。

defineItem:把 key 和类型绑在一起

每次都写 "local:xxx" 加类型参数,又烦又容易错。storage.defineItem 把「key + 类型 + 默认值」封装成一个条目对象:

// lib/storage.ts
export const theme = storage.defineItem<"light" | "dark">("local:theme", {
  fallback: "dark",
});

export const installDate = storage.defineItem<number>("local:install-date", {
  init: () => Date.now(), // 首次自动写入,之后不再覆盖
});

条目提供 getValue / setValue / removeValue / watch 等方法,类型全局一致:

await theme.getValue();      // "dark"(没存过时返回 fallback)
await theme.setValue("light");
const unwatch = theme.watch((value) => { /* ... */ });

fallbackinit 的区别:fallback 只是「读不到时返回的默认值」,不会写进存储;init 会真的把初始值写进去,适合用户 ID、安装日期这种只需初始化一次的数据。

版本化迁移:数据结构变了怎么办

扩展会更新,存储的数据结构也会变。直接改类型,老用户存的老格式就炸了。WXT 的解法是给条目加 versionmigrations

// v1 存的是 string[],v2 要升级成对象数组
export const ignoredWebsites = storage.defineItem<IgnoredWebsiteV2[]>(
  "local:ignoredWebsites",
  {
    fallback: [],
    version: 2,
    migrations: {
      2: (websites: string[]): IgnoredWebsiteV2[] =>
        websites.map((website) => ({ id: nanoid(), website })),
    },
  },
);

规则很简单:

  • 首次定义从 version: 1 开始;老数据没有版本号时,WXT 默认当作 v1。
  • 每次改结构,version 加一,同时补一条对应版本的迁移函数。
  • 迁移在 defineItem 被调用时自动执行,之后的读写都会等迁移完成。
  • 版本号存在元数据里,内部字段叫 v

就算一开始没设计版本化也没关系:老数据会被当作 v1,直接设 version: 2 加迁移即可「补票」。

元数据与批量操作

每个 key 还能挂一份元数据(存在 key + "$" 下),比如最后修改时间:

await storage.setMeta("local:preference", { lastModified: Date.now() });
await storage.getMeta("local:preference"); // { lastModified: ... }

多次 setMeta 是合并而不是覆盖;removeMeta 可以删全部或指定字段。

需要一次读写多个 key 时,用批量 API 减少调用次数:

await storage.setItems([
  { key: "local:installDate", value: Date.now() },
  { item: userId, value: generateUserId() }, // 也支持 defineItem 条目
]);

同类还有 getItems / getMetas / setMetas / removeItems

React 里的订阅模式

mkext 项目封装了一个 useStorage 钩子,把「订阅 + 首次读取」缝在一起。它有个讲究的细节:watchgetValue。先订阅,首次读取期间落进来的更新就不会丢;而且 watch 到的值永远比读到的旧值新,优先采用:

useEffect(() => {
  let active = true;
  const unwatch = item.watch((value) => {
    if (active) setState({ value, isLoaded: true });
  });
  void item.getValue().then((value) => {
    if (active) {
      setState((prev) => (prev.isLoaded ? prev : { value, isLoaded: true }));
    }
  });
  return () => {
    active = false;
    unwatch();
  };
}, [item]);

isLoaded 字段也很重要:首次异步读取完成前为 false,界面不要急着拿 fallback 值渲染,避免「默认主题闪一下再跳成用户主题」。

小结

  • 数据跨会话保存必须进存储,别指望后台内存(§10)。
  • key 永远带区域前缀;简单字段用 getItem,正式字段用 defineItem
  • 结构会变的条目,从 v1 开始就设计好版本化和迁移。
  • 界面订阅用 watch,React 里参考「先 watch 再读」的 useStorage 模式。