VueUse useStorageAsync 深入指南:为异步存储打造响应式数据层
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 同理
}
两个核心用例分别验证了本文提到的两条就绪路径:
onReady用例:预先写入'CurrentValue',创建useStorageAsync后断言storage.value仍为''(初始默认值),随后通过onReady回调触发的事件确认加载到的值是'CurrentValue'——即文档所述"先拿到默认值、随后异步存储就绪"的时序被测试固化为契约;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继续深入。