useState 跨组件状态共享
在纯 Vue 中,跨组件共享状态要么用
provide/inject,要么上 Pinia。Nuxt4 提供了一个更轻量的选择——useState,它是专为 SSR 设计的全局响应式状态容器。只需一行代码就能创建跨组件共享的状态,且自动处理服务端到客户端的状态序列化。本章讲清useState的原理、与ref的本质区别、Payload 传递机制,以及何时该用它、何时该换 Pinia。
1. useState SSR 安全特性
1.1 基本用法
// 在任意组件或 Composable 中
const count = useState<number>('counter', () => 0)
// 参数 1:唯一 key(字符串)
// 参数 2:初始化函数(仅首次调用时执行)
// 返回值:Ref<number>
count.value++ // 响应式更新1.2 跨组件共享
<!-- 组件 A -->
<script setup lang="ts">
const user = useState<User | null>('user', () => null)
</script>
<!-- 组件 B(不同文件,同一页面) -->
<script setup lang="ts">
const user = useState<User | null>('user')
// 同一个 key → 同一个 Ref → 数据共享
// 组件 A 修改 user,组件 B 自动更新
</script>1.3 SSR 安全的含义
// ❌ 不安全:模块级全局变量
// 在 SSR 中,服务端是单进程多请求共享
// 一个用户的数据会泄漏给另一个用户
const globalUser = ref<User | null>(null) // 💥 所有请求共享同一个 ref
// ✅ 安全:useState
// Nuxt 为每个请求创建独立的状态空间
const user = useState<User | null>('user', () => null)
// 用户 A 的请求有自己的 user 状态
// 用户 B 的请求有自己的 user 状态
// 互不影响核心问题:Node.js 服务端是单进程的,多个并发请求共享同一个进程内存。如果用模块级 ref,状态会在不同请求间泄漏。useState 通过 Nuxt 的请求上下文隔离解决了这个问题。
1.4 SSR 安全的底层机制
请求 A → Nuxt SSR → 创建独立的 NuxtApp 实例 A
↓ useState('user') → 存入实例 A 的 payload
↓ 渲染 HTML → 返回给用户 A
请求 B → Nuxt SSR → 创建独立的 NuxtApp 实例 B
↓ useState('user') → 存入实例 B 的 payload
↓ 渲染 HTML → 返回给用户 B
实例 A 和实例 B 的状态完全隔离
2. 与 ref 的本质区别
2.1 对比表
| 特性 | ref() | useState() |
|---|---|---|
| SSR 安全 | ❌ 模块级共享有风险 | ✅ 请求级隔离 |
| 跨组件共享 | ❌ 需要 provide/inject | ✅ 同 key 自动共享 |
| Payload 序列化 | ❌ 不自动传递到客户端 | ✅ 自动序列化到 Payload |
| 作用域 | 组件实例级 | 应用级(当前请求) |
| 初始化 | 每次调用都执行 | 仅首次调用时执行 |
| 适用场景 | 组件内部状态 | 全局/跨组件状态 |
2.2 关键区别演示
// ref:每个组件实例各自独立
// 组件 A
const count = ref(0) // 组件 A 的 count = 0
count.value = 5 // 组件 A 的 count = 5
// 组件 B
const count = ref(0) // 组件 B 的 count = 0(与 A 无关)
// ---
// useState:同 key 共享同一个 Ref
// 组件 A
const count = useState('count', () => 0) // 创建并初始化
count.value = 5 // 设为 5
// 组件 B
const count = useState('count', () => 0) // 发现已存在,返回同一个 Ref
console.log(count.value) // 5(与 A 共享)2.3 SSR → CSR 传递
// 服务端:
const theme = useState('theme', () => 'dark') // 初始化为 'dark'
// SSR HTML 中:
// <script>window.__NUXT__ = { state: { theme: 'dark' } }</script>
// 客户端 Hydration:
const theme = useState('theme', () => 'light') // 初始化函数被忽略
console.log(theme.value) // 'dark'(来自服务端的 Payload)3. Payload 机制
3.1 序列化流程
服务端:
useState('user', () => fetchUser())
→ 获取用户数据 → 存入 nuxtApp.payload.state
→ 序列化到 HTML:window.__NUXT__.state.user = { name: 'Alice', ... }
客户端:
useState('user')
→ 从 window.__NUXT__.state 恢复 → 返回相同数据
→ 不再执行初始化函数
3.2 序列化限制
useState 的值必须是可 JSON 序列化的:
// ✅ 可以:基础类型、对象、数组
useState('count', () => 0)
useState('user', () => ({ name: 'Alice', age: 25 }))
useState('tags', () => ['vue', 'nuxt'])
// ❌ 不可以:函数、类实例、Symbol、循环引用
useState('handler', () => () => {}) // 函数不可序列化
useState('date', () => new Date()) // Date 对象序列化后变成字符串
useState('map', () => new Map()) // Map 不可序列化
useState('regex', () => /pattern/) // RegExp 不可序列化处理日期的正确方式:
// 存储为 ISO 字符串
const createdAt = useState('createdAt', () => new Date().toISOString())
// 使用时转换
const date = computed(() => new Date(createdAt.value))3.3 手动操作 state
const nuxtApp = useNuxtApp()
// 直接访问所有 useState 的值
console.log(nuxtApp.payload.state)
// { counter: 0, user: { name: 'Alice' }, theme: 'dark' }
// 清除状态(如用户登出)
function logout() {
clearNuxtState('user') // 清除指定 key
clearNuxtState(['user', 'cart']) // 清除多个
clearNuxtState() // 清除所有
}4. 适用场景与局限性
4.1 适用场景
// 场景 1:用户认证状态
const user = useState<User | null>('auth-user', () => null)
const isLoggedIn = computed(() => !!user.value)
// 场景 2:UI 状态
const sidebarOpen = useState('sidebar-open', () => true)
const theme = useState<'light' | 'dark'>('theme', () => 'light')
const locale = useState('locale', () => 'zh-CN')
// 场景 3:简单的共享数据
const currentVideo = useState<Video | null>('current-video', () => null)
// 场景 4:封装为 Composable
export function useAuth() {
const user = useState<User | null>('auth-user', () => null)
const isLoggedIn = computed(() => !!user.value)
async function login(credentials: LoginData) {
user.value = await $fetch('/api/auth/login', {
method: 'POST',
body: credentials,
})
}
function logout() {
user.value = null
navigateTo('/login')
}
return { user, isLoggedIn, login, logout }
}4.2 局限性
| 局限 | 说明 | 替代方案 |
|---|---|---|
| 无 getter/action | 只是一个 Ref,无派生状态或方法 | Pinia |
| 无 DevTools 集成 | 不会出现在 Vue DevTools 的状态面板 | Pinia |
| 无持久化 | 刷新页面后状态丢失(SSR 会重新初始化) | Pinia + 持久化插件 |
| 无模块化 | 所有状态扁平存储,无命名空间 | Pinia Store |
| 仅限 setup 上下文 | 必须在组件 setup 或 Composable 中调用 | Pinia |
4.3 useState vs Pinia 选择指南
状态需求评估:
├── 简单的响应式值(主题、语言、用户信息)
│ └── → useState(够用且轻量)
├── 需要 getter/action/模块化
│ └── → Pinia
├── 需要 DevTools 调试
│ └── → Pinia
├── 需要持久化(localStorage/Cookie)
│ └── → Pinia + pinia-plugin-persistedstate
├── 需要跨 Store 引用
│ └── → Pinia
└── 大型应用(10+ 个状态模块)
└── → Pinia
4.4 组合使用
在实际项目中,useState 和 Pinia 经常配合使用:
// 轻量级全局状态 → useState
const theme = useState('theme', () => 'light')
const locale = useState('locale', () => 'zh-CN')
// 复杂业务状态 → Pinia
const videoStore = useVideoStore() // 视频列表、筛选、分页
const cartStore = useCartStore() // 购物车逻辑本章小结
- SSR 安全:
useState为每个请求创建隔离的状态空间,避免模块级ref的跨请求泄漏 - 跨组件共享:同一 key 返回同一个 Ref,无需 provide/inject
- Payload 传递:服务端状态自动序列化到 HTML,客户端 Hydration 时恢复
- 序列化限制:值必须可 JSON 序列化(无函数、Date、Map 等)
- 适用场景:简单全局状态(主题、用户、语言),封装为 Composable 更优雅
- 与 Pinia 互补:轻量状态用 useState,复杂业务状态用 Pinia
下一章讲 Pinia 在 Nuxt4 中的深度集成。