Airi 项目中的异步响应式存储:VueUse `useStorageAsync` 深度实战指南
Airi 项目中的异步响应式存储:VueUse useStorageAsync 深度实战指南
本文围绕 VueUse 的 useStorageAsync 组合式函数(composable)展开,讲解如何在需要异步数据源(如 IndexedDB、本地文件系统、Electron IPC、远程 API 等)的应用中,构建"响应式 + 可持久化"的状态层。Airi 是一个支持 Web / macOS / Windows 的桌面级 AI 陪伴应用仓库,其多个应用与包都依赖 @vueuse/core 进行状态管理,本文将以仓库内的真实封装与使用场景为佐证,带你掌握 useStorageAsync 的等待首载、Promise 化、onReady 回调等关键用法,读完即可在 Vue 3 / Nuxt 3 项目中落地一套异步持久化方案。
一、什么是 useStorageAsync:为什么需要"异步"存储
useStorageAsync 是 VueUse 中 useStorage 的异步版本,官方定义是 "Reactive Storage with async support"(带异步支持的响应式存储)。它被归类在 VueUse 的 State(状态) 类别下,在仓库的 SKILL.md 函数表中标注的调用策略为 AUTO,即"适用时自动使用"。
与同步的 useStorage(默认读写 localStorage / sessionStorage)相比,useStorageAsync 的核心区别在于:
- 存储介质不再局限于浏览器同步 API,可以是任何读写返回 Promise 的存储实现;
- 返回值除了是一个可移除的响应式引用(
RemovableRef<T>)之外,同时还是一个 Promise,允许调用方在存储真正就绪后再继续业务逻辑; - 支持异步序列化(
SerializerAsync<T>),例如读取时需要解密、写入时需要加密的场景。
典型适用场景包括:IndexedDB / localforage、File System Access API、Electron 主进程的 IPC 持久化、需要网络往返的云端存储等。在 Airi 仓库中,桌面端(apps/stage-tamagotchi)、Web 端(apps/stage-web)、移动端(apps/stage-pocket)等多个应用的 package.json 都通过 pnpm catalog 统一依赖了 @vueuse/core,而仓库对持久化状态的封装正是建立在 useStorage / useLocalStorage 这一族 API 之上的(详见下文"仓库中的真实封装")。
二、基本用法:与 useStorage 一脉相承
官方文档明确指出:useStorageAsync 的基本用法请参考 useStorage。因此我们先建立对同步版本的核心认知,再来看异步差异。
useStorage 创建了一个可用于访问和修改 localStorage / sessionStorage 的响应式 ref:
import { useStorage } from '@vueuse/core'
// 绑定对象
const state = useStorage('my-store', { hello: 'hi', greeting: 'Hello' })
// 绑定布尔值
const flag = useStorage('my-flag', true) // 返回 Ref<boolean>
// 绑定数字
const count = useStorage('my-count', 0) // 返回 Ref<number>
// 绑定字符串并指定 SessionStorage
const id = useStorage('my-id', 'some-string-id', sessionStorage) // 返回 Ref<string>
// 将数据从存储中删除
state.value = null
useStorageAsync 的调用签名与之完全一致,只是第三个参数 storage 的类型从同步的 StorageLike 变成了异步的 StorageLikeAsync:
import { useStorageAsync } from '@vueuse/core'
const accessToken = useStorageAsync('access.token', '', SomeAsyncStorage)
从类型声明(见下文第六节)可以看出,useStorageAsync 支持 string / boolean / number / 泛型 T / null 五类重载,与 useStorage 的重载结构完全对齐。
2.1 继承同步版的三大能力
由于异步版继承了 useStorage 的选项体系(UseStorageAsyncOptions<T> 通过 Omit<UseStorageOptions<T>, "serializer"> 派生),以下同步版能力在异步版中同样适用:
合并默认值(Merge Defaults):默认情况下,存储中已有值时会忽略默认值,这可能导致"默认对象新增字段但老用户存储里没有该字段"时读到 undefined。开启 mergeDefaults: true 会对对象做浅合并,也可以传函数做深度合并:
const state = useStorageAsync(
'my-store',
{ hello: 'hi', greeting: 'hello' },
SomeAsyncStorage,
{ mergeDefaults: (storageValue, defaults) => deepMerge(defaults, storageValue) },
)
自定义序列化:异步版支持的是 SerializerAsync<T>,read / write 都可以返回 Promise:
useStorageAsync(
'key',
{},
SomeAsyncStorage,
{
serializer: {
read: async (v: string) => v ? JSON.parse(await decrypt(v)) : null,
write: async (v: any) => encrypt(JSON.stringify(v)),
},
},
)
响应式 Key:存储键可以是 ref 或 getter,键变化时自动切换到新的存储位置:
const userId = ref('user-1')
const userData = useStorageAsync(
() => `user-data-${userId.value}`,
{ name: '' },
SomeAsyncStorage,
)
userId.value = 'user-2' // 自动读取新位置的数据
三、等待首载(Wait First Loaded):异步存储的"空窗期"问题
这是 useStorageAsync 最需要被正确理解的行为。当用户进入应用时,useStorageAsync() 会异步地从存储中加载值——这意味着在加载完成的瞬间之前,你拿到的可能是默认初始值,而不是存储中的真实值:
import { useStorageAsync } from '@vueuse/core'
const accessToken = useStorageAsync('access.token', '', SomeAsyncStorage)
// 异步存储就绪前,accessToken.value 可能还是空字符串
console.log(accessToken.value) // ""
setTimeout(() => {
// 一段时间后异步存储就绪
console.log(accessToken.value) // "存储中的真实值"
}, 500)
这个"空窗期"在同步存储(localStorage)中几乎感知不到,但在 IndexedDB、文件系统、网络存储这类异步介质上是真实存在的。如果业务代码在首载完成前就读取并消费了状态(例如拿着空的 accessToken 去发起请求),就会产生难以排查的隐性 Bug。
应对思路有两种:
- 等待(await):利用返回值本身是 Promise 的特性,阻塞到首载完成;
- 回调(onReady):在首载完成时被通知。
接下来分别展开。
四、方案一:直接 await,利用返回值的 Promise 身份
useStorageAsync 的返回值是 RemovableRef<T> & Promise<RemovableRef<T>> 的交叉类型——它既是一个 ref,又是一个 Promise。因此可以直接在模板或脚本中等待它 resolve:
// 如果你的环境支持顶层 await
const accessToken = await useStorageAsync('access.token', '', SomeAsyncStorage)
console.log(accessToken.value) // "存储中的真实值"
注意:await 之后拿到的 accessToken 依然是一个 ref,后续读写方式不变,只是此刻已经保证首载完成。
4.1 多个存储:Promise.allSettled() 并行等待
如果应用必须等待多个存储全部就绪,官方推荐将它们放入 Promise.allSettled() 中并行等待,而不是逐个串行 await。与 Promise.all 相比,allSettled 不会因为某一个存储失败而整体拒绝,更适合"尽力而为"的应用启动流程:
router.onReady(async () => {
await Promise.allSettled([
accessToken,
refreshToken,
userData,
])
app.mount('app')
})
这是典型的"路由就绪后再挂载应用"启动编排:router.onReady 保证了路由初始化完成,Promise.allSettled 保证所有异步存储首载完成(或失败落定),最后才 app.mount('app'),从根上杜绝了"挂载后状态还是默认值"的竞态问题。
五、方案二:onReady 回调,事件驱动式等待
useStorageAsync 的 options 中提供了一个名为 onReady 的回调,在第一个值加载完成时被调用,参数即为加载到的值。它让你不必依赖 Promise 的交叉身份,而是以事件/回调的方式感知首载完成。
文档给出的完整示例使用了 ES2024 的 Promise.withResolvers() 构造一个可手动 resolve 的 Promise(也可以用任何 Deferred 对象或 EventBus 实现同样的效果):
import { useStorageAsync } from '@vueuse/core'
// 使用 ES2024 Promise.withResolvers,也可以用任何 Deferred 对象或 EventBus 实现同样效果
const { promise, resolve } = Promise.withResolvers()
const accessToken = useStorageAsync('access.token', '', SomeAsyncStorage, {
onReady(value) {
resolve(value)
}
})
// 在 main.ts 中
router.onReady(async () => {
// 等待 accessToken 加载完成
await promise
// 此时 accessToken 已加载完成,可以安全地挂载应用
app.mount('app')
})
当回调只需要"放行"而无需消费 value 时,可以直接把 resolve 当作回调传入,代码更为精简:
const accessToken = useStorageAsync('access.token', '', SomeAsyncStorage, {
onReady: resolve
})
两种等待方案的选择建议:
| 场景 | 推荐方案 |
|---|---|
| 单个存储、代码风格偏声明式 | 直接 await useStorageAsync(...) |
| 多个存储并行等待 | Promise.allSettled([...]) |
| 需要感知具体加载值、或想复用已有 Deferred / EventBus 机制 | onReady 回调 |
| 环境不支持顶层 await,又不想额外引入 Deferred | onReady 回调 + Promise.withResolvers() |
六、类型声明解析:UseStorageAsyncOptions 与重载
官方文档给出了完整的类型声明,理解它有助于写出类型安全的代码:
export interface UseStorageAsyncOptions<T> extends Omit<
UseStorageOptions<T>,
"serializer"
> {
/**
* Custom data serialization
*/
serializer?: SerializerAsync<T>
/**
* On first value loaded hook.
*/
onReady?: (value: T) => void
}
export declare function useStorageAsync(
key: string,
initialValue: MaybeRefOrGetter<string>,
storage?: StorageLikeAsync,
options?: UseStorageAsyncOptions<string>,
): RemovableRef<string> & Promise<RemovableRef<string>>
export declare function useStorageAsync(
key: string,
initialValue: MaybeRefOrGetter<boolean>,
storage?: StorageLikeAsync,
options?: UseStorageAsyncOptions<boolean>,
): RemovableRef<boolean> & Promise<RemovableRef<boolean>>
export declare function useStorageAsync(
key: string,
initialValue: MaybeRefOrGetter<number>,
storage?: StorageLikeAsync,
options?: UseStorageAsyncOptions<number>,
): RemovableRef<number> & Promise<RemovableRef<number>>
export declare function useStorageAsync<T>(
key: string,
initialValue: MaybeRefOrGetter<T>,
storage?: StorageLikeAsync,
options?: UseStorageAsyncOptions<T>,
): RemovableRef<T> & Promise<RemovableRef<T>>
export declare function useStorageAsync<T = unknown>(
key: string,
initialValue: MaybeRefOrGetter<null>,
storage?: StorageLikeAsync,
options?: UseStorageAsyncOptions<T>,
): RemovableRef<T> & Promise<RemovableRef<T>>
要点拆解:
Omit<UseStorageOptions<T>, "serializer">:异步版继承了同步版除serializer外的全部选项,包括deep(默认true,深度监听对象/数组变化)、listenToStorageChanges(默认true,跨标签页同步)、writeDefaults(默认true,存储中不存在时写入默认值)、shallow(默认false,是否使用shallowRef)、initOnMounted(默认false,是否在组件挂载后才初始化读取)、onError、flush等。之所以剔除serializer,是因为异步版换成了SerializerAsync<T>。SerializerAsync<T>:read: (raw: string) => Awaitable<T>、write: (value: T) => Awaitable<string>,读写均可返回 Promise,对应同步版Serializer<T>的read: (raw: string) => T、write: (value: T) => string。onReady?: (value: T) => void:仅异步版新增,即上文第五节的"首载完成钩子"。- 返回值
RemovableRef<T> & Promise<RemovableRef<T>>:交叉类型,这是"既是 ref 又可 await"的类型基础。RemovableRef<T>是 VueUse 中可移除的 ref——将其赋值为null会删除存储中的对应键。 initialValue: MaybeRefOrGetter<T>:初始值可以是普通值、ref 或 getter 函数。storage?: StorageLikeAsync:可选参数。需要实现getItem(key): Awaitable<string | null>与setItem(key, value): Awaitable<void>(并可选的removeItem)。对比同步版StorageLike(即 localStorage / sessionStorage 形状),差异仅在返回值是否 Promise。
七、仓库中的真实封装:Airi 如何围绕存储组合式函数构建状态层
虽然 Airi 仓库的公开源码中没有直接调用 useStorageAsync,但它在状态持久化上的工程实践与本文主题高度同源——仓库将 useStorage / useLocalStorage 进一步封装为带业务语义的组合式函数,这些封装的模式(监听、同步、容错、默认值回退)与 useStorageAsync 的适用场景完全一致,可作为异步化改造的直接参考。
7.1 带版本号管理的本地存储:useVersionedLocalStorage
位于 packages/stage-shared/src/composables/use-versioned-local-storage/index.ts 的 useVersionedLocalStorage<T>,在 useStorage 之上增加了版本校验能力:
- 存储格式为
Versioned<T> = { version?: string, data?: T },每次写入都会带上defaultVersion(默认'1.0.0'); - 通过
satisfiesVersionBy(beforeVersion, afterVersion)自定义版本兼容判断; - 版本不匹配时通过
onVersionMismatch回调决定keep(保留)还是reset(重置为默认值),未配置回调时默认重置并console.warn; - 解析失败(如存储值损坏或兼容性破坏)时回退到默认值,避免应用因脏数据崩溃。
其中反复出现的 syncDataToStorage.pause() / resume() 模式,本质是"外部写入时避免触发自身监听导致回环",这与 useStorageAsync 在异步写回场景下需要注意的防抖/防回环问题同理。
7.2 可手动重置的本地存储:useLocalStorageManualReset
位于 packages/stage-shared/src/composables/use-local-storage-manual-reset/index.ts 的 useLocalStorageManualReset<T>,把 useLocalStorage 与 VueUse 的 refManualReset 组合:
- 暴露带
reset()能力的ManualResetRefReturn<T>,便于"一键恢复默认设置"这类 UI 交互; - 通过
watch在本地状态与存储状态之间双向同步; - 默认监听存储变化(
listenToStorageChanges !== false时),但会通过toRaw比较跳过"自己写回触发的重复变更",避免向 Pinia 等上层发布多余的第二次 mutation——这是跨标签页/跨窗口同步场景的经典防抖细节。
7.3 IndexedDB 异步存储的仓库证据
异步存储介质在仓库中同样真实存在:在 packages/scenarios-stage-tamagotchi-electron/src/scenarios/display-model-from-file.ts 中,Electron 场景脚本通过 globalThis.indexedDB.open(databaseName) 直接操作 IndexedDB,读取 / 删除导入的显示模型记录,并配合 localforage 的 databaseName / storeName / keyPrefix 约定。这正是 useStorageAsync 所面向的"异步存储"形态——IndexedDB 的所有读写都是基于 onsuccess / onerror 回调的异步操作,若要把这类存储接入响应式状态,useStorageAsync 正是把回调式 API 收拢为响应式 ref + Promise 的桥梁。
八、最佳实践小结
- 启动期状态一律等首载:涉及登录态、权限、关键配置的异步存储,不要在首载完成前消费。用
await、Promise.allSettled或onReady三者之一兜底,优先选择router.onReady+Promise.allSettled的组合以并行加速启动。 - 多个存储用
allSettled而非all:任一存储异常不应阻断整个应用挂载。 onReady适合事件驱动架构:若你的代码库已使用 Deferred / EventBus 模式,onReady: resolve一行即可接入,无需感知返回值是 Promise 这一交叉类型。- 自定义异步序列化:
SerializerAsync<T>允许在读写链路中加入加解密、压缩等异步处理,注意与同步版序列化器(StorageSerializers中的string/number/boolean/object/map/set/date/any)的取舍——异步介质通常值得用any或自定义序列化以保留对复杂结构的控制。 - 参考仓库封装模式:Airi 的
useVersionedLocalStorage(版本兼容与脏数据回退)与useLocalStorageManualReset(手动重置与防回环)展示了生产级存储组合式函数应具备的容错与防抖细节;当你的异步存储需要类似语义时,可基于useStorageAsync以相同模式扩展onReady之外的业务钩子。
参考资源
- 本文核心文档:useStorageAsync 官方参考
- 基础能力来源:useStorage 官方参考(含
mergeDefaults、StorageSerializers、Options 完整说明) - 函数选用决策指南:VueUse Functions Skill
- 仓库实战封装:useVersionedLocalStorage、useLocalStorageManualReset
- IndexedDB 异步存储示例:display-model-from-file.ts