React 基础体系 · 第 46/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

Redux Toolkit 深入:Slice、Thunk、Listener、RTK Query 和测试

Redux Toolkit(RTK)是 Redux 官方维护的工具集。它并不是另一套独立的状态管理模型,而是在 Redux 的单向数据流、Reducer 和 Middleware 之上,提供了更安全的默认配置、更少的样板代码,以及面向异步数据请求的 RTK Query。

本文使用 TypeScript 说明一个完整的 Redux Toolkit 应用结构,重点覆盖:

  • Store、Reducer、Action、Middleware 的关系
  • createSlice 如何组织同步状态变化
  • createAsyncThunk 如何表达异步流程、错误和取消
  • Listener Middleware 如何响应 Action 或状态变化
  • RTK Query 如何管理服务端数据、缓存、失效和请求生命周期
  • Reducer、Thunk、Listener、RTK Query 和 React 组件如何测试
  • 客户端、服务端渲染和 React Server Components 之间的边界

示例以现代 TypeScript、React 19 和当前主流 Redux Toolkit API 为基础。具体项目还需要根据使用的构建工具、路由框架和 SSR 方案调整入口代码。


一、先建立 Redux Toolkit 的运行模型

Redux 的核心状态转移可以写成:

Sn+1=R(Sn,An)S_{n+1} = R(S_n, A_n)

其中:

  • SnS_n 是第 nn 次更新前的状态;
  • AnA_n 是一个 Action,至少包含字符串类型字段 type
  • RR 是 Reducer;
  • Sn+1S_{n+1} 是更新后的状态。

Redux 的基本约束是:Reducer 应该是确定性的纯函数。对于同一个状态和同一个 Action,应得到相同的下一个状态,并且不应该在 Reducer 内执行网络请求、读取当前时间、修改外部变量或写入 localStorage

完整的数据流如下:

flowchart LR
    UI[React 组件] -->|dispatch(action)| D[Store.dispatch]
    D --> MW[Middleware 链]
    MW --> R[Root Reducer]
    R --> S[新 State]
    S --> SEL[Selector]
    SEL --> UI
    MW --> SIDE[异步请求/日志/副作用]
    SIDE -->|dispatch 成功或失败 Action| D

configureStore 创建的 Store 至少包含以下部分:

  1. State:应用状态;
  2. Reducer:根据 Action 计算下一个 State;
  3. Dispatch:提交 Action;
  4. Middleware:在 Action 到达 Reducer 前后执行额外逻辑;
  5. Enhancer:扩展 Store 行为,通常由 Redux Toolkit 自动配置。

Redux Toolkit 的核心工具与这些概念的对应关系如下:

工具 主要职责
createSlice 定义一组状态、同步 Reducer 和 Action Creator
createAsyncThunk 把一个异步函数转换成 pending/fulfilled/rejected Action 流程
createListenerMiddleware 监听 Action 或状态变化,执行副作用
createApi 管理服务端数据请求、缓存、标签失效和请求状态
configureStore 创建带有安全默认 Middleware 的 Store

一个重要区分是:

  • Slice 通常描述客户端应用状态以及它的同步状态变化;
  • Thunk 描述一次异步工作流;
  • Listener 描述“发生某个 Action 后,还要做什么”;
  • RTK Query 描述服务端数据的请求、缓存和同步;
  • Reducer 本身不负责副作用。

二、配置类型安全的 Store

先创建一个最小的 Store。实际项目中可以按领域拆分多个 Slice。

// src/app/store.ts
import { configureStore } from '@reduxjs/toolkit'
import cartReducer from '../features/cart/cartSlice'
import { api } from '../services/api'
import { listenerMiddleware } from './listener'

export const store = configureStore({
  reducer: {
    cart: cartReducer,
    [api.reducerPath]: api.reducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware()
      .concat(api.middleware)
      .concat(listenerMiddleware.middleware),
})

export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch

api.reducerPath 默认是 "api",但不应该手写成固定字符串,因为 createApi 可以配置其他路径。使用计算属性可以保证 Reducer 键名与 API 实例一致。

api.middleware 必须加入 Store,否则 RTK Query 的缓存生命周期、轮询、失效和请求状态管理不会完整工作。

接着定义类型安全的 React Hooks:

// src/app/hooks.ts
import { useDispatch, useSelector } from 'react-redux'
import type { AppDispatch, RootState } from './store'

export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()

withTypes 是现代 React Redux 提供的类型辅助方法。旧版本也可以使用泛型包装:

import { useDispatch, useSelector } from 'react-redux'
import type { TypedUseSelectorHook } from 'react-redux'
import type { AppDispatch, RootState } from './store'

export const useAppDispatch = () => useDispatch<AppDispatch>()
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector

组件只应使用 useAppDispatchuseAppSelector,而不是在每个文件中重新写类型。

为什么不能随意替换默认 Middleware

configureStore 默认加入了多种开发期检查,尤其是:

  • serializableCheck:检查 Action 和 State 是否包含不可序列化值;
  • immutableCheck:开发期检查是否直接修改了 State;
  • thunk Middleware:让 dispatch 能够接收函数形式的 Thunk。

以下值通常不适合放进 Redux State 或 Action:

  • Promise
  • MapSet
  • Date
  • DOM 节点
  • 类实例
  • 函数
  • AbortController

这不是说这些值在任何情况下都绝对不能使用,而是说 Redux 的时间旅行、持久化、调试和可重放能力依赖可序列化数据。若确实有第三方库 Action 携带不可序列化对象,应精确配置忽略路径,而不是整体关闭检查。


三、Slice:把状态、同步 Reducer 和 Action 组织在一起

3.1 Slice 的结构

createSlice 接收:

  • name:Action 类型前缀;
  • initialState:初始状态;
  • reducers:同步 Reducer;
  • extraReducers:响应其他 Slice、Thunk 或 RTK Query Action。

示例定义一个购物车 Slice:

// src/features/cart/cartSlice.ts
import {
  createSelector,
  createSlice,
  type PayloadAction,
} from '@reduxjs/toolkit'
import type { RootState } from '../../app/store'

export type CartItem = {
  id: string
  name: string
  price: number
  quantity: number
}

type CartState = {
  items: CartItem[]
}

const initialState: CartState = {
  items: [],
}

const cartSlice = createSlice({
  name: 'cart',
  initialState,
  reducers: {
    itemAdded: (
      state,
      action: PayloadAction<Omit<CartItem, 'quantity'>>,
    ) => {
      const existing = state.items.find(
        (item) => item.id === action.payload.id,
      )

      if (existing) {
        existing.quantity += 1
      } else {
        state.items.push({
          ...action.payload,
          quantity: 1,
        })
      }
    },

    itemQuantityChanged: (
      state,
      action: PayloadAction<{ id: string; quantity: number }>,
    ) => {
      const item = state.items.find((item) => item.id === action.payload.id)

      if (!item) {
        return
      }

      if (action.payload.quantity <= 0) {
        state.items = state.items.filter((candidate) => candidate.id !== item.id)
      } else {
        item.quantity = action.payload.quantity
      }
    },

    itemRemoved: (state, action: PayloadAction<string>) => {
      state.items = state.items.filter((item) => item.id !== action.payload)
    },

    cartCleared: (state) => {
      state.items = []
    },
  },
})

export const {
  itemAdded,
  itemQuantityChanged,
  itemRemoved,
  cartCleared,
} = cartSlice.actions

export default cartSlice.reducer

const selectCartState = (state: RootState) => state.cart

export const selectCartItems = createSelector(
  [selectCartState],
  (cart) => cart.items,
)

export const selectCartTotal = createSelector(
  [selectCartItems],
  (items) =>
    items.reduce((total, item) => total + item.price * item.quantity, 0),
)

调用:

dispatch(
  itemAdded({
    id: 'keyboard',
    name: 'Keyboard',
    price: 299,
  }),
)

生成的 Action 近似为:

{
  type: 'cart/itemAdded',
  payload: {
    id: 'keyboard',
    name: 'Keyboard',
    price: 299
  }
}

3.2 为什么 Reducer 中可以“直接修改”状态

示例中写了:

existing.quantity += 1
state.items.push(...)

这看起来违反了 Redux 的不可变更新规则,但 createSlice 内部使用 Immer。Reducer 实际接收的是一个 Draft,Immer 会记录这些写操作,然后生成新的不可变 State。

可以把过程理解为:

原始 State
  ↓
Immer 创建可追踪的 Draft
  ↓
Reducer 修改 Draft
  ↓
Immer 生成新的不可变 State

因此,“直接修改”只在 createSlicecreateReducer 管理的 Reducer 内成立。以下代码仍然是错误的:

const addItem = (items: CartItem[], item: CartItem) => {
  items.push(item)
  return items
}

如果它在普通函数中接收真实 State,就会直接改变原对象。

另一个常见错误是同时修改 Draft 并返回另一个值:

badReducer: (state) => {
  state.items = []
  return { items: [{ id: 'unexpected' }] }
}

Immer 无法明确判断应该采用哪一种结果。一个 Reducer 应选择以下两种写法之一:

clearCart: (state) => {
  state.items = []
}

或者:

replaceCart: (_state, action: PayloadAction<CartState>) => {
  return action.payload
}

3.3 Selector 为什么不应直接散落在组件中

如果组件直接写:

const items = useAppSelector((state) => state.cart.items)

代码可以运行,但当 State 结构变化、需要派生数据或需要记忆化时,组件会承担过多细节。

Selector 的职责是从 State 读取数据,派生 Selector 则负责计算:

export const selectCartTotal = createSelector(
  [selectCartItems],
  (items) => items.reduce((sum, item) => sum + item.price * item.quantity, 0),
)

createSelector 只有在输入 Selector 的结果引用没有变化时,才会复用上一次结果。它不是自动解决所有性能问题的工具:

  • Reducer 每次都创建新的 items 数组,派生结果会重新计算;
  • 如果输入 Selector 每次返回新数组,即使内容相同,也会导致重新计算;
  • Selector 应保持纯函数,不应在其中请求网络或修改 State。

四、Thunk:把一次异步流程表示为多个 Action

4.1 Thunk 的本质

Thunk 是一个函数形式的 Action:

(dispatch, getState) => {
  // 异步工作
}

普通 Reducer 只能处理对象 Action,而 Thunk Middleware 会先识别函数,再调用它。

createAsyncThunk 把一次异步操作标准化为三个阶段:

pending
   ↓
fulfilled 或 rejected

例如:

orders/submit/pending
orders/submit/fulfilled
orders/submit/rejected

Thunk 本身不直接修改 State。它通过派发这些 Action,让 Slice 的 extraReducers 更新状态。

4.2 一个带业务错误的 Thunk

假设服务端接口为:

POST /api/orders
请求体:{ items: CartItem[] }
成功:{ id: string; status: "created" }
失败:{ message: string; code: string }

定义类型和请求函数:

// src/features/orders/orderTypes.ts
import type { CartItem } from '../cart/cartSlice'

export type Order = {
  id: string
  status: 'created'
}

export type ApiError = {
  code: string
  message: string
}

export type SubmitOrderPayload = {
  items: CartItem[]
}

export async function postOrder(
  payload: SubmitOrderPayload,
  signal?: AbortSignal,
): Promise<Order> {
  const response = await fetch('/api/orders', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(payload),
    signal,
  })

  const body: unknown = await response.json()

  if (!response.ok) {
    const error = body as Partial<ApiError>

    throw {
      code: error.code ?? 'UNKNOWN_ERROR',
      message: error.message ?? '请求失败',
    } satisfies ApiError
  }

  return body as Order
}

这里把 signal 传给 fetch,是为了让 Thunk 取消时可以中止底层请求。

定义 Thunk:

// src/features/orders/orderSlice.ts
import {
  createAsyncThunk,
  createSlice,
  type PayloadAction,
} from '@reduxjs/toolkit'
import type { RootState } from '../../app/store'
import type { ApiError, Order, SubmitOrderPayload } from './orderTypes'
import { postOrder } from './orderTypes'

type OrderState = {
  current: Order | null
  status: 'idle' | 'pending' | 'succeeded' | 'failed'
  error: ApiError | null
}

const initialState: OrderState = {
  current: null,
  status: 'idle',
  error: null,
}

export const submitOrder = createAsyncThunk<
  Order,
  SubmitOrderPayload,
  {
    state: RootState
    rejectValue: ApiError
  }
>(
  'orders/submit',
  async (payload, thunkApi) => {
    try {
      return await postOrder(payload, thunkApi.signal)
    } catch (error) {
      if (isApiError(error)) {
        return thunkApi.rejectWithValue(error)
      }

      throw error
    }
  },
  {
    condition: (_payload, { getState }) => {
      const state = getState()
      return state.orders.status !== 'pending'
    },
  },
)

function isApiError(value: unknown): value is ApiError {
  if (!value || typeof value !== 'object') {
    return false
  }

  const candidate = value as Partial<ApiError>
  return (
    typeof candidate.code === 'string' &&
    typeof candidate.message === 'string'
  )
}

const orderSlice = createSlice({
  name: 'orders',
  initialState,
  reducers: {
    orderReset: () => initialState,
  },
  extraReducers: (builder) => {
    builder
      .addCase(submitOrder.pending, (state) => {
        state.status = 'pending'
        state.error = null
      })
      .addCase(
        submitOrder.fulfilled,
        (state, action: PayloadAction<Order>) => {
          state.status = 'succeeded'
          state.current = action.payload
        },
      )
      .addCase(submitOrder.rejected, (state, action) => {
        state.status = 'failed'

        if (action.payload) {
          state.error = action.payload
        } else {
          state.error = {
            code: 'UNEXPECTED_ERROR',
            message: action.error.message ?? '未知错误',
          }
        }
      })
  },
})

export const { orderReset } = orderSlice.actions
export default orderSlice.reducer

Store 还需要注册:

import ordersReducer from '../features/orders/orderSlice'

export const store = configureStore({
  reducer: {
    cart: cartReducer,
    orders: ordersReducer,
    [api.reducerPath]: api.reducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware()
      .concat(api.middleware)
      .concat(listenerMiddleware.middleware),
})

4.3 rejectWithValue 与异常抛出的区别

Thunk 中有两种不同的失败:

return thunkApi.rejectWithValue(apiError)

表示“请求失败,但这是应用预期的业务错误”。此时:

action.payload

中会保留 ApiError

而:

throw error

表示未预期异常,例如解析错误、代码错误或网络层异常。此时通常只有:

action.error

可用,且内容经过序列化处理,不应期待它保留任意自定义对象。

因此组件或上层流程应区分:

const resultAction = await dispatch(
  submitOrder({
    items,
  }),
)

if (submitOrder.fulfilled.match(resultAction)) {
  console.log('订单创建成功', resultAction.payload)
}

if (submitOrder.rejected.match(resultAction)) {
  console.error('订单创建失败', resultAction.payload ?? resultAction.error)
}

更常见的写法是使用 unwrap()

try {
  const order = await dispatch(
    submitOrder({ items }),
  ).unwrap()

  console.log(order.id)
} catch (error) {
  console.error('提交失败', error)
}

unwrap() 的行为是:

  • fulfilled:返回实际 Payload;
  • rejected with rejectWithValue:抛出 action.payload
  • rejected with普通异常:抛出序列化后的错误信息。

这使得组件可以使用普通的 try/catch,而不必手动检查 Action 类型。

4.4 condition、取消和竞态

示例中的 condition 在 Thunk 真正派发 pending 前执行:

condition: (_payload, { getState }) => {
  return getState().orders.status !== 'pending'
}

当条件返回 false 时,这次执行会被跳过。它适合防止重复提交,但不是通用的请求去重机制,因为两个调用之间仍然可能存在复杂竞态,尤其是状态读取和外部请求已经开始时。

主动取消:

const promise = dispatch(submitOrder({ items }))

promise.abort()

try {
  await promise.unwrap()
} catch (error) {
  console.log('请求被取消或失败', error)
}

取消链路是:

promise.abort()
  ↓
Thunk 的 signal.aborted = true
  ↓
fetch 收到 AbortSignal
  ↓
底层请求被中止
  ↓
Thunk 派发 rejected

如果请求函数没有把 signal 传给 fetch 或其他支持取消的客户端,那么 Thunk 状态会变成取消,但底层网络请求可能仍然继续。

Thunk 还可能遇到响应顺序问题:

请求 A 开始
请求 B 开始
请求 B 返回,写入较新数据
请求 A 返回,覆盖了 B 的数据

condition 只能阻止部分请求,不能自动解决所有竞态。需要时应引入请求 ID、版本号,或使用 RTK Query 的缓存和请求管理能力。


五、Listener Middleware:对状态变化执行副作用

5.1 Listener 与 Thunk 的区别

Thunk 通常由调用方显式启动:

dispatch(submitOrder(payload))

Listener 则是预先注册规则:

当某个 Action 发生时,执行一个 effect

它们的适用模型不同:

  • “点击提交按钮,然后请求订单”适合 Thunk 或 RTK Query mutation;
  • “只要购物车变化,就保存到本地”适合 Listener;
  • “登录成功后记录日志、刷新用户资料、跳转”可以用 Listener;
  • “处理服务端数据缓存”应优先使用 RTK Query,而不是自己监听请求 Action。

5.2 注册 Listener

先创建实例:

// src/app/listener.ts
import {
  createListenerMiddleware,
  isAnyOf,
} from '@reduxjs/toolkit'
import {
  cartCleared,
  itemAdded,
  itemQuantityChanged,
  itemRemoved,
} from '../features/cart/cartSlice'

export const listenerMiddleware = createListenerMiddleware()

export const registerListeners = () => {
  listenerMiddleware.startListening({
    matcher: isAnyOf(
      itemAdded,
      itemQuantityChanged,
      itemRemoved,
      cartCleared,
    ),
    effect: async (_action, listenerApi) => {
      if (typeof window === 'undefined') {
        return
      }

      const state = listenerApi.getState() as {
        cart: { items: unknown[] }
      }

      window.localStorage.setItem(
        'cart',
        JSON.stringify(state.cart.items),
      )
    },
  })
}

在客户端入口调用一次:

// src/main.tsx
import { registerListeners } from './app/listener'
import { store } from './app/store'

registerListeners()

// 将 store 传给 <Provider store={store}> ...

registerListeners 必须只调用一次。如果在 React 组件每次渲染时调用,会重复注册监听器,导致一个 Action 触发多次副作用。

更严格的类型写法可以在 Store 类型确定后创建类型化的 startListening

const startAppListening =
  listenerMiddleware.startListening.withTypes<RootState, AppDispatch>()

项目应确保所使用的 Redux Toolkit 版本支持该类型辅助 API;如果版本较旧,可以使用类型断言或手动定义 Listener 类型。

5.3 Action 匹配与状态比较

Listener 支持多种触发条件:

listenerMiddleware.startListening({
  actionCreator: itemAdded,
  effect: async (action, listenerApi) => {
    console.log(action.payload.id)
  },
})

也可以使用 predicate

listenerMiddleware.startListening({
  predicate: (_action, currentState, previousState) => {
    return currentState.cart.items.length !== previousState.cart.items.length
  },
  effect: async (_action, listenerApi) => {
    // 购物车商品数量变化后的副作用
  },
})

predicate 接收:

  • 当前 Action;
  • 当前 State;
  • 更新前的 State。

因此它可以表达“某个字段从未登录变成已登录”这类状态转换,而不仅是匹配某个 Action 类型。

5.4 取消和竞态

Listener 的 effect 可以异步执行,但多个 Action 可能产生多个并发 effect:

itemAdded A → effect A 开始
itemAdded B → effect B 开始
effect B 先结束
effect A 后结束

如果副作用是搜索、自动保存或统计上报,旧任务可能不应该覆盖新任务。可以使用取消相关 API 设计“只保留最新任务”的行为:

listenerMiddleware.startListening({
  predicate: (action) => action.type === 'search/queryChanged',
  effect: async (_action, listenerApi) => {
    listenerApi.cancelActiveListeners()

    await listenerApi.delay(300)

    // 300ms 内再次触发同一个监听器时,
    // 旧 effect 会被取消,新的 effect 继续执行。
    // 此处可以 dispatch 搜索请求。
  },
})

这里的关键是:

  • cancelActiveListeners() 只影响当前监听规则已经启动的其他实例;
  • delay 等可取消流程会响应取消;
  • 如果你调用的外部库不支持取消,Listener 逻辑可能停止继续执行,但外部操作本身未必被中止。

Listener 不等于全功能工作流引擎。对于复杂的长生命周期任务、持久连接或需要严格事务语义的流程,应单独设计任务模型。


六、RTK Query:管理服务端数据,而不是普通客户端状态

6.1 服务端状态与客户端状态

客户端状态通常具有以下特征:

  • 当前打开的弹窗;
  • 表单输入;
  • 购物车草稿;
  • 当前筛选条件;
  • UI 是否展开。

服务端状态通常具有不同特征:

  • 数据由服务器拥有;
  • 多个客户端可能同时修改;
  • 数据需要请求、缓存、重新验证;
  • 需要处理加载、错误、失效和重复请求。

RTK Query 专门解决第二类问题。它把请求结果放入一个规范化的缓存区域,并根据 endpoint、参数和标签管理生命周期。

它不是简单的:

useEffect(() => {
  fetch('/api/posts').then(...)
}, [])

因为 RTK Query 还要处理:

  • 请求状态;
  • 相同参数的缓存键;
  • 多个组件共享请求;
  • 缓存订阅;
  • 标签失效;
  • mutation 后重新获取查询;
  • polling 和重新连接;
  • 请求错误。

6.2 定义 API Slice

// src/services/api.ts
import {
  createApi,
  fetchBaseQuery,
} from '@reduxjs/toolkit/query/react'

export type Post = {
  id: string
  title: string
  body: string
}

export type NewPost = {
  title: string
  body: string
}

export const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({
    baseUrl: '/api',
  }),
  tagTypes: ['Post'],
  endpoints: (builder) => ({
    getPosts: builder.query<Post[], void>({
      query: () => '/posts',
      providesTags: (result) =>
        result
          ? [
              ...result.map(({ id }) => ({
                type: 'Post' as const,
                id,
              })),
              { type: 'Post' as const, id: 'LIST' },
            ]
          : [{ type: 'Post' as const, id: 'LIST' }],
    }),

    getPost: builder.query<Post, string>({
      query: (id) => `/posts/${id}`,
      providesTags: (_result, _error, id) => [
        { type: 'Post', id },
      ],
    }),

    addPost: builder.mutation<Post, NewPost>({
      query: (body) => ({
        url: '/posts',
        method: 'POST',
        body,
      }),
      invalidatesTags: [{ type: 'Post', id: 'LIST' }],
    }),

    updatePost: builder.mutation<Post, Post>({
      query: ({ id, ...body }) => ({
        url: `/posts/${id}`,
        method: 'PUT',
        body,
      }),
      invalidatesTags: (_result, _error, post) => [
        { type: 'Post', id: post.id },
        { type: 'Post', id: 'LIST' },
      ],
    }),
  }),
})

export const {
  useGetPostsQuery,
  useGetPostQuery,
  useAddPostMutation,
  useUpdatePostMutation,
} = api

createApi 通常应在应用中按数据域创建有限数量的 API 实例。每个 API 实例都会增加一组 Middleware 逻辑;把每个组件都定义成一个独立 API 实例,会使缓存失效和 Middleware 管理变得复杂。

6.3 Query 的生命周期

组件调用:

import { useGetPostsQuery } from '../../services/api'

export function PostList() {
  const {
    data: posts = [],
    isLoading,
    isFetching,
    isError,
    error,
    refetch,
  } = useGetPostsQuery()

  if (isLoading) {
    return <p>首次加载中……</p>
  }

  if (isError) {
    return (
      <div>
        <p>加载失败:{JSON.stringify(error)}</p>
        <button onClick={() => refetch()}>重试</button>
      </div>
    )
  }

  return (
    <section aria-busy={isFetching}>
      {posts.map((post) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.body}</p>
        </article>
      ))}
    </section>
  )
}

需要区分:

  • isLoading:当前没有缓存数据,正在进行首次加载;
  • isFetching:正在请求,可能已经有旧数据;
  • isError:本次查询失败;
  • data:当前查询结果,可能是旧缓存或新响应。

因此,当已有旧数据并重新请求时,不应简单地用全屏 Loading 替换页面。更合适的表现通常是保留旧数据,同时用 isFetching 显示刷新状态。

查询缓存键近似由以下因素组成:

K=(endpointName,queryArgs)K = (\text{endpointName}, \text{queryArgs})

例如:

useGetPostQuery('p1')
useGetPostQuery('p1')

通常共享同一个缓存条目;而:

useGetPostQuery('p1')
useGetPostQuery('p2')

属于两个不同条目。

6.4 Mutation、标签和失效

Mutation 示例:

import { useState } from 'react'
import { useAddPostMutation } from '../../services/api'

export function AddPostForm() {
  const [title, setTitle] = useState('')
  const [body, setBody] = useState('')
  const [addPost, result] = useAddPostMutation()

  async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()

    try {
      await addPost({ title, body }).unwrap()
      setTitle('')
      setBody('')
    } catch (error) {
      console.error('创建文章失败', error)
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={title}
        onChange={(event) => setTitle(event.target.value)}
        placeholder="标题"
      />
      <textarea
        value={body}
        onChange={(event) => setBody(event.target.value)}
        placeholder="正文"
      />
      <button disabled={result.isLoading}>
        {result.isLoading ? '提交中……' : '创建'}
      </button>
      {result.isError && <p>提交失败</p>}
    </form>
  )
}

invalidatesTags 的逻辑不是“直接修改所有缓存数据”,而是:

addPost 成功
  ↓
标记 Post/LIST 失效
  ↓
正在订阅 getPosts 的组件重新请求
  ↓
列表得到服务器最新数据

这里有两个重要边界:

  1. 只有仍被组件订阅的查询,才会按缓存生命周期重新获取;
  2. 标签是缓存关联机制,不是数据库事务,也不能保证服务器端操作一定成功。

如果希望创建成功后立刻把新数据写入缓存,可以使用 onQueryStartedupdateQueryData

addPost: builder.mutation<Post, NewPost>({
  query: (body) => ({
    url: '/posts',
    method: 'POST',
    body,
  }),
  async onQueryStarted(arg, { dispatch, queryFulfilled }) {
    const patchResult = dispatch(
      api.util.updateQueryData('getPosts', undefined, (draft) => {
        draft.push({
          id: 'temporary-id',
          title: arg.title,
          body: arg.body,
        })
      }),
    )

    try {
      await queryFulfilled
    } catch {
      patchResult.undo()
    }
  },
  invalidatesTags: [{ type: 'Post', id: 'LIST' }],
})

这个例子展示了乐观更新的风险:临时 ID 与服务器真实 ID 不同,服务端响应可能改变排序或字段。如果数据关系复杂,直接失效并重新请求往往比手动维护缓存更稳妥。

6.5 setupListeners 与重新获取

要启用诸如重新获得窗口焦点、重新连接网络等行为,需要在浏览器入口调用:

import { setupListeners } from '@reduxjs/toolkit/query'
import { store } from './app/store'

setupListeners(store.dispatch)

然后查询可以配置:

const result = useGetPostsQuery(undefined, {
  refetchOnFocus: true,
  refetchOnReconnect: true,
})

setupListeners 依赖浏览器事件。在服务端环境不应无条件调用,至少要确保代码只在客户端执行。


七、RTK Query 与 React 19、路由和服务端边界

RTK Query 的 React Hooks,例如:

useGetPostsQuery()

需要运行在客户端 React 组件中。它们依赖 React Hooks 和浏览器端订阅机制,不能直接当作服务端组件中的普通函数调用。

在使用支持服务端渲染或 React Server Components 的框架时,需要明确区分:

服务端组件 / loader / server action
  ↓
服务端直接访问数据源或服务端 API
  ↓
把初始数据传给客户端边界

客户端组件
  ↓
使用 RTK Query Hook 管理后续查询、缓存和刷新

React Router 也提供 loader、action 和导航状态等路由级数据能力。以下情况可以优先使用路由数据 API:

  • 数据只服务于某个路由;
  • 数据生命周期与路由进入、退出严格绑定;
  • 需要在渲染前完成路由级加载;
  • 表单提交本身属于路由 action。

以下情况更适合 RTK Query:

  • 多个页面共享同一份服务端数据;
  • 需要跨组件缓存;
  • mutation 后需要按标签刷新多个查询;
  • 需要 polling、重新聚焦刷新或缓存订阅;
  • 数据并不只属于某一个 URL。

二者可以共存,但不要让同一个数据源同时被两个系统无规则地缓存。否则会出现:

Router loader 有一份数据
RTK Query 缓存有另一份数据
某一侧更新后,另一侧没有失效

在 SSR 中还要避免把 Store 创建成跨请求的全局单例:

// 服务端请求之间共享,这是危险的
export const store = configureStore(...)

如果服务端进程复用该对象,不同用户的状态可能互相污染。服务端通常应当:

每个 HTTP 请求创建一个新的 Store
  ↓
执行该请求需要的预取逻辑
  ↓
生成 HTML 和预加载状态
  ↓
客户端重新创建 Store 并进行 hydration

具体的脱水、注水和框架集成方式依赖所使用的 SSR 框架,不能用浏览器端的全局 Store 方案直接替代。


八、错误处理:不同错误必须走不同路径

8.1 Slice 和 Thunk 的错误

Thunk 常见的错误来源有三类:

  1. HTTP 状态码错误,例如 401、403、422、500;
  2. 网络错误,例如 DNS、断网或请求被取消;
  3. 客户端代码错误,例如 JSON 解析失败。

不要只把所有错误都写成:

state.error = '请求失败'

至少应保留可供 UI 和日志判断的结构:

type ApiError = {
  code: string
  message: string
  fieldErrors?: Record<string, string>
}

服务端返回的错误必须经过校验。as ApiError 只是 TypeScript 编译期断言,不会在运行时验证数据。

8.2 RTK Query 的错误

fetchBaseQuery 返回的错误通常是一个联合结构,可能包含:

  • status 为 HTTP 状态码;
  • status'FETCH_ERROR'
  • status'PARSING_ERROR'
  • status'TIMEOUT_ERROR'
  • status'CUSTOM_ERROR'

因此 UI 不应假定:

error.message

永远存在。可以先记录完整对象:

console.error(result.error)

再根据项目 API 规范编写类型守卫或统一的错误映射函数。

8.3 401 刷新 Token 的边界

如果 API 使用 Token,常见做法是包装 fetchBaseQuery

import {
  fetchBaseQuery,
  type BaseQueryFn,
  type FetchArgs,
  type FetchBaseQueryError,
} from '@reduxjs/toolkit/query'

const rawBaseQuery = fetchBaseQuery({
  baseUrl: '/api',
})

const baseQueryWithReauth: BaseQueryFn<
  string | FetchArgs,
  unknown,
  FetchBaseQueryError
> = async (args, api, extraOptions) => {
  let result = await rawBaseQuery(args, api, extraOptions)

  if (result.error?.status === 401) {
    const refreshResult = await rawBaseQuery(
      {
        url: '/auth/refresh',
        method: 'POST',
      },
      api,
      extraOptions,
    )

    if (refreshResult.data) {
      result = await rawBaseQuery(args, api, extraOptions)
    } else {
      api.dispatch({ type: 'auth/sessionExpired' })
    }
  }

  return result
}

这段逻辑仍有并发问题:

请求 A 返回 401
请求 B 返回 401
A 和 B 同时刷新 Token

生产环境可能需要一个共享的刷新锁或队列,保证同一时间只执行一次刷新。这个问题不是 fetchBaseQuery 自动解决的。

此外,刷新 Token 的凭据应放在符合安全模型的位置。把长期有效 Token 放入可被 XSS 读取的 localStorage 会扩大风险;HttpOnly Cookie、CSRF 防护和服务端会话通常需要结合具体架构设计。


九、测试 Redux Toolkit 应该测试什么

测试不应只验证“某个函数被调用”,而应验证状态转移和外部行为。

推荐分层:

  1. Reducer 单元测试:输入 State 和 Action,验证输出 State;
  2. Thunk 测试:用真实 Store 和模拟 HTTP 服务验证 pending、fulfilled、rejected;
  3. Listener 测试:dispatch 触发 Action,验证副作用或后续 Action;
  4. RTK Query 测试:使用真实 API Slice,拦截 HTTP 请求,验证 Hook 或 Store 结果;
  5. 组件测试:验证用户操作、加载状态、错误状态和最终 UI。

不要在所有测试中 mock @reduxjs/toolkit。这样虽然容易让测试通过,却可能完全绕过真正的 Reducer、Middleware 和缓存行为。

以下示例使用 Vitest 和 MSW。测试环境需要具备:

npm install -D vitest msw jsdom

具体项目还需要根据构建工具配置 vitestenvironment: 'jsdom'


十、Reducer 测试:验证纯状态转移

// src/features/cart/cartSlice.test.ts
import { describe, expect, it } from 'vitest'
import reducer, {
  cartCleared,
  itemAdded,
  itemQuantityChanged,
} from './cartSlice'

describe('cart reducer', () => {
  it('添加新商品时数量为 1', () => {
    const state = reducer(
      undefined,
      itemAdded({
        id: 'keyboard',
        name: 'Keyboard',
        price: 299,
      }),
    )

    expect(state.items).toEqual([
      {
        id: 'keyboard',
        name: 'Keyboard',
        price: 299,
        quantity: 1,
      },
    ])
  })

  it('添加已存在商品时只增加数量', () => {
    const previousState = {
      items: [
        {
          id: 'keyboard',
          name: 'Keyboard',
          price: 299,
          quantity: 1,
        },
      ],
    }

    const nextState = reducer(
      previousState,
      itemAdded({
        id: 'keyboard',
        name: 'Keyboard',
        price: 299,
      }),
    )

    expect(nextState.items[0].quantity).toBe(2)
    expect(nextState.items).not.toBe(previousState.items)
  })

  it('数量改为 0 时移除商品', () => {
    const state = reducer(
      {
        items: [
          {
            id: 'keyboard',
            name: 'Keyboard',
            price: 299,
            quantity: 1,
          },
        ],
      },
      itemQuantityChanged({
        id: 'keyboard',
        quantity: 0,
      }),
    )

    expect(state.items).toEqual([])
  })

  it('清空购物车', () => {
    const state = reducer(
      {
        items: [
          {
            id: 'keyboard',
            name: 'Keyboard',
            price: 299,
            quantity: 1,
          },
        ],
      },
      cartCleared(),
    )

    expect(state.items).toEqual([])
  })
})

这类测试不需要 React,也不需要真实 Store。它验证的是最稳定的部分:给定输入后,Reducer 是否产生正确状态。

重点测试边界:

  • 初始状态;
  • 不存在的商品 ID;
  • 数量为 0;
  • 负数数量;
  • 同一商品重复添加;
  • Reducer 是否错误地修改了旧对象。

十一、Thunk 测试:使用真实 Store 和 MSW

11.1 设置 MSW

// tests/server.ts
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'

export const server = setupServer(
  http.post('/api/orders', async () => {
    return HttpResponse.json({
      id: 'order-1',
      status: 'created',
    })
  }),
)
// tests/setup.ts
import { afterAll, afterEach, beforeAll } from 'vitest'
import { server } from './server'

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

onUnhandledRequest: 'error' 很重要。它能防止测试悄悄发出未定义的请求,避免测试因为错误的 URL、方法或参数而失去意义。

11.2 创建测试 Store

测试 Store 应使用和生产 Store 相同的 Reducer、Thunk Middleware、Listener Middleware 和 RTK Query Middleware。可以封装一个工厂函数:

// tests/testStore.ts
import { configureStore } from '@reduxjs/toolkit'
import cartReducer from '../src/features/cart/cartSlice'
import ordersReducer from '../src/features/orders/orderSlice'
import { api } from '../src/services/api'

export function createTestStore() {
  return configureStore({
    reducer: {
      cart: cartReducer,
      orders: ordersReducer,
      [api.reducerPath]: api.reducer,
    },
    middleware: (getDefaultMiddleware) =>
      getDefaultMiddleware().concat(api.middleware),
  })
}

测试 Thunk:

// src/features/orders/orderSlice.test.ts
import { describe, expect, it } from 'vitest'
import { submitOrder } from './orderSlice'
import { createTestStore } from '../../../tests/testStore'

describe('submitOrder thunk', () => {
  it('请求成功时保存订单', async () => {
    const store = createTestStore()

    const result = await store.dispatch(
      submitOrder({
        items: [
          {
            id: 'keyboard',
            name: 'Keyboard',
            price: 299,
            quantity: 1,
          },
        ],
      }),
    )

    expect(submitOrder.fulfilled.match(result)).toBe(true)
    expect(store.getState().orders).toMatchObject({
      status: 'succeeded',
      current: {
        id: 'order-1',
        status: 'created',
      },
      error: null,
    })
  })
})

测试失败路径:

import { http, HttpResponse } from 'msw'
import { server } from '../../../tests/server'

it('业务错误时保留 rejectWithValue 的 payload', async () => {
  server.use(
    http.post('/api/orders', () => {
      return HttpResponse.json(
        {
          code: 'OUT_OF_STOCK',
          message: '库存不足',
        },
        { status: 409 },
      )
    }),
  )

  const store = createTestStore()

  const result = await store.dispatch(
    submitOrder({
      items: [],
    }),
  )

  expect(submitOrder.rejected.match(result)).toBe(true)
  expect(result.payload).toEqual({
    code: 'OUT_OF_STOCK',
    message: '库存不足',
  })
  expect(store.getState().orders.status).toBe('failed')
})

这个测试证明了两个事实:

  1. HTTP 失败不会自动成为 rejectWithValue 的 Payload;
  2. 只有 Thunk 代码显式调用 rejectWithValue,业务错误才会出现在 result.payload

十二、Listener 测试:验证 Action 到副作用的连接

Listener 的测试重点不是测试 Redux Toolkit 内部实现,而是验证:

指定 Action
  ↓
Listener 被触发
  ↓
副作用发生

为了便于测试,可以把副作用抽成函数:

// src/features/cart/persistCart.ts
export function persistCart(items: unknown[]) {
  if (typeof window !== 'undefined') {
    window.localStorage.setItem('cart', JSON.stringify(items))
  }
}

Listener:

// src/app/listener.ts
import {
  createListenerMiddleware,
  isAnyOf,
} from '@reduxjs/toolkit'
import {
  cartCleared,
  itemAdded,
  itemQuantityChanged,
  itemRemoved,
} from '../features/cart/cartSlice'
import { persistCart } from '../features/cart/persistCart'

export const listenerMiddleware = createListenerMiddleware()

listenerMiddleware.startListening({
  matcher: isAnyOf(
    itemAdded,
    itemQuantityChanged,
    itemRemoved,
    cartCleared,
  ),
  effect: async (_action, listenerApi) => {
    const state = listenerApi.getState() as {
      cart: { items: unknown[] }
    }

    persistCart(state.cart.items)
  },
})

测试:

// src/app/listener.test.ts
import { describe, expect, it, vi } from 'vitest'
import { configureStore } from '@reduxjs/toolkit'
import cartReducer, {
  itemAdded,
} from '../features/cart/cartSlice'
import { listenerMiddleware } from './listener'

describe('cart listener', () => {
  it('购物车变化后持久化当前商品', () => {
    const store = configureStore({
      reducer: {
        cart: cartReducer,
      },
      middleware: (getDefaultMiddleware) =>
        getDefaultMiddleware().prepend(listenerMiddleware.middleware),
    })

    const setItem = vi.spyOn(Storage.prototype, 'setItem')

    store.dispatch(
      itemAdded({
        id: 'keyboard',
        name: 'Keyboard',
        price: 299,
      }),
    )

    expect(setItem).toHaveBeenCalledWith(
      'cart',
      JSON.stringify([
        {
          id: 'keyboard',
          name: 'Keyboard',
          price: 299,
          quantity: 1,
        },
      ]),
    )

    setItem.mockRestore()
  })
})

如果测试中重用同一个全局 Listener 实例,应注意注册次数和测试之间的状态污染。更稳妥的做法是让 Listener Middleware 也由工厂函数创建,或者在测试环境中保证每个测试模块只注册一次。


十三、RTK Query 测试:验证缓存与请求行为

RTK Query 测试应尽量使用真实的 createApi 实例,而不是 mock Hook。

MSW Handler:

// tests/server.ts
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'

export const server = setupServer(
  http.get('/api/posts', () => {
    return HttpResponse.json([
      {
        id: 'p1',
        title: '第一篇文章',
        body: '正文',
      },
    ])
  }),
)

如果测试 Store:

import { describe, expect, it } from 'vitest'
import { api } from '../services/api'
import { createTestStore } from '../../tests/testStore'

describe('RTK Query', () => {
  it('查询成功后写入 API cache', async () => {
    const store = createTestStore()

    const result = await store.dispatch(
      api.endpoints.getPosts.initiate(),
    )

    expect(result.data).toEqual([
      {
        id: 'p1',
        title: '第一篇文章',
        body: '正文',
      },
    ])

    const state = store.getState()
    const queryState =
      state[api.reducerPath].queries['getPosts(undefined)']

    expect(queryState?.status).toBe('fulfilled')

    result.unsubscribe()
  })
})

这里的 result.unsubscribe() 用于释放该测试订阅。测试 RTK Query 时如果不清理订阅,可能出现:

  • Jest 或 Vitest 进程无法退出;
  • 后台重新获取仍然运行;
  • 测试之间共享缓存;
  • 定时器没有清理。

也可以测试标签失效:

it('mutation 成功后使列表查询失效', async () => {
  const store = createTestStore()

  const query = store.dispatch(
    api.endpoints.getPosts.initiate(),
  )

  await query.unwrap()

  const mutation = store.dispatch(
    api.endpoints.addPost.initiate({
      title: '新文章',
      body: '新正文',
    }),
  )

  await mutation.unwrap()

  query.unsubscribe()
})

这个测试至少证明了请求可以完成,但若要严格验证“失效后重新请求”,应在 MSW Handler 中计数,并等待重新请求完成。测试网络请求次数时要考虑 RTK Query 的缓存状态、订阅状态和请求调度时机,不能仅依靠同步断言。


十四、组件测试:从用户行为验证完整链路

组件测试不应只 mock:

useGetPostsQuery: () => ({ data: [...] })

因为这样无法发现以下问题:

  • Store 是否注册了 api.reducer
  • api.middleware 是否缺失;
  • URL 是否写错;
  • 查询参数是否错误;
  • 错误状态是否正确展示;
  • 组件是否在 isLoadingisFetching 间做了错误判断。

组件测试通常应提供真实 Provider:

import { Provider } from 'react-redux'
import { render } from '@testing-library/react'
import { createTestStore } from './testStore'

export function renderWithStore(ui: React.ReactNode) {
  const store = createTestStore()

  return {
    store,
    ...render(
      <Provider store={store}>
        {ui}
      </Provider>,
    ),
  }
}

然后使用 Testing Library 的用户行为 API:

const { findByText } = renderWithStore(<PostList />)

expect(await findByText('第一篇文章')).toBeInTheDocument()

这里使用异步查询是因为组件先经历:

初始渲染
  ↓
发起 GET /api/posts
  ↓
MSW 返回数据
  ↓
RTK Query 更新缓存
  ↓
组件重新渲染

如果直接使用同步的 getByText,测试可能在网络响应到达前就失败。


十五、常见误解与失败表现

15.1 把所有数据都放进一个 Slice

失败表现:

  • loadingerror、缓存条目和业务实体混在一起;
  • 同一个服务端数据在多个页面重复保存;
  • mutation 后需要手动更新大量 State;
  • 请求取消和重新获取逻辑散落在组件中。

改进方向是先区分:

UI / 客户端状态 → Slice
单次异步业务流程 → Thunk
服务端查询与缓存 → RTK Query
跨 Action 副作用 → Listener

这不是强制规则,但可以减少职责重叠。

15.2 在 Reducer 中发请求

错误示例:

const slice = createSlice({
  name: 'users',
  initialState,
  reducers: {
    userLoaded: (state) => {
      fetch('/api/user').then(...)
    },
  },
})

Reducer 可能被开发工具重放、测试直接调用或在不同环境执行。网络请求会使结果依赖外部时序,破坏 Reducer 的确定性。

请求应放入 Thunk、RTK Query、Listener 或路由层的数据加载机制。

15.3 误把 isLoading 当作所有 Loading

RTK Query 已经有缓存数据时,重新请求通常表现为:

isLoading === false
isFetching === true
data !== undefined

如果 UI 只判断 isLoading,用户可能看不到刷新状态;如果 UI 把 isFetching 当成首次加载并清空旧数据,页面会出现不必要的闪烁。

15.4 关闭所有序列化检查

错误处理方式:

getDefaultMiddleware({
  serializableCheck: false,
  immutableCheck: false,
})

这会隐藏真正的问题,例如:

  • Action 携带 ErrorFormData 或函数;
  • Reducer 意外直接修改对象;
  • 第三方库把不可序列化状态写入 Store。

如果某个已知路径确实必须忽略,应精确配置:

getDefaultMiddleware({
  serializableCheck: {
    ignoredActions: ['some/thirdPartyAction'],
    ignoredPaths: ['someSlice.someField'],
  },
})

忽略配置应与第三方库行为对应,并通过测试确认不会影响持久化和调试。

15.5 认为 RTK Query 会自动更新所有缓存

标签失效依赖三个条件:

  1. 查询声明了 providesTags
  2. mutation 声明了匹配的 invalidatesTags
  3. 目标查询仍在 Store 中并处于相关缓存生命周期。

如果标签类型或 ID 不匹配,mutation 可以成功,但列表不会重新获取。

例如:

getPost: providesTags: [{ type: 'Post', id: 'p1' }]
updatePost: invalidatesTags: [{ type: 'Article', id: 'p1' }]

这两个标签完全不同,不会互相失效。

15.6 在 React 组件中重复注册 Listener

错误示例:

function App() {
  listenerMiddleware.startListening(...)
  return <Page />
}

React 重新渲染后会再次注册,最终一个 Action 触发多个 effect。Listener 注册应放在应用初始化阶段,并控制初始化次数。


十六、如何诊断状态、异步和缓存问题

Store 中状态没有变化

依次检查:

  1. dispatch 的 Action 类型是否正确;
  2. Slice Reducer 是否注册到 configureStore.reducer
  3. Reducer 是否错误地同时修改并返回;
  4. 组件是否从正确的 State 路径读取;
  5. Selector 是否因为输入引用不变而复用了旧结果。

Thunk 没有发请求

检查:

  1. Thunk 是否被 dispatch
  2. condition 是否返回了 false
  3. 默认 Middleware 是否被替换而丢失 thunk 支持;
  4. 请求函数是否在进入 fetch 前抛出异常;
  5. 测试中的 MSW 是否拦截了正确的 URL 和 HTTP 方法。

RTK Query Hook 不工作

检查:

  1. 是否把 api.reducer 注册到 api.reducerPath
  2. 是否加入 api.middleware
  3. 是否在客户端 React 树中使用 Hook;
  4. baseUrl 是否符合当前浏览器和部署路径;
  5. 查询参数是否导致了另一个缓存键;
  6. 是否在测试结束时取消订阅。

Listener 被调用多次

检查:

  1. startListening 是否只执行一次;
  2. 是否重复创建 Store;
  3. matcher 是否同时匹配了多个监听规则;
  4. effect 是否因为 Action 链再次 dispatch 自己会匹配的 Action;
  5. 是否存在多个 Provider 使用不同 Store。

Redux DevTools 可以查看 Action 顺序和 State 差异。一个有效的诊断方法是先回答:

这个问题是 Action 没有发出,
还是 Reducer 没有响应,
还是 State 更新了但 Selector 没读到,
还是请求成功了但缓存没有失效?

不要直接从组件 JSX 开始猜测。


十七、生产取舍:Thunk、Listener 和 RTK Query 如何分工

可以用下面的判断顺序选择工具:

状态是否由服务器拥有

如果是,并且需要请求、缓存或重新验证,优先考虑 RTK Query。

文章列表、用户详情、商品详情、分页结果

是否是一段有明确开始和结束的异步流程

如果是,Thunk 适合表达:

提交订单
导出文件
上传并解析
批量操作

Thunk 可以读取当前 State,也可以通过 rejectWithValue 传递结构化业务错误。

是否是对既有 Action 的响应

如果需求是:

登录成功后记录审计日志
购物车变化后持久化
用户退出后清理某些缓存

Listener 通常比在多个组件中手动调用副作用更集中。

是否只是本地同步状态

如果只涉及:

弹窗是否打开
当前 Tab
表单草稿
购物车客户端草稿

Slice 就足够,不需要为它创建 Thunk 或 RTK Query endpoint。

最终可以得到这样的分层:

flowchart TB
    C[React 组件] -->|同步 UI Action| S[Slice]
    C -->|一次异步业务操作| T[Thunk]
    C -->|查询/新增/更新服务端数据| Q[RTK Query]
    S --> ST[Redux State]
    T --> ST
    Q --> CACHE[RTK Query Cache]
    ST --> L[Listener Middleware]
    L --> FX[持久化/日志/跨模块副作用]

这些工具不是互斥的。例如:

  • RTK Query mutation 成功后,Listener 可以记录审计事件;
  • Thunk 可以读取购物车 Slice,然后调用服务端;
  • Slice 可以响应 Thunk 的 pendingfulfilledrejected
  • Listener 可以 dispatch Slice Action 或 RTK Query 的工具 Action。

但应避免同一份服务端数据同时由 Thunk 手动存储、普通 Slice 维护、RTK Query 缓存和路由 loader 缓存,除非已经明确了同步策略。


十八、结语

Redux Toolkit 的核心价值不在于减少几行 Action Creator 代码,而在于把不同类型的工作放入相对明确的运行模型:

  • createSlice 负责确定性的同步状态转移;
  • Immer 让 Reducer 可以用接近可变数据的写法生成不可变结果;
  • createAsyncThunk 把异步流程拆成可观察、可测试的生命周期 Action;
  • Listener Middleware 根据 Action 或状态变化集中执行副作用;
  • RTK Query 管理服务端数据的请求、缓存、订阅、失效和重新获取;
  • 测试应从纯 Reducer 到真实 Middleware、网络拦截和组件行为逐层验证。

当出现状态不同步、请求重复、缓存未更新或副作用执行多次时,先沿着下面的链路定位:

用户行为
  → dispatch
  → Middleware
  → Reducer 或 RTK Query cache
  → Selector
  → React 重渲染

再结合客户端与服务端边界判断 Store 的生命周期,通常比在组件中继续添加条件分支更容易找到真正原因。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。