CC 咖啡猫的工作空间 Coding Space

状态管理方案选型与实践

一、前端状态分类

1.1 状态的本质

前端应用中的"状态"本质上是客户端内存中的数据快照。根据数据的来源、生命周期和变更频率,可以将状态分为四个核心类别:

状态类别 数据来源 生命周期 变更频率 典型场景
服务端状态 后端 API 随页面/组件 低频(依赖用户操作) 用户列表、文章详情、订单数据
客户端状态 本地产生 随应用/会话 高频 主题、侧边栏折叠、表单输入值
表单状态 用户输入 随表单组件 极高 输入值、校验错误、提交状态
URL 状态 路由系统 随路由导航 中频 当前页面、筛选参数、搜索关键词

1.2 各类状态的技术选型

服务端状态(Server State)

服务端状态是从后端 API 获取的数据,其核心挑战不是"存储",而是"缓存、同步与失效"。

方案 推荐指数 核心特性 适用场景
TanStack Query (React Query / Vue Query) ⭐⭐⭐⭐⭐ 自动缓存、后台刷新、乐观更新、请求去重 几乎任何需要调用 API 的项目
SWR ⭐⭐⭐⭐ 轻量、stale-while-revalidate 策略、轮询 React 轻量项目
RTK Query ⭐⭐⭐⭐ 集成 Redux、强类型、代码生成 已使用 Redux Toolkit 的项目
Apollo Client ⭐⭐⭐ GraphQL 专用、缓存丰富 GraphQL 项目
自行封装 fetch/axios ⭐⭐ 灵活但需手动处理缓存/去重/竞态 极简项目或学习用途

客户端状态(Client State)

客户端状态是本地产生的 UI 状态,核心挑战是"跨组件共享、响应式更新与模块化"。

方案 推荐指数 核心特性 适用场景
Pinia ⭐⭐⭐⭐⭐ Vue 官方推荐、TypeScript 友好、模块化 Vue 3 项目首选
Zustand ⭐⭐⭐⭐⭐ 极致轻量(~1KB)、TS 友好、无 Provider React 项目首选
Jotai ⭐⭐⭐⭐ 原子化状态、细粒度更新、类 Recoil 复杂原子依赖场景
Redux Toolkit ⭐⭐⭐⭐ 生态成熟、中间件丰富、DevTools 企业级大型 React 应用
Provide/Inject ⭐⭐⭐ 无需安装、适用范围有限 简单组件树共享
Context + useReducer ⭐⭐⭐ React 内置、无外部依赖 中小型应用、原型开发

表单状态(Form State)

表单状态管理的核心挑战是"输入跟踪、校验、提交状态和性能优化"。

方案 推荐指数 核心特性 适用场景
VeeValidate + FormKit ⭐⭐⭐⭐⭐ Vue 生态、校验声明式、zod 集成 Vue 3 项目
React Hook Form ⭐⭐⭐⭐⭐ 性能优异、不受控组件、zod/yup 集成 React 项目
Formik ⭐⭐⭐⭐ 成熟稳定、社区资源丰富 React 中大型表单
Element Plus / Ant Design 表单 ⭐⭐⭐⭐ UI 库内置、开箱即用 基础表单场景

URL 状态(URL State)

URL 状态是通过路由系统管理的状态,核心优势是可分享、可收藏、可回溯

方案 推荐指数 核心特性 适用场景
Vue Router ⭐⭐⭐⭐⭐ query/params、导航守卫、类型化路由 Vue 项目
React Router v6 ⭐⭐⭐⭐⭐ useSearchParams、loaders、URL params React 项目
Next.js/Nuxt 路由 ⭐⭐⭐⭐⭐ 文件路由、SSR 友好、自动类型 全栈框架项目

1.3 状态生命周期总览

┌──────────────────────────────────────────────────────────┐
│                   前端状态全景图                           │
├────────────┬──────────────┬──────────────┬───────────────┤
│ 服务端状态  │  客户端状态   │   表单状态   │    URL状态    │
│            │              │              │               │
│ ┌──────┐   │  ┌──────┐   │  ┌──────┐   │  ┌────────┐   │
│ │ API  │───│→│ Store│   │  │ Input│   │  │ Route  │   │
│ └──────┘   │  └──────┘   │  └──────┘   │  └────────┘   │
│   ↓ 缓存   │    ↓ 响应式   │   ↓ 校验    │    ↓ 导航      │
│ 自动刷新   │  模块化共享  │  提交状态  │  可分享/可回溯  │
├────────────┴──────────────┴──────────────┴───────────────┤
│                   推荐工具链                              │
│ TanStack Query  │  Pinia/Zustand  │  RHF/vee-validate   │
└──────────────────────────────────────────────────────────┘

二、Vue 状态管理方案对比

2.1 方案全景

维度 Pinia Pinia + Vue Query Provide/Inject Vuex 4
定位 客户端状态 服务端+客户端 简单共享 历史遗产
TS 支持 ✅ 原生 ✅ 原生 ⚠️ 一般 ❌ 较差
体积 ~1KB ~15KB(含 Vue Query) 0KB(内置) ~10KB
DevTools ✅ 专用插件 ✅ 双插件 ❌ 不支持
模块化 ✅ defineStore ✅ 按 Query 拆分 ❌ 无结构 ⚠️ 模板代码多
学习成本 极低
推荐场景 Vue 3 项目首选 中大型数据密集型项目 简单父子通信 兼容 Vue 2 历史项目

2.2 Vue 生态选型决策

                  ┌─────────────────────┐
                  │     Vue 3 项目      │
                  └──────────┬──────────┘
                             │
              ┌──────────────┼──────────────┐
              ▼              ▼              ▼
          ┌────────┐   ┌──────────┐   ┌──────────┐
          │ 小型   │   │  中型    │   │  大型    │
          └───┬────┘   └────┬─────┘   └────┬─────┘
              │             │              │
              ▼             ▼              ▼
     ┌────────────┐  ┌────────────┐  ┌────────────────┐
     │Provide/    │  │ Pinia      │  │ Pinia          │
     │Inject      │  │ + Vue Query│  │ + Vue Query    │
     │(简单共享)  │  │            │  │ + 模块化Store   │
     └────────────┘  └────────────┘  └────────────────┘

三、React 状态管理方案对比

3.1 方案全景

维度 Zustand Jotai Redux Toolkit Context + useReducer
定位 轻量全局 Store 原子化状态 企业级状态管理 内置方案
包体积 ~1KB ~3KB ~12KB (含 Redux) 0KB
TS 支持 ✅ 原生 ✅ 原生 ✅ 原生 ⚠️ 需手动类型
Boilerplate 极少 较少 中等 较少
性能 按 selector 更新 原子级精确更新 按 slice 更新 全子树重渲染
中间件 persist/immer/devtools 无官方中间件 丰富生态 N/A
外部使用 ✅ 可直接读写 ❌ 需要 Provider ✅ 可直接读写 ❌ 需要 Provider
推荐场景 中小型项目首选 复杂原子依赖 大型企业项目 原型/简单场景

3.2 React 生态选型决策

                  ┌─────────────────────┐
                  │     React 项目      │
                  └──────────┬──────────┘
                             │
              ┌──────────────┼──────────────┐
              ▼              ▼              ▼
          ┌────────┐   ┌──────────┐   ┌──────────┐
          │ 小型   │   │  中型    │   │  大型    │
          └───┬────┘   └────┬─────┘   └────┬─────┘
              │             │              │
              ▼             ▼              ▼
     ┌────────────┐  ┌────────────┐  ┌────────────────┐
     │Context +   │  │ Zustand    │  │ Redux Toolkit  │
     │useReducer  │  │ + React    │  │ + RTK Query    │
     │(简单场景)  │  │ Query      │  │ + 模块化Slice  │
     └────────────┘  └────────────┘  └────────────────┘

四、Pinia 实践(Vue 3)

4.1 defineStore 的两种定义方式

Options Store(选项式,类 Vuex 写法)

// stores/counter.ts
import { defineStore } from 'pinia'

interface CounterState {
  count: number
  lastUpdated: number | null
}

export const useCounterStore = defineStore('counter', {
  // state 必须是返回对象的函数,避免跨请求/实例的状态污染
  state: (): CounterState => ({
    count: 0,
    lastUpdated: null,
  }),

  // getters 定义派生状态,带类型推断
  getters: {
    doubleCount: (state) => state.count * 2,
    formattedDate: (state): string => {
      if (!state.lastUpdated) return '尚未更新'
      return new Date(state.lastUpdated).toLocaleString('zh-CN')
    },
  },

  // actions 定义业务逻辑,支持同步和异步
  actions: {
    increment(step: number = 1) {
      this.count += step
      this.lastUpdated = Date.now()
    },
    async fetchCount() {
      try {
        const response = await api.get('/api/count')
        this.count = response.data
        this.lastUpdated = Date.now()
      } catch (error) {
        console.error('获取计数失败:', error)
        // 防御式编程:API 失败时使用兜底值
        this.count = 0
      }
    },
  },
})

Setup Store(组合式,推荐方式)

// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

interface User {
  id: number
  name: string
  email: string
  avatar: string
  role: 'admin' | 'editor' | 'viewer'
}

export const useUserStore = defineStore('user', () => {
  // state —— 使用 ref / reactive
  const currentUser = ref<User | null>(null)
  const token = ref<string | null>(localStorage.getItem('token'))
  const loginLoading = ref(false)
  const loginError = ref<string | null>(null)

  // getters —— 使用 computed
  const isLoggedIn = computed(() => !!token.value && !!currentUser.value)
  const isAdmin = computed(() => currentUser.value?.role === 'admin')
  const displayName = computed(() => currentUser.value?.name ?? '未登录用户')
  const avatarUrl = computed(() => {
    // 防御式编程:处理用户信息缺失的场景
    return currentUser.value?.avatar ?? '/default-avatar.png'
  })

  // actions —— 使用普通函数
  async function login(username: string, password: string) {
    loginLoading.value = true
    loginError.value = null

    try {
      // 防御式编程:永远不要信任后端返回的数据
      const response = await api.post('/api/auth/login', {
        username,
        password,
      })

      const data = response.data

      // 防御式编程:校验后端数据完整性
      if (!data.user || !data.token) {
        throw new Error('登录响应格式异常:缺少用户信息或令牌')
      }

      currentUser.value = {
        id: data.user.id,
        name: data.user.name ?? '',
        email: data.user.email ?? '',
        avatar: data.user.avatar ?? '',
        role: data.user.role ?? 'viewer',
      }
      token.value = data.token
      localStorage.setItem('token', data.token)
    } catch (error: any) {
      const message = error?.response?.data?.message ?? error.message ?? '登录失败,请稍后重试'
      loginError.value = message
      throw new Error(message)
    } finally {
      loginLoading.value = false
    }
  }

  function logout() {
    currentUser.value = null
    token.value = null
    localStorage.removeItem('token')
  }

  return {
    currentUser,
    token,
    loginLoading,
    loginError,
    isLoggedIn,
    isAdmin,
    displayName,
    avatarUrl,
    login,
    logout,
  }
})

4.2 Options Store vs Setup Store 对比

对比维度 Options Store Setup Store
写法 对象属性声明 函数式定义(Composition API)
TypeScript 推断 state/getters 自动推断,actions 需手动 全自动类型推断
灵活性 结构固定,无法自定义逻辑组合 可组合任意 Composition API
可测试性 依赖 Pinia 实例 纯函数,更易测试
复杂逻辑组织 依赖 mixin 或外部函数 可直接复用 composables
推荐度 简单场景可选 项目主力推荐

4.3 组件中使用

<!-- views/DashboardView.vue -->
<script setup lang="ts">
import { onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia'

const userStore = useUserStore()
const counterStore = useCounterStore()

// 使用 storeToRefs 解构保持响应性
const { displayName, isAdmin, avatarUrl, loginLoading } = storeToRefs(userStore)

// 直接解构 actions 不会丢失 this 上下文
const { login, logout } = userStore
const { increment, fetchCount } = counterStore

// 错误示范 ❌:直接解构 state/getters 会导致响应性丢失
// const { count } = counterStore // 这样 count 是普通值,不响应

onMounted(() => {
  fetchCount()
})
</script>

<template>
  <div class="dashboard">
    <header>
      <el-avatar :src="avatarUrl" />
      <span>{{ displayName }}</span>
      <el-tag v-if="isAdmin" type="danger">管理员</el-tag>
    </header>

    <main>
      <el-card>
        <template #header>
          <span>计数器</span>
        </template>
        <p>当前值:{{ counterStore.count }}</p>
        <p>翻倍值:{{ counterStore.doubleCount }}</p>
        <p>更新时间:{{ counterStore.formattedDate }}</p>
        <el-button @click="increment(1)" :disabled="loginLoading">
          增加
        </el-button>
        <el-button @click="fetchCount" :loading="loginLoading">
          从服务端刷新
        </el-button>
      </el-card>
    </main>
  </div>
</template>

4.4 模块化拆分策略

src/stores/
├── index.ts              # 统一导出
├── user.ts               # 用户模块
├── permission.ts         # 权限模块
├── app.ts                # 应用配置(主题、侧边栏等)
├── settings.ts           # 用户设置
└── modules/              # 业务模块
    ├── order.ts
    ├── product.ts
    └── notification.ts
// stores/index.ts —— 统一导出入口
export { useUserStore } from './user'
export { usePermissionStore } from './permission'
export { useAppStore } from './app'
export { useOrderStore } from './modules/order'
export { useProductStore } from './modules/product'

4.5 Pinia 持久化

// main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
// stores/settings.ts
import { defineStore } from 'pinia'

export const useSettingsStore = defineStore('settings', () => {
  const theme = ref<'light' | 'dark'>('light')
  const sidebarCollapsed = ref(false)
  const language = ref('zh-CN')

  // 开启持久化
  return { theme, sidebarCollapsed, language }
}, {
  persist: {
    key: 'app-settings',       // 存储键名
    storage: localStorage,      // 存储引擎
    pick: ['theme', 'language'], // 只持久化部分字段
  },
})

4.6 Pinia 注意事项与踩坑

✅ 最佳实践

  • 优先使用 Setup Store,类型推断更好,逻辑组织更灵活
  • 使用 storeToRefs 解构 state/getters,保持响应性
  • 将 API 调用层与 Store 解耦,在 action 中调用独立的 API 模块
  • 合理拆分模块,每个 Store 不超过 8~10 个 action
  • 使用 $reset() 方法重置状态(Options Store 原生支持,Setup Store 需手动实现)

❌ 常见错误

  • 直接解构 state导致响应性丢失
  • 在 Store 外部直接修改 state(虽然 Pinia 允许,但不推荐)
  • Store 之间循环依赖(A Store 引用 B Store,B Store 引用 A Store)
  • Store 中存放非序列化数据(如 DOM 元素、组件实例)
  • 未处理异步 action 的错误边界,导致组件中无法感知失败
// ✅ 正确:使用 storeToRefs 解构
const { count } = storeToRefs(counterStore)

// ❌ 错误:直接解构失去响应性
const { count } = counterStore
// ✅ 正确:Setup Store 手动实现 $reset
export const useFormStore = defineStore('form', () => {
  const initialState = () => ({
    name: '',
    email: '',
    age: 0,
  })

  const name = ref('')
  const email = ref('')
  const age = ref(0)

  function $reset() {
    const state = initialState()
    name.value = state.name
    email.value = state.email
    age.value = state.age
  }

  return { name, email, age, $reset }
})

五、Zustand 实践(React)

5.1 create 函数定义 Store

// stores/counterStore.ts
import { create } from 'zustand'

interface CounterState {
  count: number
  lastUpdated: number | null
  increment: (step?: number) => void
  decrement: (step?: number) => void
  reset: () => void
}

export const useCounterStore = create<CounterState>((set) => ({
  count: 0,
  lastUpdated: null,

  increment: (step = 1) =>
    set((state) => ({
      count: state.count + step,
      lastUpdated: Date.now(),
    })),

  decrement: (step = 1) =>
    set((state) => ({
      count: state.count - step,
      lastUpdated: Date.now(),
    })),

  reset: () => set({ count: 0, lastUpdated: null }),
}))

5.2 使用方式对比

Hook 方式(推荐在组件内使用)

// components/CounterPanel.tsx
import { useCounterStore } from '@/stores/counterStore'

export function CounterPanel() {
  // ✅ 正确:使用 selector 订阅特定字段,避免不必要的重渲染
  const count = useCounterStore((state) => state.count)
  const doubleCount = useCounterStore((state) => state.count * 2)
  const { increment, decrement, reset } = useCounterStore(
    (state) => ({
      increment: state.increment,
      decrement: state.decrement,
      reset: state.reset,
    }),
  )

  return (
    <div className="counter-panel">
      <p>当前值:{count}</p>
      <p>翻倍值:{doubleCount}</p>
      <button onClick={() => increment(1)}>增加</button>
      <button onClick={() => decrement(1)}>减少</button>
      <button onClick={reset}>重置</button>
    </div>
  )
}

外部使用方式(在组件外、工具函数中读写)

// api/tokenInterceptor.ts
import { useAuthStore } from '@/stores/authStore'

// ✅ 在 axios 拦截器中直接获取/设置 Store 值
apiClient.interceptors.request.use((config) => {
  // 注意:useAuthStore 是函数,在非组件环境下可直接调用 .getState()
  const token = useAuthStore.getState().token
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
})

apiClient.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      // 在外部直接修改状态
      useAuthStore.getState().logout()
    }
    return Promise.reject(error)
  },
)

5.3 Hook 方式 vs 外部使用

对比维度 Hook 方式 外部使用
使用范围 仅限 React 组件/Hooks 组件、工具函数、拦截器
订阅机制 自动订阅 + 按 selector 重渲染 手动调用 .getState()
性能 按 selector 精确控制重渲染 无自动订阅,无重渲染
状态更新 set 更新自动通知组件 .setState().getState() 后手动处理
典型场景 组件内使用状态 路由守卫、请求拦截器、Service 层

5.4 中间件

// stores/authStore.ts
import { create } from 'zustand'
import { persist, devtools, immer } from 'zustand/middleware'

interface AuthState {
  user: { id: number; name: string; roles: string[] } | null
  token: string | null
  login: (username: string, password: string) => Promise<void>
  logout: () => void
  addRole: (role: string) => void
}

export const useAuthStore = create<AuthState>()(
  devtools(
    persist(
      immer((set) => ({
        user: null,
        token: null,

        login: async (username, password) => {
          try {
            const response = await api.post('/api/auth/login', { username, password })
            // immer 允许"可变"写法,自动生成不可变状态
            set((state) => {
              state.user = response.data.user
              state.token = response.data.token
            })
          } catch (error) {
            console.error('登录失败:', error)
            throw error
          }
        },

        logout: () => {
          set({ user: null, token: null })
          localStorage.removeItem('auth-storage')
        },

        addRole: (role) => {
          set((state) => {
            if (state.user) {
              state.user.roles.push(role) // immer 允许直接 push
            }
          })
        },
      })),
      {
        name: 'auth-storage',
        partialize: (state) => ({ token: state.token, user: state.user }),
      },
    ),
    { name: 'AuthStore' },
  ),
)

5.5 slice 模式拆分 Store

当业务逻辑复杂时,使用 slice 模式将一个 Store 拆分为多个逻辑片段:

// stores/orderStore.ts
import { create } from 'zustand'

// ── 类型定义 ──
interface OrderItem {
  id: string
  productId: number
  productName: string
  quantity: number
  price: number
}

interface OrderState {
  // 订单列表 slice
  orders: OrderItem[]
  loading: boolean
  error: string | null

  // 筛选 slice
  filter: {
    status: string
    dateRange: [string, string] | null
  }

  // Actions:订单列表 slice
  fetchOrders: () => Promise<void>
  addOrder: (order: OrderItem) => void
  removeOrder: (id: string) => void

  // Actions:筛选 slice
  setFilter: (filter: Partial<OrderState['filter']>) => void
  resetFilter: () => void
}

// ── Slice 工厂函数模式 ──
function createOrderSlice(
  set: Parameters<typeof create<OrderState>>[0],
) {
  return {
    orders: [] as OrderItem[],
    loading: false,
    error: null,

    fetchOrders: async () => {
      set({ loading: true, error: null })
      try {
        const response = await api.get('/api/orders')
        // 防御式编程:校验后端数据
        const data = Array.isArray(response.data) ? response.data : []
        set({ orders: data, loading: false })
      } catch (error: any) {
        set({
          loading: false,
          error: error?.message ?? '获取订单列表失败',
        })
      }
    },

    addOrder: (order) =>
      set((state) => ({ orders: [...state.orders, order] })),

    removeOrder: (id) =>
      set((state) => ({
        orders: state.orders.filter((o) => o.id !== id),
      })),
  }
}

function createFilterSlice(
  set: Parameters<typeof create<OrderState>>[0],
) {
  return {
    filter: {
      status: 'all',
      dateRange: null as [string, string] | null,
    },

    setFilter: (partial) =>
      set((state) => ({
        filter: { ...state.filter, ...partial },
      })),

    resetFilter: () =>
      set({
        filter: { status: 'all', dateRange: null },
      }),
  }
}

// ── 合并 Slice ──
export const useOrderStore = create<OrderState>()((set) => ({
  ...createOrderSlice(set),
  ...createFilterSlice(set),
}))

5.6 Zustand 注意事项与踩坑

✅ 最佳实践

  • 使用 selector 精确订阅,避免父组件更新导致整个子树重渲染
  • 使用 shallow 比较import { shallow } from 'zustand/shallow'
  • immer 中间件简化不可变更新,特别是嵌套对象场景
  • 使用 persist 中间件持久化关键状态(token、偏好设置)
  • 在拦截器/工具函数中使用 .getState() 而非 useXxxStore()

❌ 常见错误

  • 在组件中订阅整个 Store,导致任何字段变更都触发重渲染
  • 在 Zustand action 中直接修改 state(非 immer 中间件时)
  • selector 返回新对象/数组(每次重渲染时引用不同,导致死循环)
  • Store 中存放异步操作状态(loading/error 应独立管理或使用 TanStack Query)
// ✅ 正确:selector 返回原始值
const count = useCounterStore((s) => s.count)

// ❌ 错误:selector 每次都返回新对象,导致无限重渲染
const { count, increment } = useCounterStore(
  (s) => ({ count: s.count, increment: s.increment }),
)
// ✅ 修复:使用 shallow 比较
import { shallow } from 'zustand/shallow'
const { count, increment } = useCounterStore(
  (s) => ({ count: s.count, increment: s.increment }),
  shallow,
)

六、服务端状态管理

6.1 为什么需要专门的服务端状态管理工具?

传统模式下,开发者通常自行封装 API 调用,在 Store 或组件中管理 loading/error/data 三个状态。这种模式存在以下问题:

问题 传统模式 TanStack Query 方案
缓存管理 手动维护缓存,缺少失效策略 自动缓存 + 基于时间的失效策略
请求去重 同一时间多个组件请求相同 API 自动去重,只发一个请求
后台刷新 需手动 setInterval 支持 focus refetch、stale time
乐观更新 实现繁琐,易出错 内置 API,回滚机制
分页/无限加载 手动维护 page/cursor 内置 useInfiniteQuery
竞态条件 需手动处理请求过期 自动取消过期请求

6.2 Vue:TanStack Vue Query 实践

// composables/useArticle.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query'
import { api } from '@/api'

interface Article {
  id: number
  title: string
  content: string
  author: { id: number; name: string }
  createdAt: string
  tags: string[]
}

// ── 获取文章列表 ──
export function useArticles() {
  return useQuery({
    queryKey: ['articles'],
    queryFn: async (): Promise<Article[]> => {
      const response = await api.get('/api/articles')
      // 防御式编程:校验后端数据结构
      if (!Array.isArray(response.data)) {
        console.warn('API 返回格式异常,期望数组,收到:', typeof response.data)
        return []
      }
      return response.data.map((item: any) => ({
        id: item.id,
        title: item.title ?? '',
        content: item.content ?? '',
        author: {
          id: item.author?.id ?? 0,
          name: item.author?.name ?? '未知作者',
        },
        createdAt: item.createdAt ?? new Date().toISOString(),
        tags: Array.isArray(item.tags) ? item.tags : [],
      }))
    },
    staleTime: 30_000,     // 30 秒内认为数据是新鲜的,不触发重新请求
    gcTime: 5 * 60_000,    // 5 分钟后清除缓存(原名 cacheTime)
    refetchOnWindowFocus: true,
    retry: 2,               // 失败重试次数
    retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 10_000),
  })
}

// ── 获取单个文章 ──
export function useArticle(id: number) {
  return useQuery({
    queryKey: ['articles', id],
    queryFn: async (): Promise<Article | null> => {
      try {
        const response = await api.get(`/api/articles/${id}`)
        const item = response.data
        if (!item || !item.id) {
          throw new Error('文章不存在')
        }
        return {
          id: item.id,
          title: item.title ?? '',
          content: item.content ?? '',
          author: {
            id: item.author?.id ?? 0,
            name: item.author?.name ?? '未知作者',
          },
          createdAt: item.createdAt ?? new Date().toISOString(),
          tags: Array.isArray(item.tags) ? item.tags : [],
        }
      } catch (error) {
        // 防御:捕获所有异常,返回 null 而非崩溃
        console.error(`获取文章 ${id} 失败:`, error)
        return null
      }
    },
    enabled: !!id,          // id 为 falsy 时不请求
  })
}

// ── 创建文章(Mutation + 乐观更新 + 缓存失效) ──
export function useCreateArticle() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (data: { title: string; content: string; tags: string[] }) =>
      api.post('/api/articles', data),

    // 乐观更新
    onMutate: async (newArticle) => {
      // 取消可能正在进行的相关查询
      await queryClient.cancelQueries({ queryKey: ['articles'] })

      // 保存之前的数据用于回滚
      const previousArticles = queryClient.getQueryData<Article[]>(['articles'])

      // 乐观插入
      queryClient.setQueryData<Article[]>(['articles'], (old) => {
        const optimisticArticle: Article = {
          id: -Date.now(),     // 临时 ID
          title: newArticle.title,
          content: newArticle.content,
          author: { id: 0, name: '保存中...' },
          createdAt: new Date().toISOString(),
          tags: newArticle.tags,
        }
        return old ? [optimisticArticle, ...old] : [optimisticArticle]
      })

      // 返回 context,供 onError 回滚使用
      return { previousArticles }
    },

    // 请求成功:使缓存失效,触发重新获取
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['articles'] })
    },

    // 请求失败:回滚乐观更新
    onError: (error, _, context) => {
      if (context?.previousArticles) {
        queryClient.setQueryData(['articles'], context.previousArticles)
      }
      console.error('创建文章失败:', error)
    },
  })
}
<!-- views/ArticleListView.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { useArticles, useCreateArticle } from '@/composables/useArticle'
import { ElMessage } from 'element-plus'

const { data: articles, isLoading, isError, error, refetch } = useArticles()
const { mutate: createArticle, isPending: isCreating } = useCreateArticle()

const showDialog = ref(false)
const form = ref({ title: '', content: '', tags: '' })

function handleCreate() {
  createArticle(
    {
      title: form.value.title,
      content: form.value.content,
      tags: form.value.tags.split(',').map((t) => t.trim()).filter(Boolean),
    },
    {
      onSuccess: () => {
        ElMessage.success('文章创建成功')
        showDialog.value = false
        form.value = { title: '', content: '', tags: '' }
      },
      onError: (err) => {
        ElMessage.error(err?.message ?? '创建失败,请稍后重试')
      },
    },
  )
}
</script>

<template>
  <div>
    <!-- 加载状态 -->
    <el-skeleton v-if="isLoading" :rows="5" animated />

    <!-- 错误状态 -->
    <el-result v-else-if="isError" status="error" title="加载失败">
      <template #extra>
        <el-button @click="refetch">重新加载</el-button>
      </template>
    </el-result>

    <!-- 空状态 -->
    <el-empty v-else-if="!articles?.length" description="暂无文章" />

    <!-- 数据列表 -->
    <el-table v-else :data="articles">
      <el-table-column prop="title" label="标题" />
      <el-table-column prop="author.name" label="作者" />
      <el-table-column prop="createdAt" label="创建时间" />
    </el-table>

    <el-button @click="showDialog = true" :loading="isCreating">
      新建文章
    </el-button>
  </div>
</template>

6.3 React:TanStack React Query 实践

// hooks/useArticles.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/api'

interface Article {
  id: number
  title: string
  content: string
  author: { id: number; name: string }
  createdAt: string
  tags: string[]
}

export function useArticles(filters?: { status?: string; page?: number }) {
  return useQuery({
    queryKey: ['articles', filters],
    queryFn: async (): Promise<Article[]> => {
      const response = await api.get('/api/articles', { params: filters })
      // 防御式编程:永远不要信任后端返回的数据
      if (!Array.isArray(response.data)) {
        console.warn('API 返回格式异常,期望数组:', response.data)
        return []
      }
      return response.data.map((item: any) => ({
        id: item.id,
        title: item.title ?? '',
        content: item.content ?? '',
        author: {
          id: item.author?.id ?? 0,
          name: item.author?.name ?? '未知作者',
        },
        createdAt: item.createdAt ?? new Date().toISOString(),
        tags: Array.isArray(item.tags) ? item.tags : [],
      }))
    },
    staleTime: 30_000,
    placeholderData: keepPreviousData, // 分页时保留前页数据
    refetchOnWindowFocus: true,
  })
}

export function useUpdateArticle() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: ({ id, ...data }: Partial<Article> & { id: number }) =>
      api.put(`/api/articles/${id}`, data),

    onSuccess: (_, variables) => {
      // 使具体文章和高层列表缓存同时失效
      queryClient.invalidateQueries({ queryKey: ['articles', variables.id] })
      queryClient.invalidateQueries({ queryKey: ['articles'] })
    },
  })
}
// components/ArticleList.tsx
import { useState } from 'react'
import { useArticles, useCreateArticle } from '@/hooks/useArticles'
import { Button, Table, Modal, message, Skeleton, Empty, Result } from 'antd'

export function ArticleList() {
  const [page, setPage] = useState(1)
  const { data: articles, isLoading, isError, refetch } = useArticles({ page })
  const { mutateAsync: createArticle, isPending: isCreating } = useCreateArticle()
  const [modalOpen, setModalOpen] = useState(false)

  // 加载态
  if (isLoading) {
    return <Skeleton active paragraph={{ rows: 8 }} />
  }

  // 错误态
  if (isError) {
    return (
      <Result
        status="error"
        title="数据加载失败"
        extra={<Button onClick={() => refetch()}>重新加载</Button>}
      />
    )
  }

  return (
    <div>
      {/* 空态 */}
      {!articles?.length ? (
        <Empty description="暂无文章" />
      ) : (
        <Table
          dataSource={articles}
          columns={[
            { title: '标题', dataIndex: 'title', key: 'title' },
            {
              title: '作者',
              dataIndex: ['author', 'name'],
              key: 'author',
            },
            { title: '创建时间', dataIndex: 'createdAt', key: 'createdAt' },
          ]}
          pagination={{
            current: page,
            onChange: setPage,
          }}
        />
      )}

      <Button
        type="primary"
        onClick={() => setModalOpen(true)}
        loading={isCreating}
      >
        新建文章
      </Button>

      <Modal
        title="新建文章"
        open={modalOpen}
        onCancel={() => setModalOpen(false)}
        onOk={async () => {
          await createArticle({ title: '测试', content: '内容', tags: [] })
          setModalOpen(false)
        }}
      />
    </div>
  )
}

6.4 服务端状态管理注意事项

✅ 最佳实践

  • 将 queryKey 作为缓存依赖的第一公民,合理设计层级(['资源', ...参数]
  • 合理设置 staleTime,避免不必要地重复请求
    • 静态数据(配置、字典):5~30 分钟
    • 用户信息:30 秒~1 分钟
    • 实时数据(通知、消息):0 或很短
  • 使用 placeholderData: keepPreviousData 提升分页/筛选体验
  • 使用乐观更新提升用户体验,同时做好回滚准备
  • 在 queryFn 中做数据校验和转换,而非在组件中处理

❌ 常见错误

  • 将 API 调用和状态管理混在 Store 中,与 TanStack Query 职责重叠
  • queryKey 粒度太粗,导致不必要的缓存失效
  • 乐观更新未处理回滚逻辑,导致 UI 显示脏数据
  • 未处理 queryFn 中的异常,导致错误边界崩溃
  • onSuccess 中又手动 setState,导致数据源不统一

七、状态管理选型决策树

7.1 完整决策树

              ┌──────────────────────────────────┐
              │        开始项目状态管理选型        │
              └────────────────┬─────────────────┘
                               │
                    ┌──────────┴──────────┐
                    ▼                     ▼
            ┌─────────────┐      ┌──────────────┐
            │  Vue 3 项目 │      │ React 项目   │
            └──────┬──────┘      └──────┬───────┘
                   │                    │
           ┌───────┼───────┐    ┌───────┼───────┐
           ▼       ▼       ▼    ▼       ▼       ▼
       ┌─────┐ ┌─────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐
       │ 小  │ │ 中  │ │ 大 │ │ 小 │ │ 中  │ │ 大 │
       └──┬──┘ └──┬──┘ └──┬─┘ └──┬─┘ └──┬──┘ └──┬─┘
          │       │       │      │       │       │
          ▼       ▼       ▼      ▼       ▼       ▼
      ┌──────┐ ┌──────┐ ┌────┐ ┌────┐ ┌──────┐ ┌──────┐
      │简单  │ │Pinia │ │标准│ │ Ctx│ │Zust- │ │Redux │
      │共享  │ │+Vue  │ │+   │ │+use│ │and   │ │Tool- │
      │:     │ │Query │ │Vue │ │Red-│ │+     │ │kit   │
      │Prov/ │ │      │ │Qry │ │ucr │ │ReactQ│ │+RTK  │
      │Inject│ │      │ │    │ │    │ │      │ │Query │
      └──────┘ └──────┘ └────┘ └────┘ └──────┘ └──────┘

7.2 场景建议表

项目类型 推荐方案 备选方案 核心考量
Vue 3 后台管理 Pinia + Vue Query Pinia + axios 官方推荐、生态成熟
Vue 3 简单 SPA Pinia Provide/Inject 保持简洁、不过度设计
Vue 3 SSR (Nuxt) Pinia + useFetch Vue Query Nuxt 内置 useFetch 更简单
React 中后台 Zustand + React Query RTK + RTK Query 轻量、TS 友好
React 大型企业应用 Redux Toolkit + RTK Query Zustand + React Query 团队规范、DevTools、生态
React 简单页面 Context + useReducer Zustand 零依赖、快速
React SSR (Next.js) Zustand + React Query RTK Query Server Components 兼容性
微前端主应用 Zustand(外部可读写) Pinia 无需 Provider 包裹
复杂表单 React Hook Form + zod VeeValidate + zod 性能优先、TS 集成
实时数据仪表盘 React Query + WebSocket Zustand + EventSource 自动缓存 + 实时更新

7.3 技术选型检查清单

Vue 3 项目检查清单

  • 是否选择了 Pinia 作为客户端状态管理方案?
  • 是否引入 TanStack Vue Query 处理服务端状态?
  • 简单父子共享是否使用了 Provide/Inject 而非引入 Store?
  • Store 模块是否按业务垂直拆分(非按功能水平拆分)?
  • 是否需要持久化插件 pinia-plugin-persistedstate
  • 是否避免了 Store 中存放非序列化数据?
  • 组件中是否使用了 storeToRefs 解构 state?
  • API 层是否与 Store 解耦(放在独立的 api/ 目录)?

React 项目检查清单

  • 小型项目是否使用了 Zustand 而非 Redux Toolkit
  • 大型项目是否评估了 Redux Toolkit + RTK Query
  • 是否引入了 TanStack React Query 处理服务端状态?
  • Zustand 组件中是否使用了精确的 selector 订阅?
  • Zustand selector 是否避免了返回新对象/数组(或使用 shallow)?
  • 非组件场景是否使用 .getState() 而非 useStore()
  • Redux Toolkit 是否使用了 createAsyncThunkRTK Query 处理异步?
  • 是否避免了 Context 滥用导致的性能问题?

通用检查清单

  • URL 状态是否存储在路由中而非 Store 中?
  • 表单状态是否由表单库管理而非全局 Store?
  • 是否区分了服务端状态和客户端状态,并各自选用了合适的工具?
  • 是否对 API 返回的数据进行了防御式校验和默认值处理?
  • 缓存策略是否根据数据特性设置了合理的 staleTime
  • 团队是否有统一的 Store 规范和命名约定?

7.4 总结

┌────────────────────────────────────────────────────────────┐
│                     核心原则总结                            │
├────────────────────────────────────────────────────────────┤
│                                                            │
│  1. 分类管理:服务端状态用 Query 工具,客户端状态用 Store │
│  2. 职责分离:API 调用层、Store 层、组件层职责清晰        │
│  3. 最小必要:不滥用全局状态,能用局部就用局部            │
│  4. 防御编程:永远不信任后端数据,校验 + 兜底值            │
│  5. 性能意识:精确订阅、合理缓存、避免不必要重渲染        │
│  6. 团队规范:统一命名、结构化模块、代码审查              │
│                                                            │
└────────────────────────────────────────────────────────────┘

状态管理的本质不是"存数据",而是"管理数据的生命周期"。选择方案时,理解你的数据从哪里来、到哪里去、多久变一次,比选哪个库更重要。