Airi 项目中的异步响应式存储:VueUse `useStorageAsync` 深度实战指南

原创2026-09-09 21:56:291,858 阅读
文章标签:人工智能AI 应用大模型AI Agent交互助手语音数字人后端前端桌面应用移动开发GUI 自动化MCP 服务

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。

应对思路有两种:

  1. 等待(await):利用返回值本身是 Promise 的特性,阻塞到首载完成;
  2. 回调(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 的桥梁。

八、最佳实践小结

  1. 启动期状态一律等首载:涉及登录态、权限、关键配置的异步存储,不要在首载完成前消费。用 await、Promise.allSettled 或 onReady 三者之一兜底,优先选择 router.onReady + Promise.allSettled 的组合以并行加速启动。
  2. 多个存储用 allSettled 而非 all:任一存储异常不应阻断整个应用挂载。
  3. onReady 适合事件驱动架构:若你的代码库已使用 Deferred / EventBus 模式,onReady: resolve 一行即可接入,无需感知返回值是 Promise 这一交叉类型。
  4. 自定义异步序列化:SerializerAsync<T> 允许在读写链路中加入加解密、压缩等异步处理,注意与同步版序列化器(StorageSerializers 中的 string / number / boolean / object / map / set / date / any)的取舍——异步介质通常值得用 any 或自定义序列化以保留对复杂结构的控制。
  5. 参考仓库封装模式:Airi 的 useVersionedLocalStorage(版本兼容与脏数据回退)与 useLocalStorageManualReset(手动重置与防回环)展示了生产级存储组合式函数应具备的容错与防抖细节;当你的异步存储需要类似语义时,可基于 useStorageAsync 以相同模式扩展 onReady 之外的业务钩子。

参考资源

登录后查看全文
airi