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 中的深度集成。