状态管理应用实践与进阶模式
掌握了
useState、Pinia、响应式 API 的基础之后,真正的挑战在于实际场景中如何组合运用。本章从 6 个高频场景出发,每个只给核心思路和关键代码片段,重在讲清何时用、为什么这样用。
1. 表单状态管理
表单是 reactive 最合适的场景——多字段、不需要整体替换、直接 v-model 绑定无需 .value。
推荐做法是封装一个通用 useForm Composable,统一管理 values / errors / dirty / submitting 四个维度的状态:
// app/composables/useForm.ts
export function useForm<T extends Record<string, any>>(initialValues: T) {
const values = reactive({ ...initialValues }) as T
const errors = reactive(
Object.fromEntries(Object.keys(initialValues).map(k => [k, ''])) as Record<keyof T, string>
)
const isDirty = ref(false)
const isSubmitting = ref(false)
watch(() => ({ ...values }), () => { isDirty.value = true }, { deep: true })
function reset() { Object.assign(values, initialValues); isDirty.value = false }
async function handleSubmit(
onSubmit: (values: T) => Promise<void>,
onValidate?: (values: T) => Record<string, string> | null,
) {
// 清空错误 → 校验 → 提交
Object.keys(errors).forEach(k => { (errors as any)[k] = '' })
const validationErrors = onValidate?.(values)
if (validationErrors) {
Object.entries(validationErrors).forEach(([k, v]) => { (errors as any)[k] = v })
return
}
isSubmitting.value = true
try { await onSubmit(values); isDirty.value = false } finally { isSubmitting.value = false }
}
return { values, errors, isDirty, isSubmitting, reset, handleSubmit }
}使用时只需传入初始值、校验函数和提交函数,模板中 v-model="values.title" + errors.title 即可完成表单绑定。对于复杂表单(动态字段、跨步骤),建议直接用 vee-validate 或 FormKit。
2. 乐观更新模式
核心思想:不等服务端响应,先更新 UI,失败时回滚——让用户感受到即时反馈。
典型场景:点赞、收藏、关注等高频交互操作。实现套路固定为三步:
- 记录旧值 → 2. 立即更新 UI → 3. 请求失败时回滚到旧值
async function toggleLike() {
const wasLiked = isLiked.value
isLiked.value = !wasLiked // 立即更新
likeCount.value += wasLiked ? -1 : 1
try {
await $fetch(`/api/videos/${videoId}/like`, { method: wasLiked ? 'DELETE' : 'POST' })
} catch {
isLiked.value = wasLiked // 回滚
likeCount.value += wasLiked ? 1 : -1
useNotificationStore().error('操作失败,请重试')
}
}如果项目中乐观更新场景多,可以抽象一个 useOptimistic(currentValue, updateFn) Composable,将"保存旧值 → 乐观写入 → try/catch 回滚"的通用逻辑封装起来。
3. 列表状态与无限滚动
列表场景的状态需求很固定:items / page / total / loading / error + hasMore / isEmpty 派生状态。推荐封装为通用 Composable:
// app/composables/useInfiniteList.ts(核心结构)
export function useInfiniteList<T>(
fetcher: (params: { page: number }) => Promise<{ items: T[]; total: number }>,
) {
const items = ref<T[]>([]) as Ref<T[]>
const page = ref(1)
const total = ref(0)
const loading = ref(false)
const hasMore = computed(() => items.value.length < total.value)
async function load(reset = false) {
if (loading.value) return
if (reset) { page.value = 1; items.value = [] }
loading.value = true
try {
const result = await fetcher({ page: page.value })
items.value = reset ? result.items : [...items.value, ...result.items]
total.value = result.total
} finally { loading.value = false }
}
async function loadMore() {
if (!hasMore.value) return
page.value++
await load()
}
return { items, loading, hasMore, load, loadMore }
}触发加载的方式推荐 IntersectionObserver 哨兵元素——在列表底部放一个不可见的 <div ref="sentinel">,当它进入视口时调用 loadMore()。设置 rootMargin: '200px' 可以提前 200px 触发,用户几乎感知不到加载间隙。
4. 实时状态同步
WebSocket 和 SSE 的状态同步 Composable 结构相似,核心要点:
- SSR 安全:
import.meta.server时跳过连接 - 自动重连:
onclose/onerror中延迟重试 - 生命周期管理:
onMounted连接,onUnmounted关闭
// 通用模式(以 SSE 为例)
export function useSSEStream<T>(url: string) {
const data = ref<T | null>(null)
const status = ref<'connecting' | 'connected' | 'disconnected'>('connecting')
let es: EventSource | null = null
function connect() {
if (import.meta.server) return
es = new EventSource(url)
es.onopen = () => { status.value = 'connected' }
es.onmessage = (e) => { data.value = JSON.parse(e.data) }
es.onerror = () => { status.value = 'disconnected'; es?.close(); setTimeout(connect, 5000) }
}
onMounted(connect)
onUnmounted(() => es?.close())
return { data, status }
}WebSocket 版本结构类似,额外提供一个 send() 方法即可。选择依据:单向推送用 SSE(如 AI 生成进度),双向通信用 WebSocket(如实时协作编辑)。
5. 状态持久化进阶
不同数据需要不同的持久化策略,统一封装为一个 Composable:
| 存储方式 | SSR 可用 | 容量 | 适用场景 |
|---|---|---|---|
| Cookie | ✅ | ~4KB | Token、主题、语言 |
| localStorage | ❌ | ~5MB | 搜索历史、草稿 |
| sessionStorage | ❌ | ~5MB | 当前会话临时数据 |
// app/composables/usePersisted.ts
export function usePersisted<T>(key: string, defaultValue: T, storage: 'cookie' | 'local' | 'session' = 'local') {
if (storage === 'cookie') return useCookie<T>(key, { default: () => defaultValue })
const data = ref<T>(defaultValue) as Ref<T>
if (import.meta.client) {
const store = storage === 'local' ? localStorage : sessionStorage
const stored = store.getItem(key)
if (stored) try { data.value = JSON.parse(stored) } catch {}
watch(data, (val) => store.setItem(key, JSON.stringify(val)), { deep: true })
}
return data
}使用时一行代码切换策略:usePersisted('theme', 'light', 'cookie') / usePersisted('history', [], 'local')。
6. 状态分治与大型应用
6.1 领域驱动的 Store 拆分
当应用有 10+ 个状态模块时,按业务领域而非技术类型拆分目录:
app/stores/
├── auth/ ← 认证层:useAuthStore、usePermissionStore
├── video/ ← 视频领域:useVideoListStore、useVideoDetailStore、useVideoPlayerStore
├── studio/ ← 创作领域:useStudioStore、useUploadStore
├── ui/ ← UI 层:useNotificationStore、useModalStore
└── settings/ ← 配置层:useSettingsStore
6.2 Store 组合模式
页面级逻辑往往需要组合多个 Store 的数据。推荐用页面级 Composable 作为胶水层,避免页面组件直接引用过多 Store:
export function useVideoPage(videoId: string) {
const videoStore = useVideoDetailStore()
const authStore = useAuthStore()
const canEdit = computed(() =>
authStore.isLoggedIn && (videoStore.video?.authorId === authStore.user?.id || authStore.isAdmin)
)
return { video: computed(() => videoStore.video), canEdit }
}6.3 状态重置
两种常见需求:页面离开时重置和用户登出时全局重置。
// 页面离开时自动重置
export function useStoreReset(...stores: Array<{ $reset?: () => void }>) {
onUnmounted(() => stores.forEach(s => s.$reset?.()))
}
// 全局重置(登出时)
function resetAllStores() {
getActivePinia()?._s.forEach(store => store.$reset?.())
}本章小结
- 表单状态:
reactive+useFormComposable 封装校验/提交/重置 - 乐观更新:记录旧值 → 立即更新 UI → 失败回滚,提升交互体验
- 无限滚动:
useInfiniteList+ IntersectionObserver 哨兵元素 - 实时同步:SSE/WebSocket Composable 统一封装,SSR 安全 + 自动重连
- 多策略持久化:
usePersisted一个 Composable 覆盖 Cookie/localStorage/sessionStorage - 状态分治:领域驱动拆分 Store,页面级 Composable 组合多 Store
至此,第五篇"状态管理"补充完毕。共 5 章全面覆盖了从基础到进阶的状态管理知识体系。