VueUse useStorageAsync 深入指南:为异步存储打造响应式数据层

原创2026-10-02 21:47:02397 阅读
文章标签:前端

VueUse useStorageAsync 深入指南:为异步存储打造响应式数据层

useStorageAsync 是 VueUse 中面向异步存储后端的响应式状态工具,它把 useStorage 的同步读写模型升级为基于 Promise 的异步模型,适用于 IndexedDB、文件系统、云同步 SDK 等一切读写操作返回 Promise 的存储介质。读完本文,你将掌握 useStorageAsync 的完整用法、首次加载时序问题的三种解决方案,以及它在源码层面的类型系统、序列化与跨标签页同步实现原理。

概述:它与 useStorage 的关系

useStorageAsync 的定位在官方文档中一句话即可概括:"Reactive Storage with async support"(支持异步的响应式存储)。它的基本用法与 useStorage 完全一致,因此官方文档明确提示:"The basic usage please refer to useStorage"——关于绑定对象、布尔、数字、字符串,以及删除数据(赋 null)等基础操作,都可以直接参阅 packages/core/useStorage/index.md。

两者的关键差异在于存储接口:useStorage 依赖同步的 StorageLike 接口,而 useStorageAsync 依赖异步的 StorageLikeAsync 接口。从 packages/core/ssr-handlers.ts 的源码可以看到,后者的每个方法返回值都被包装为 Awaitable:

export interface StorageLikeAsync {
  getItem: (key: string) => Awaitable<string | null>
  setItem: (key: string, value: string) => Awaitable<void>
  removeItem: (key: string) => Awaitable<void>
}

这意味着你传入的存储对象只要 getItem / setItem / removeItem 能返回 Promise(或同步值)即可,比如 IndexedDB 的封装层、基于 localStorage 的异步 stub,或者任何云同步客户端。

使用场景:为什么需要"等待第一次加载"

当用户进入应用时,useStorageAsync() 会立即从异步存储中读取值。由于读取操作是异步的,在刚开始的一小段时间内,ref 中保存的是默认初始值,而不是存储中的真实值:

import { useStorageAsync } from '@vueuse/core'

const accessToken = useStorageAsync('access.token', '', SomeAsyncStorage)

// accessToken.value 在异步存储就绪之前可能是空字符串
console.log(accessToken.value) // ""

setTimeout(() => {
  // 一段时间后,异步存储就绪
  console.log(accessToken.value) // "the real value stored in storage"
}, 500)

这个时序窗口正是 useStorageAsync 区别于 useStorage 的核心问题:同步场景下数据立即可得,异步场景下你必须等待。官方文档为此提供了三种等待方案,下面逐一展开。

方案一:直接 await 返回值(返回对象本身是 Promise)

useStorageAsync 的返回值类型是 RemovableRef<T> & Promise<RemovableRef<T>>——它既是响应式 ref,又是一个可 await 的 Promise。因此可以像等待普通 Promise 一样等待数据就绪:

// 如果你的运行环境支持顶层 await
const accessToken = await useStorageAsync('access.token', '', SomeAsyncStorage)

console.log(accessToken.value) // "the real value stored in storage"

从 packages/core/useStorageAsync/index.ts 的实现可以看到这一机制的底层原理:函数内部创建一个 Promise,在首次 read() 完成后 resolve(data),随后把 promise.then 与 promise.catch 直接绑定到 ref 对象上:

const promise = new Promise((resolve) => {
  read().then(() => {
    onReady?.(data.value)
    resolve(data)
  })
})

Object.assign(data, {
  then: promise.then.bind(promise),
  catch: promise.catch.bind(promise),
})

return data as RemovableRef<T> & Promise<RemovableRef<T>>

注意这里 resolve(data) 解析出的就是 ref 本身,所以 await 之后拿到的 accessToken 依然可以直接读写 .value。

方案二:用 Promise.allSettled 等待多个存储

如果需要同时等待多个异步存储(例如 accessToken、refreshToken、userData 三个数据源),可以把它们放进 Promise.allSettled(),全部就绪后再挂载应用:

router.onReady(async () => {
  await Promise.allSettled([
    accessToken,
    refreshToken,
    userData,
  ])

  app.mount('app')
})

选用 allSettled 而非 all 是合理的工程取舍:即使某个存储读取失败(比如存储不可用),也不会阻塞其他数据源的等待与应用挂载,这与 useStorageAsync 内部"读取失败仅触发 onError 回调、不抛出致命错误"的设计哲学是一致的(见下文错误处理)。

方案三:使用 onReady 回调

useStorageAsync 的 options 中提供了一个 onReady 回调,在首次值加载完成后触发,携带已加载的值。官方文档结合 Promise.withResolvers()(ES2024 特性,也可以用任何 Deferred 对象或 EventBus 实现同样效果)演示了这种用法:

import { useStorageAsync } from '@vueuse/core'

const { promise, resolve } = Promise.withResolvers()

const accessToken = useStorageAsync('access.token', '', SomeAsyncStorage, {
  onReady(value) {
    resolve(value)
  }
})

// At main.ts
router.onReady(async () => {
  // 等待 accessToken 加载完成
  await promise

  // 现在 accessToken 已加载,可以安全地挂载应用
  app.mount('app')
})

由于回调参数 value 正是 data.value,且 resolve 本身接受该值,可以进一步简写为:

const accessToken = useStorageAsync('access.token', '', SomeAsyncStorage, {
  onReady: resolve
})

从源码看,onReady 在首次 read() 成功之后、Promise resolve 之前被调用(packages/core/useStorageAsync/index.ts),因此无论是回调路径还是 await 路径,数据就绪的时机是一致的,两种写法可以按场景自由混用。

类型系统:重载签名与 UseStorageAsyncOptions

useStorageAsync 提供了与 useStorage 对应的 5 组函数重载(packages/core/useStorageAsync/index.ts),覆盖 string、boolean、number、泛型 T 以及 null 默认值场景,返回值统一为 RemovableRef<T> & Promise<RemovableRef<T>>。RemovableRef 表示该 ref 支持赋值 null 来删除存储项。

它的选项接口继承自 useStorage 的 UseStorageOptions<T>,并做了两处调整(packages/core/useStorageAsync/index.ts):

export interface UseStorageAsyncOptions<T> extends Omit<UseStorageOptions<T>, 'serializer'> {
  /**
   * 自定义数据序列化
   */
  serializer?: SerializerAsync<T>

  /**
   * 首次值加载完成后的钩子
   */
  onReady?: (value: T) => void
}
  • serializer 从同步的 Serializer<T> 变为异步的 SerializerAsync<T>:read: (raw: string) => Awaitable<T>、write: (value: T) => Awaitable<string>,即序列化本身也可以是异步操作(packages/core/useStorage/index.ts);
  • 新增 onReady 钩子。

从实现(packages/core/useStorageAsync/index.ts)可以看到所有默认值:

选项 默认值 说明
flush 'pre' watch 刷新时机(pre / post / sync)
deep true 是否深度监听对象/数组内部变化
listenToStorageChanges true 是否监听跨标签页/跨文档的 storage 事件
writeDefaults true 存储中无该 key 时,是否把默认值写回存储
mergeDefaults false 是否将存储值与默认值合并(见下节)
shallow false 为 true 时使用 shallowRef 代替 ref
window defaultWindow 绑定的 window 对象
eventFilter 无 事件过滤器(如防抖/节流)
onError console.error 错误回调,读写异常不会向外抛出而是在此统一处理
onReady 无 首次加载完成回调

序列化机制:内置 Serializers 与类型推断

useStorageAsync 的序列化策略与 useStorage 完全一致:基于默认值的实际类型,通过 guessSerializerType() 自动挑选对应的内置序列化器(packages/core/useStorage/guess.ts)。推断顺序为:null → any,Set → set,Map → map,Date → date,boolean → boolean,string → string,object → object,数值 → number。

对应地,StorageSerializers 中注册了 8 个内置序列化器(packages/core/useStorage/index.ts):

Type 说明
string 纯字符串
number 数字(通过 parseFloat)
boolean 布尔值
object JSON 对象/数组
map JavaScript Map
set JavaScript Set
date JavaScript Date(通过 toISOString)
any 原始字符串直通

如果你的默认值为 null,类型无从推断,此时应显式指定序列化器:

import { StorageSerializers, useStorageAsync } from '@vueuse/core'

const objectLike = useStorageAsync('key', null, SomeAsyncStorage, {
  serializer: StorageSerializers.object,
})
objectLike.value = { foo: 'bar' }

内置序列化器本身是同步的,但由于 SerializerAsync 接受 Awaitable 返回值,同步实现天然兼容异步接口;而你也可以传入完全异步的自定义序列化器,例如加密存储场景下 write 返回一个加密后的 Promise。

合并默认值(mergeDefaults)

mergeDefaults 默认关闭。开启后,当存储中已存在值时,read() 会把存储值并入默认值;mergeDefaults: true 时对对象执行浅合并(源码中为 { ...rawInit, ...value }),传入函数时则使用自定义合并逻辑(例如深合并,packages/core/useStorageAsync/index.ts):

const state = useStorageAsync(
  'my-store',
  { hello: 'hi', greeting: 'hello' },
  SomeAsyncStorage,
  { mergeDefaults: (storageValue, defaults) => deepMerge(defaults, storageValue) }
)

写入与同步原理:watchWithFilter + storage 事件

useStorageAsync 内部用 watchWithFilter 监听 ref 数据(packages/core/useStorageAsync/index.ts),每当数据变化就异步写回存储:值为 null 时调用 removeItem(key) 删除条目,否则调用 setItem(key, serializer.write(data.value)),写入异常统一交给 onError。flush、deep、eventFilter 都透传给该 watcher,这意味着你可以用 eventFilter 对写回做防抖/节流,减少高频更新对异步存储的压力。

跨标签页同步方面,useStorageAsync 与 useStorage 走同一条路径:当 listenToStorageChanges 为 true 且存在 window 时,通过 useEventListener 监听 storage 事件(packages/core/useStorageAsync/index.ts),事件到达后在微任务中调用 read(event) 刷新 ref。read() 内部使用 event ? event.newValue : await storage.getItem(key) 获取值,且仅在 event.key === key 时才响应(packages/core/useStorageAsync/index.ts),避免无关 key 的更新干扰当前 ref。

SSR 环境与默认存储

与 useStorage 一样,useStorageAsync 在未显式传入 storage 时,会通过 getSSRHandler('getDefaultStorageAsync', () => defaultWindow?.localStorage)() 获取默认异步存储(packages/core/useStorageAsync/index.ts)。该机制定义在 packages/core/ssr-handlers.ts:你可以在 SSR 服务端用 setSSRHandler('getDefaultStorageAsync', handler) 覆盖默认实现,为服务端渲染提供安全的存储抽象,避免直接访问不存在的 window.localStorage。

需要留意的是,getDefaultStorageAsync 目前仍标注为 @experimental,其 API 尚未最终定稿、可能不遵循 semver(packages/core/ssr-handlers.ts),生产环境建议始终显式传入 storage 参数。

测试验证:从测试用例看契约行为

仓库为 useStorageAsync 提供了浏览器端测试 packages/core/useStorageAsync/index.browser.test.ts,其中定义了一个基于 setTimeout 模拟 10ms 延迟的 AsyncStubStorage,很好地示范了如何为 useStorageAsync 构造异步存储对象:

class AsyncStubStorage implements StorageLikeAsync {
  getItem(key: string): Awaitable<string | null> {
    return new Promise((resolve) => {
      setTimeout(() => resolve(localStorage.getItem(key)), asyncDelay)
    })
  }
  // removeItem / setItem 同理
}

两个核心用例分别验证了本文提到的两条就绪路径:

  1. onReady 用例:预先写入 'CurrentValue',创建 useStorageAsync 后断言 storage.value 仍为 ''(初始默认值),随后通过 onReady 回调触发的事件确认加载到的值是 'CurrentValue'——即文档所述"先拿到默认值、随后异步存储就绪"的时序被测试固化为契约;
  2. onReadyByPromise 用例:不传 onReady,直接 await storage 后断言 result.value 为存储中的 'AnotherValue'——验证了"返回值本身可 await"这条路径。

这两条用例从测试层面确认了:无论用 onReady 回调还是 await 返回值,最终都能拿到存储中的真实值,且初始阶段 ref 持有默认值的行为是预期内的。

总结

useStorageAsync 把 VueUse 的响应式存储能力从同步世界延伸到了异步世界。核心要点可以归纳为:

  • 使用模式不变:基础用法完全复用 useStorage,仅需把存储对象换成实现 StorageLikeAsync 接口的异步存储;
  • 时序问题明确:首次加载期间 ref 持有默认值,通过 await 返回值、Promise.allSettled 或 onReady 回调三种方式等待真实值就绪,再挂载应用;
  • 类型与序列化完整继承:重载签名、StorageSerializers、guessSerializerType 类型推断、mergeDefaults、shallow、flush、eventFilter 等选项与 useStorage 一脉相承,仅序列化接口升级为异步;
  • 底层机制透明:Promise 封装、watchWithFilter 异步写回、storage 事件跨标签页同步、SSR handler 覆盖,均有源码与测试佐证,读者可沿 packages/core/useStorageAsync/index.ts 与 packages/core/useStorageAsync/index.browser.test.ts 继续深入。
登录后查看全文
vueuse