存储:状态放哪才不丢
本教程共 45 篇 · 第 18 篇 · 更新于 2026-08-13 · 约 4 分钟阅读
本节目标:搞懂扩展的数据该往哪存:四个存储区域怎么选,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) => { /* ... */ });
fallback 和 init 的区别:fallback 只是「读不到时返回的默认值」,不会写进存储;init 会真的把初始值写进去,适合用户 ID、安装日期这种只需初始化一次的数据。
版本化迁移:数据结构变了怎么办
扩展会更新,存储的数据结构也会变。直接改类型,老用户存的老格式就炸了。WXT 的解法是给条目加 version 和 migrations:
// 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 钩子,把「订阅 + 首次读取」缝在一起。它有个讲究的细节:先 watch 再 getValue。先订阅,首次读取期间落进来的更新就不会丢;而且 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模式。