airi 中的响应式 SessionStorage 实践:深入解析 VueUse useSessionStorage 的组合式存储方案
【免费下载链接】airi💖🧸 Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-sama's altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi
导读
useSessionStorage是 VueUse 中用于将浏览器sessionStorage与 Vue 响应式系统绑定的组合式函数(composable)。在 airi 这类面向 Web / macOS / Windows 多端交付的自托管 AI 伴侣项目中,它承担着"会话级、关标签页即失效"的状态持久化职责,与useLocalStorage、useStorage共同构成一套完整的浏览器存储响应式方案。读完本文,你将掌握useSessionStorage的全部重载签名、与useStorage的关系、默认合并与自定义序列化等进阶能力,并能参考 airi 仓库内的真实封装(如useLocalStorageManualReset)和 OIDC 登录流程,在 Vue 3 项目中落地自己的响应式会话存储。
什么是 useSessionStorage:响应式地访问 SessionStorage
useSessionStorage创建一个响应式 ref,用于读写浏览器的sessionStorage的函数表中标注为AUTO调用级别——即"在适用场景下可直接自动使用",与useLocalStorage(同表AUTO)并列,而其底层通用实现则是useStorage。
其核心行为特点与sessionStorage原生 API 一致:
- 数据随标签页(Tab)会话存活:关闭标签页或浏览器窗口即被清除,不跨会话持久;
- 不跨标签页共享:每个标签页拥有独立的存储空间;
- 不会持久化到磁盘:刷新页面后仍在,但关闭页面后消失。
因此useSessionStorage适合保存"本次会话内有效"的状态,例如:表单草稿、一次性引导(onboarding)标记、OAuth 流程中的临时参数、当前会话的筛选条件等。凡是需要跨会话保留的用户偏好(如语言设置、主题、API 地址),则应改用useLocalStorage。
基本用法:一行代码绑定会话状态
useSessionStorage的用法与useStorage完全一致(关联文档明确指出"Please refer touseStorage"),只是默认绑定到sessionStorage,无需再传入第三个 storage 参数:
import { useSessionStorage } from '@vueuse/core' // 绑定字符串 const token = useSessionStorage('session/token', '') // 绑定布尔值,返回 Ref<boolean> const guided = useSessionStorage('session/onboarding-done', false) // 绑定数字,返回 Ref<number> const step = useSessionStorage('session/wizard-step', 0) // 绑定对象,自动使用 JSON 序列化 const filters = useSessionStorage('session/filters', { keyword: '', page: 1 })赋值即写回存储,读取即自动反序列化,且值的变化会在同一标签页内被响应式地观察:
filters.value = { keyword: 'airi', page: 2 } // 自动 sessionStorage.setItem(...) // 置为 null 会从存储中删除该键 filters.value = null完整类型声明(继承自原文档)
关联文档给出了useSessionStorage的全部重载签名,按初始值类型自动推导返回的 ref 类型:
export declare function useSessionStorage( key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<string>, options?: UseStorageOptions<string>, ): RemovableRef<string> export declare function useSessionStorage( key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<boolean>, options?: UseStorageOptions<boolean>, ): RemovableRef<boolean> export declare function useSessionStorage( key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<number>, options?: UseStorageOptions<number>, ): RemovableRef<number> export declare function useSessionStorage<T>( key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>, ): RemovableRef<T> export declare function useSessionStorage<T = unknown>( key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<null>, options?: UseStorageOptions<T>, ): RemovableRef<T>要点说明:
key:存储键名,类型为MaybeRefOrGetter<string>,即可以传字符串、ref或 getter 函数(见下文"响应式 Key");initialValue:初始默认值,同样支持 ref 或 getter;其类型决定返回的RemovableRef<T>泛型;options:UseStorageOptions<T>配置项(见"配置项详解");RemovableRef<T>:比普通Ref<T>多一个remove()方法,调用后会将对应存储键删除,并将值重置为默认值。
useSessionStorage 与 useStorage / useLocalStorage 的关系
useSessionStorage并非独立实现,而是useStorage的便捷封装。从 .agents/skills/vueuse-functions/references/useStorage.md 的声明可以看出,useStorage的第三个参数storage?: StorageLike允许指定任意类 Storage 对象,默认使用localStorage:
// 等价写法一:useSessionStorage 封装的正是下面的调用 const s1 = useSessionStorage('key', 'value') // 等价写法二:显式传入 sessionStorage const s2 = useStorage('key', 'value', sessionStorage) // 等价写法三:绑定 localStorage const s3 = useStorage('key', 'value', localStorage)三者的选用原则可以概括为:
| 函数 | 默认绑定目标 | 数据生命周期 | 典型场景 |
|---|---|---|---|
useSessionStorage | sessionStorage | 标签页会话内有效,关闭即清除 | OAuth 临时参数、表单草稿、向导步骤 |
useLocalStorage | localStorage | 跨会话持久,无过期时间 | 语言、主题、API 配置等长期偏好 |
useStorage | localStorage(可指定) | 取决于传入的 storage | 需要自定义存储源或统一抽象时 |
airi 仓库中的应用分布也印证了这一分工:长期偏好(语言设置、LLM 服务配置、聊天发送模式、弹窗"不再提示"标记)统一走useLocalStorage,例如 apps/component-calling/src/pages/index.vue 中用useLocalStorage持久化settings/llm/baseUrl、settings/llm/apiKey、settings/llm/model;而 OIDC 登录这类跨页面跳转、会话内有效的临时状态则直接使用原生sessionStorage(详见下文实战案例)。
实战案例一:airi 对 useStorage 家族的企业级封装(useLocalStorageManualReset)
虽然 airi 中未直接散落调用useSessionStorage,但它对同族函数useLocalStorage做了一层很有参考价值的封装,位于 packages/stage-shared/src/composables/use-local-storage-manual-reset/index.ts。这套封装思路完全可以迁移到useSessionStorage上:
export function useLocalStorageManualReset<T>( key: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T> & WatchOptions, ): ManualResetRefReturn<T> { const value = unref(initialValue) const localStorageState = useLocalStorage<T>(key, value, options) const state = refManualReset<T>(localStorageState) const { resume, pause } = watch(state, newValue => localStorageState.value = newValue, options) if (options?.listenToStorageChanges !== false) { watch(localStorageState, (newValue) => { // 只有源自 storage 的值才需要回写 state, // 避免手动 ref 因赋同一引用而触发第二次 Pinia mutation if (toRaw(newValue) === toRaw(state.value)) return pause() state.value = newValue resume() }, options) } return state }这段源码揭示了三个与useStorage家族直接相关的关键点:
UseStorageOptions<T>被透传:useLocalStorageManualReset的options参数直接透传给底层的useLocalStorage,说明所有存储选项(deep、listenToStorageChanges、writeDefaults等)在封装层依然生效;listenToStorageChanges选项影响封装逻辑:当该选项不为false时,封装层额外建立了一条从 storage 到手动 ref 的反向同步通道——这正是useStorage内部"跨标签页 storage 事件监听"机制的延伸运用;watch与 ref 的联动:通过watch(state, newValue => localStorageState.value = newValue)实现"改动即持久化",与useStorage自身"watch 变化并写回存储"的行为一致。
该封装的消费方是 apps/stage-tamagotchi/src/renderer/composables/use-language.ts,用于持久化settings/language,并在注释中明确说明动机是规避Electron 重启时 renderer 的 localStorage 可能尚未 flush的问题。对应测试 apps/stage-tamagotchi/src/renderer/composables/use-language.test.ts 也验证了这套同步与恢复逻辑。如果你的需求是"关闭标签页即丢弃"的临时状态,把封装内的useLocalStorage换成useSessionStorage即可获得同样健壮的会话级持久化。
实战案例二:sessionStorage 在 airi OIDC 登录流程中的运用
useSessionStorage的底层存储sessionStorage有一个关键特性——同标签页内页面导航后数据仍保留,这与 OAuth/PKCE 流程"跳转到授权服务器再跳回"的场景天然契合。airi 的 packages/stage-ui/src/libs/auth-oidc.ts 正是这样用的:
// Session storage keys for PKCE flow state (survives page navigation during OAuth) const FLOW_STATE_KEY = 'auth/v1/oidc-flow-state' const FLOW_PARAMS_KEY = 'auth/v1/oidc-flow-params' export function persistFlowState(flowState: OIDCFlowState, params: OIDCFlowParams): void { sessionStorage.setItem(FLOW_STATE_KEY, JSON.stringify(flowState)) sessionStorage.setItem(FLOW_PARAMS_KEY, JSON.stringify(params)) } export function consumeFlowState(): { flowState: OIDCFlowState, params: OIDCFlowParams } | null { const flowStateRaw = sessionStorage.getItem(FLOW_STATE_KEY) const paramsRaw = sessionStorage.getItem(FLOW_PARAMS_KEY) if (!flowStateRaw || !paramsRaw) return null sessionStorage.removeItem(FLOW_STATE_KEY) sessionStorage.removeItem(FLOW_PARAMS_KEY) return { flowState: JSON.parse(flowStateRaw), params: JSON.parse(paramsRaw), } }该模块的注释点明了选型理由:"Session storage keys for PKCE flow state (survives page navigation during OAuth)"。同时 apps/ui-server-auth/src/pages/sign-in.vue 和 apps/ui-server-auth/src/pages/verify-email.vue 也围绕"邮件链接在新标签页打开导致 sessionStorage(进而 PKCE flowState)不可见"这一边界场景做了处理说明。
迁移到 useSessionStorage 的价值:把上述手写的setItem/getItem/removeItem换成useSessionStorage后,可获得自动 JSON 序列化、响应式联动、RemovableRef.remove()内置删除等能力,而生命周期语义(同标签页导航存活、新标签页隔离)完全不变:
const flowState = useSessionStorage<OIDCFlowState | null>('auth/v1/oidc-flow-state', null) const flowParams = useSessionStorage<OIDCFlowParams | null>('auth/v1/oidc-flow-params', null) // 跳转前写入 flowState.value = { ... } flowParams.value = { ... } // 回调消费并删除 const state = flowState.value flowState.value = null // 等价于 sessionStorage.removeItem配置项详解:UseStorageOptions
useSessionStorage的options类型为UseStorageOptions<T>,完整定义可见 .agents/skills/vueuse-functions/references/useStorage.md,各字段的作用与默认值如下:
useSessionStorage('key', defaults, { // 深度监听对象/数组内部变化(默认 true) deep: true, // 通过 storage 事件监听跨标签页变化(默认 true) listenToStorageChanges: true, // 存储中不存在该键时写入默认值(默认 true) writeDefaults: true, // 使用 shallowRef 代替 ref(默认 false) shallow: false, // 仅在组件挂载后再读取存储(默认 false) initOnMounted: false, // 自定义错误处理(默认 console.error) onError: e => console.error(e), // watch 触发时机(默认 'pre') flush: 'pre', })逐个解读其含义:
deep:控制对对象/数组的深度监听。默认true,因此修改嵌套属性(如filters.value.keyword = 'x')也会触发写回存储;设false可减少大对象上的监听开销。listenToStorageChanges:是否监听storage事件以同步多标签页变化。对useSessionStorage而言,由于sessionStorage不跨标签页共享,此选项的实际影响较小,但接口层面保持一致。writeDefaults:若存储中尚无该键,是否把默认值写入存储。设为false可避免"仅读取"场景污染存储空间。shallow:为true时内部使用shallowRef,对大对象可避免深响应式开销,适合只整体替换(不修改内部字段)的场景。initOnMounted:SSR 场景下,挂载前读取可能拿到服务端环境,设为true可推迟到onMounted再初始化。onError:存储读写抛错(如隐私模式、配额超限)时的回调,默认console.error。flush:watch 回调的触发时机,'pre'表示在组件更新前同步写回。
默认值合并(Merge Defaults):避免"存量数据缺字段"
useSessionStorage继承自useStorage的默认行为是:只要存储中存在该键,就直接使用存储值,忽略默认值。这意味着当你在新版本中为默认值对象增加了字段,老用户存储中的旧数据不会自动补齐这些字段,读取时会出现undefined:
sessionStorage.setItem('my-store', '{"hello": "hello"}') const state = useSessionStorage('my-store', { hello: 'hi', greeting: 'hello' }) console.log(state.value.greeting) // undefined —— 存储中不存在该字段解决办法是开启mergeDefaults:
const state = useSessionStorage( 'my-store', { hello: 'hi', greeting: 'hello' }, { mergeDefaults: true }, // <-- 浅合并:存储值优先,缺失字段用默认值补齐 ) console.log(state.value.hello) // 'hello'(来自存储) console.log(state.value.greeting) // 'hello'(来自合并的默认值)当mergeDefaults为true时,对对象执行的是浅合并;如需深合并(嵌套对象逐层补齐),可传入自定义合并函数:
const state = useSessionStorage( 'my-store', { hello: 'hi', profile: { nickname: '', avatar: '' } }, { mergeDefaults: (storageValue, defaults) => deepMerge(defaults, storageValue), }, )自定义序列化与内置序列化器
默认情况下,useSessionStorage会根据初始值类型"智能"选择序列化方式:对象用JSON.stringify/JSON.parse,数字用Number.toString/parseFloat,布尔值同理。你也可通过serializer选项完全接管读写逻辑:
useSessionStorage( 'key', {}, { serializer: { read: (v: any) => v ? JSON.parse(v) : null, write: (v: any) => JSON.stringify(v), }, }, )需要注意:当默认值为null时,useSessionStorage无法从 null 推断数据类型,此时必须显式提供序列化器,或复用内置序列化器。内置序列化器通过StorageSerializers暴露,对应关系如下:
| 类型 | 说明 |
|---|---|
string | 普通字符串 |
number | 数字(经parseFloat) |
boolean | 布尔值 |
object | JSON 对象 / 数组 |
map | JavaScriptMap |
set | JavaScriptSet |
date | JavaScriptDate(经toISOString) |
any | 原始字符串直通(不做转换) |
例如存储Map:
import { StorageSerializers, useSessionStorage } from '@vueuse/core' const myMap = useSessionStorage('session/my-map', new Map(), { serializer: StorageSerializers.map, })响应式 Key:键名随 ref 变化自动迁移
useSessionStorage的key参数支持MaybeRefOrGetter<string>,因此键名本身也可以是响应式的——当键变化时,组合式函数会自动读取新位置的数据:
const userId = ref('user-1') const userData = useSessionStorage( () => `session/user-data-${userId.value}`, { name: '' }, ) // 键变化后,userData 自动切换为读取新键对应的存储值 userId.value = 'user-2'这一能力适合"同一份逻辑、多份会话数据"的场景,例如按当前用户、当前路由或当前会话 ID 区分存储位置,无需手动销毁重建 ref。
注意事项与最佳实践
综合关联文档与 airi 仓库实践,使用useSessionStorage时有几点值得留意:
- Nuxt 3 下的命名冲突:与
useStorage一样,在 Nuxt 3 中useSessionStorage不会被自动导入(VueUse 刻意让位于 Nitro 内置的useStorage()),需要显式import { useSessionStorage } from '@vueuse/core'。此注意事项记录于 .agents/skills/vueuse-functions/references/useStorage.md 的提示块。 - 会话边界 = 标签页边界:
sessionStorage按标签页隔离,弹窗、window.open出来的新窗口可能不共享数据。airi 的 OIDC 流程就为此在 apps/ui-server-auth/src/pages/sign-in.vue 做了专门处理。 - 删除数据用
remove()或赋null:RemovableRef的remove()是useSessionStorage相对原生 API 的便利增强,等价于sessionStorage.removeItem。 - Electron 等 WebView 环境注意 flush 时机:airi 在 packages/stage-shared/src/composables/use-local-storage-manual-reset/index.ts 中通过"storage 回写与手动 ref 隔离"规避了重启时存储未落盘导致的竞态,这一思路对
sessionStorage同样适用。 - 与手写 API 的取舍:airi 在 OIDC 流程中直接使用原生
sessionStorage(packages/stage-ui/src/libs/auth-oidc.ts),因为该场景是一次性读写、无需响应式联动;而凡是状态需要在组件模板或计算属性中联动、且需要自动序列化的场景,useSessionStorage都是更简洁的选择——这正是 VueUse 组合式方案"少写样板代码、聚焦业务"的价值所在。
总结
useSessionStorage是 VueUse State 类别中面向会话级数据的标准答案:它把sessionStorage的读写、序列化、删除与 Vue 的响应式系统无缝衔接,重载签名覆盖string / boolean / number / 泛型 / null五种形态,配置项与useStorage完全同源,并支持默认值合并、自定义序列化与响应式键名。在 airi 中,同族的useLocalStorage已被广泛用于语言、LLM 配置等长期偏好的持久化(apps/component-calling/src/pages/index.vue),sessionStorage则承载着 OIDC PKCE 流程等会话内临时状态(packages/stage-ui/src/libs/auth-oidc.ts)。理解了这套"生命周期决定存储选型、useStorage 统一底层"的设计哲学后,你在自己的 Vue 3 项目中就能准确地在useSessionStorage、useLocalStorage、useStorage之间做出选择。
【免费下载链接】airi💖🧸 Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-sama's altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考