CC 咖啡猫的工作空间 Coding Space

前端状态管理深度笔记

覆盖主流状态管理方案的核心原理、实现对比、最佳实践,以及服务端/客户端状态分离、状态规范化等通用原则。


1. 方案对比总览

方案 核心思想 数据结构 不可变 学习曲线 体积 TS 支持 适用场景
Redux (RTK) 单一 Store、Flux 单向流 纯对象(reducer 返回新对象) 是(immer 辅助) 中高 ~12KB 极好 大型应用、复杂异步逻辑
MobX 可观察状态、自动追踪 可变对象(Proxy/defineProperty) ~16KB 状态间依赖复杂的中型应用
Zustand 小型独立 Store、Hook 驱动 纯对象 + 不可变更新 是(约定) 极低 ~1KB 极好 中/小型应用、组件状态提升
Jotai 原子化状态、Bottom-up 原子(Atom,独立状态单元) 是(不可变原子) ~3KB 需要细粒度订阅的场景
Pinia 去中心化 Store、组合式 API 响应式对象(Vue reactive) 否(可变修改) ~1KB 极好 Vue 3 应用
Vuex 单一 Store、Mutation/Action 分离 响应式对象 否(可变修改) ~5KB 一般 Vue 2 遗留项目

核心选择原则

应用规模小且状态简单 → useState / ref + Context / provide-inject
应用规模中等 → Zustand / Jotai / Pinia
应用规模大且异步逻辑复杂 → Redux Toolkit / RTK Query
Vue 3 项目 → Pinia
需要细粒度订阅 → Jotai(React)/ Pinia(Vue)
不可变性偏好 → Redux / Zustand
可变性偏好 → MobX

2. Flux 模式

2.1 单向数据流

  ┌──────────┐      ┌────────────┐      ┌─────────┐      ┌─────────┐
  │  Action  │ ───→ │ Dispatcher │ ───→ │  Store  │ ───→ │  View   │
  │ (描述变更)│      │ (调度中心)   │      │ (状态+逻辑)│      │ (UI组件) │
  └──────────┘      └────────────┘      └─────────┘      └─────────┘
       ↑                                                       │
       └─────────────────────── 用户交互 ────────────────────────┘

核心特点

  • 单向:数据只能沿一个方向流动(Action → Dispatcher → Store → View)
  • 确定性:同一 Action 序列产生相同的状态变化
  • 可追踪:所有状态变更都通过 Action 触发,便于调试(时间旅行)

2.2 Flux vs MVC 在大型应用中的区别

MVC 在大型应用中容易出现双向数据流混乱(Model 更新 View,View 事件又修改 Model),Flux 通过强制单向流解决了这个问题。

2.3 Redux 是对 Flux 的演进

Redux 简化了 Flux:

  • 单一 Store:vs Flux 的多个 Store
  • 无 Dispatcher:reducer 本身就是纯函数调度
  • 纯函数 Reducer(prevState, action) => newState

3. Redux Toolkit

3.1 createSlice(immer 不可变更新)

import { createSlice } from '@reduxjs/toolkit'

const postsSlice = createSlice({
  name: 'posts',
  initialState: {
    items: [],
    status: 'idle',
    error: null
  },
  reducers: {
    // immer 代理 state,可以直接"修改"
    addPost: (state, action: PayloadAction<Post>) => {
      state.items.push(action.payload)   // 看起来是 mutable 操作
    },
    deletePost: (state, action) => {
      state.items = state.items.filter(p => p.id !== action.payload)
    },
    resetPosts: () => ({ items: [], status: 'idle', error: null })
  }
})
// 自动生成 actions: postsSlice.actions.addPost

immer 原理produce(baseState, recipe) 用 Proxy 拦截 draft 上的所有修改操作,记录变更成 patch,最后根据 patch 生成新对象(不可变)。

3.2 createAsyncThunk(异步状态管理)

const fetchPosts = createAsyncThunk(
  'posts/fetchPosts',
  async (_, { rejectWithValue }) => {
    try {
      const res = await fetch('/api/posts')
      if (!res.ok) return rejectWithValue('Fetch failed')
      return await res.json()
    } catch (err) {
      return rejectWithValue(err.message)
    }
  }
)

const postsSlice = createSlice({
  name: 'posts',
  initialState: { items: [], status: 'idle', error: null },
  extraReducers: (builder) => {
    builder
      .addCase(fetchPosts.pending, (state) => {
        state.status = 'loading'
      })
      .addCase(fetchPosts.fulfilled, (state, action) => {
        state.status = 'succeeded'
        state.items = action.payload
      })
      .addCase(fetchPosts.rejected, (state, action) => {
        state.status = 'failed'
        state.error = action.payload
      })
  }
})

内部原理createAsyncThunk 生成三个 action types(pending/fulfilled/rejected),返回的 thunk action 内部 dispatch 这三个 action。

3.3 RTK Query(缓存策略)

const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Posts', 'User'],
  endpoints: (builder) => ({
    getPosts: builder.query<Post[], void>({
      query: () => 'posts',
      // 缓存策略
      keepUnusedDataFor: 60,     // 缓存保留时间(秒)
      providesTags: ['Posts'],   // 提供哪些标签
    }),
    addPost: builder.mutation<Post, Partial<Post>>({
      query: (body) => ({
        url: 'posts',
        method: 'POST',
        body,
      }),
      // 自动失效缓存
      invalidatesTags: ['Posts'],
    }),
    // 乐观更新
    updatePost: builder.mutation<Post, Post>({
      query: ({ id, ...patch }) => ({
        url: `posts/${id}`,
        method: 'PATCH',
        body: patch,
      }),
      async onQueryStarted({ id, ...patch }, { dispatch, queryFulfilled }) {
        // 乐观更新:先更新 UI
        const patchResult = dispatch(
          api.util.updateQueryData('getPosts', undefined, (draft) => {
            const post = draft.find(p => p.id === id)
            if (post) Object.assign(post, patch)
          })
        )
        try {
          await queryFulfilled
        } catch {
          // 请求失败,回滚
          patchResult.undo()
        }
      }
    })
  })
})

RTK Query 缓存机制

  1. 每个查询结果缓存 + tag 标记
  2. Mutation 通过 invalidatesTags 使缓存过期
  3. 订阅该 tag 的 query 自动重新获取
  4. keepUnusedDataFor 控制缓存保留时间
  5. 提供 matchFulfilled 等监听器实现响应式更新

3.4 Middleware 原理

Redux Middleware 是柯里化函数,在 dispatch 和 reducer 之间增加一层:

// Middleware 签名
const middleware = (store) => (next) => (action) => {
  console.log('dispatching', action)
  const result = next(action)  // 传给下一个 middleware 或 reducer
  console.log('next state', store.getState())
  return result
}

链式调用applyMiddleware(m1, m2, m3) 将 middleware 组合成链,dispatch 穿透时依次执行。

常见中间件

  • redux-thunk — 允许 dispatch 函数(thunk),处理异步
  • redux-saga — 基于 Generator 的副作用管理
  • redux-observable — 基于 RxJS 的异步流处理
  • RTK 内置 createAsyncThunk 替代 thunk/saga 的大部分场景

4. Context 方案

4.1 React Context 的局限性

const AppContext = createContext({ user: null, theme: 'light', cart: [] })

// ❌ 任何值变化触发所有 Consumer 重渲染
// 即使 ThemeProvider 的 theme 从 'light' → 'dark'
// 只用了 user 的 Profile 组件也会重新渲染

核心缺陷:Context 没有选择器机制,值变化时所有 Consumer 不论是否需要都重渲染。

4.2 拆分 Context 的策略

const UserContext = createContext(null)
const ThemeContext = createContext('light')
const CartContext = createContext([])

function App() {
  return (
    <UserProvider>
      <ThemeProvider>
        <CartProvider>
          <Main />
        </CartProvider>
      </ThemeProvider>
    </UserProvider>
  )
}

何时拆分:不同数据更新频率差异大时必须拆分。如:用户偏好(低频)与购物车(高频)分开。

4.3 use-context-selector 原理

import { createContext, useContextSelector } from 'use-context-selector'

const Context = createContext(null)

function UserName() {
  // 只订阅 user.name 的变化
  const name = useContextSelector(Context, (state) => state.user.name)
  return <div>{name}</div>
}

原理:内部维护每个 Context 的订阅者列表 + selector 函数列表。Provider 更新时遍历订阅者,执行 selector 比较新旧值,只有 selector 返回值变化时才触发重新渲染。


5. Zustand

5.1 create 函数原理

// Zustand 的核心实现(约 200 行)
import { useSyncExternalStore } from 'react'

function createStore(createState) {
  let state
  const listeners = new Set<() => void>()

  const setState = (partial, replace = false) => {
    const nextState = typeof partial === 'function'
      ? partial(state)
      : partial
    if (nextState !== state) {
      const previousState = state
      state = replace ? nextState : Object.assign({}, state, nextState)
      listeners.forEach(listener => listener())
    }
  }

  const getState = () => state
  const subscribe = (listener) => {
    listeners.add(listener)
    return () => listeners.delete(listener)
  }
  const destroy = () => listeners.clear()

  state = createState(setState, getState, api)
  const api = { setState, getState, subscribe, destroy }
  return api
}

// React Hook 绑定(通过 useSyncExternalStore)
export function create<State>(createState) {
  const api = createStore(createState)
  const useStore = (selector = identity, equalityFn = Object.is) =>
    useSyncExternalStoreWithSelector(
      api.subscribe,
      api.getState,
      api.getServerState,
      selector,
      equalityFn
    )
  Object.assign(useStore, api)
  return useStore
}

核心create 返回一个既是 Hook 又是 Store API 的函数。Hook 内部通过 useSyncExternalStore 订阅 store,使得 Zustand 不依赖 React Context。

5.2 不可变更新与 immer 中间件

// 直接不可变更新
const useStore = create((set) => ({
  todos: [],
  addTodo: (todo) => set((state) => ({
    todos: [...state.todos, todo]   // 手动不可变
  }))
}))

// immer 中间件("可变"写法)
import { immer } from 'zustand/middleware/immer'

const useStore = create(
  immer((set) => ({
    todos: [],
    addTodo: (todo) => set((state) => {
      state.todos.push(todo)   // immer 代理,实际不可变
    })
  }))
)

5.3 订阅选择器(selector + shallow)

// 精确订阅 —— 只有 bears 变化时重渲染
const bears = useBearStore(s => s.bears)

// 浅比较订阅 —— 返回的对象的顶层属性变化时重渲染
import { shallow } from 'zustand/shallow'

const { name, age } = useBearStore(
  s => ({ name: s.name, age: s.age }),
  shallow  // 默认是 strict equal (===),shallow 做浅比较
)

5.4 多个 Store 的设计模式

// 按业务领域拆分 Store
const useUserStore = create(...)
const useCartStore = create(...)
const useUIStore = create(...)

// Store 间可互相访问(如下单时读取用户信息)
const useOrderStore = create((set, get) => ({
  placeOrder: async () => {
    const user = useUserStore.getState().user
    const cart = useCartStore.getState().items
    const order = await api.placeOrder(user.id, cart)
    set({ order })
    useCartStore.getState().clearCart() // 下单后清空购物车
  }
}))

模式原则:按数据更新频率业务领域拆分,避免"上帝 Store"。


6. Jotai / VueUse

6.1 原子化状态

import { atom, useAtom } from 'jotai'

// 定义原子
const countAtom = atom(0)
const doubledAtom = atom((get) => get(countAtom) * 2)
const asyncDataAtom = atom(async () => {
  const res = await fetch('/api/data')
  return res.json()
})

// 组件中使用
function Counter() {
  const [count, setCount] = useAtom(countAtom)
  const [doubled] = useAtom(doubledAtom)
  return <div>{count} x 2 = {doubled}</div>
}

6.2 派生状态

// 派生原子(只读)
const isHighAtom = atom((get) => get(countAtom) > 100)

// 写原子(读写)
const toggleAtom = atom(
  (get) => get(countAtom),
  (get, set, newValue: number) => {
    set(countAtom, newValue)
    set(logAtom, [...get(logAtom), newValue])
  }
)

原理:原子构成依赖图(DAG),Jotai 内部按拓扑序更新,每个原子只重新计算受影响的派生原子。

6.3 异步原子

const userAtom = atom(async (get) => {
  const userId = get(userIdAtom)
  const res = await fetch(`/api/users/${userId}`)
  return res.json()
})

// 使用 Suspense 或 loadable
function User() {
  return (
    <Suspense fallback={<Loading />}>
      <UserContent />
    </Suspense>
  )
}

// 或使用 loadable 避免 Suspense
const userLoadable = useAtomValue(loadable(userAtom))
if (userLoadable.state === 'loading') return <Loading />

6.4 Jotai 与 Zustand 对比

对比维度 Zustand Jotai
抽象层级 Store(全局状态容器) Atom(原子化状态单元)
细粒度订阅 需要 selector 天然细粒度(每个 atom 独立)
派生状态 set/get 时手动计算 自动推导(依赖图)
与 React Context 不依赖 底层的 Provider 仍用 Context
异步支持 手动管理 原生支持异步 atom
心智模型 集中式 Store 分散式原子

7. 通用原则

7.1 服务端状态 vs 客户端状态分离

┌─────────────────────────────────────────────┐
│ 服务端状态(Server State)                  │
│ - 存储在服务端数据库                        │
│ - 数据获取(fetch/query)                    │
│ - 缓存策略(stale-while-revalidate)        │
│ - 可能被其他用户/设备修改                    │
│ - 示例:文章列表、用户信息、订单数据        │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ 客户端状态(Client State)                  │
│ - 仅存在于客户端                            │
│ - 无需网络请求                              │
│ - 刷新即丢失(除非持久化)                   │
│ - 示例:UI 状态(折叠/展开)、表单输入、主题  │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ URL 状态(URL State)                       │
│ - 存在于 URL(路径/查询参数/哈希)            │
│ - 可分享、可收藏、可后退/前进                 │
│ - 示例:当前页面、搜索关键词、筛选条件        │
└─────────────────────────────────────────────┘

分离的好处

  • 服务端状态用专用工具管理(RTK Query / TanStack Query / SWR)
  • 客户端状态用通用工具管理(Zustand / Context / useState)
  • 避免将服务端数据缓存在全局 Store 中做手动同步

7.2 状态放置位置的优先级

                   第一选择
                    ↓
                 ┌──────┐
                 │  URL  │  ← 可分享、可书签、可导航
                 └──────┘
                    ↓
               ┌──────────┐
               │ 全局 Store │  ← 多组件共享、跨页面状态
               └──────────┘
                    ↓
              ┌────────────┐
              │ 组件提升状态 │  ← 父子或兄弟间共享
              └────────────┘
                    ↓
              ┌──────────────┐
              │ 组件局部状态   │  ← 仅当前组件需要
              └──────────────┘
                    ↓
              ┌──────────────┐
              │ 局部变量/ref  │  ← 不需要触发渲染的值
              └──────────────┘
                    ↓
                   最后选择

判断步骤

  1. 这个状态是否应出现在 URL 中?(搜索、筛选、分页)→ URL State
  2. 是否被多层或远离的组件共享?→ 全局 Store
  3. 是否只是父子间共享?→ Lifting State Up
  4. 是否只属于一个组件?→ useState/useReducer
  5. 是否不需要触发 UI 更新?→ useRef / 局部变量

7.3 状态规范化(Normalization)

问题:嵌套对象在 Store 中导致数据冗余、更新困难、同步不一致。

❌ 非规范化
{
  posts: [
    { id: 1, title: "...", author: { id: 1, name: "Alice" }, comments: [...] }
  ]
}

✅ 规范化(类似数据库设计)
{
  posts: {
    byId: { "1": { id: 1, title: "...", authorId: 1, commentIds: ["100"] } },
    allIds: ["1"]
  },
  users: {
    byId: { "1": { id: 1, name: "Alice" } },
    allIds: ["1"]
  },
  comments: {
    byId: { "100": { id: "100", text: "..." } },
    allIds: ["100"]
  }
}

好处

  • 单一数据源(用户信息只存一次)
  • 更新用户信息只需改一处
  • 减少冗余数据,降低 Store 体积
  • 更高效的选择和查找

工具:Redux 推荐 normalizr 库,RTK Query 原生支持规范化的缓存。

7.4 状态持久化

// Zustand persist 中间件
import { persist } from 'zustand/middleware'

const useStore = create(
  persist(
    (set) => ({
      count: 0,
      increment: () => set(s => ({ count: s.count + 1 })),
    }),
    {
      name: 'app-storage',      // localStorage key
      partialize: (state) => ({ count: state.count }),  // 只持久化部分字段
      merge: (persisted, current) => ({
        ...current,
        ...(persisted as Partial<typeof current>),
        // 版本迁移
      }),
      onRehydrateStorage: () => (state) => {
        // 恢复完成后的回调
      }
    }
  )
)

持久化的注意事项

  • 只持久化必要的状态(排除计算属性、临时 UI 状态)
  • 处理持久化后的版本兼容(版本迁移策略)
  • 考虑敏感数据(token)的安全存储
  • 大对象考虑压缩或 IndexedDB

7.5 乐观更新(Optimistic Update)

思想:用户操作后立即更新 UI,不等待服务端响应。

// 乐观更新的通用模式
async function toggleLike(postId: string) {
  // 1. 保存原始状态(用于回滚)
  const previousPosts = store.getState().posts

  // 2. 乐观更新 UI
  store.setState(s => ({
    posts: s.posts.map(p =>
      p.id === postId
        ? { ...p, liked: !p.liked, likes: p.liked ? p.likes - 1 : p.likes + 1 }
        : p
    )
  }))

  try {
    // 3. 发送实际请求
    await api.toggleLike(postId)
  } catch {
    // 4. 失败时回滚
    store.setState({ posts: previousPosts })
  }
}

适用场景:点赞、收藏、发送消息等高频且大概率成功的操作。

不适用:支付、订单提交等需要服务端确认的关键操作。

7.6 订阅选择器性能优化

组件 A: 使用整个 store
        每次变化重新渲染(最差)

组件 B: 使用 selector 选择部分状态
        s => s.user.name
        仅 name 变化渲染(好)

组件 C: 返回新对象的 selector + shallow 比较
        s => ({ a: s.a, b: s.b })
        shallow 比较顶层属性(较好)

组件 D: 返回新对象的 selector(无 shallow)
        s => ({ a: s.a, b: s.b })
        每次生成新对象 → 每次都渲染(差)

结论

  • selector 尽量返回基础类型
  • 返回对象时配合 shallowuseShallow
  • 避免 selector 中创建新数组/对象(用 createSelector 缓存)