
1. 项目概述为什么“异步 Redux Action”不是个伪命题而是每个 React 开发者绕不开的实战门槛你刚写完一个 React 组件用useDispatch触发了一个 action想从后端拉用户列表——结果页面卡住、控制台报错 “A non-serializable value was detected in the state”或者更糟什么都没发生连请求都没发出去。你翻遍文档发现dispatch({ type: FETCH_USERS, payload: users })这种写法只适用于同步场景而真实业务里90% 的数据流都带着网络延迟、加载状态、错误重试、取消逻辑。这时候Redux 官方文档里那个轻描淡写的词——asynchronous actions异步 action——突然变得无比具体、无比刺眼。这不是概念游戏而是工程现实Redux 本身设计为纯函数式状态容器它要求所有 reducer 必须是纯函数不能包含副作用如 API 调用、定时器、localStorage 操作。但你的业务逻辑偏偏要等接口返回才能更新状态。于是问题就来了谁来负责发起请求谁来决定什么时候 dispatch 成功/失败 action谁来管理 loading 和 error 状态这些职责Redux 自身不承担必须由外部机制接管。而Redux Thunk就是这个被社区验证十年、至今仍是中大型 React 应用事实标准的解决方案。它不是魔法而是一套精巧的“责任转移协议”把副作用从 reducer 中剥离交由一个可执行的函数即 thunk来承载并让这个函数在合适的时机、以正确的顺序、携带必要的上下文去调用dispatch。你可能已经听过“Thunk 是 middleware”但真正卡住人的从来不是定义而是它如何嵌入整个数据流闭环。比如为什么dispatch(fetchUsers())能跑通而dispatch({ type: FETCH_USERS_START })却不行为什么fetchUsers()返回的是函数而不是对象这个函数里的dispatch和getState是从哪来的它们和你在组件里用的useDispatch是同一个东西吗这些问题不靠调试器打断点、不靠手写三遍 demo根本没法建立肌肉记忆。本文不讲“什么是 middleware”而是直接带你拆开 Redux Thunk 的源码级实现还原它在 React 应用启动时如何被applyMiddleware注入 store再追踪一次dispatch(fetchUsers())调用从组件出发经过 Thunk middleware最终触发三次不同 actionSTART / SUCCESS / FAILURE的完整链路。你会看到所谓“异步 action”本质是把一个线性、不可中断的同步 dispatch 流程改造成一个可暂停、可分支、可携带上下文的函数执行流程。这正是它能支撑登录鉴权、文件上传进度、WebSocket 消息队列、甚至复杂表单多步骤提交的根本原因。2. 核心设计思路Redux Thunk 不是“加功能”而是对 Redux 数据流的一次精准外科手术2.1 为什么不能直接在组件里写 fetch——副作用污染的代价新手最常犯的错误是在useEffect里直接调用fetch然后在.then()里调用dispatchuseEffect(() { fetch(/api/users) .then(res res.json()) .then(users dispatch({ type: SET_USERS, payload: users })) }, []);这段代码看似可行但它埋下了三个深坑逻辑碎片化请求逻辑、错误处理、loading 状态管理、缓存策略全部散落在组件内部。当另一个组件也要拉用户列表时你得复制粘贴一整块useEffect稍作修改——这不是复用是灾难性耦合。状态不可预测dispatch({ type: SET_USERS, payload: users })是一个裸 action它不携带任何上下文。如果用户在请求过程中切换了页面或者并发触发了两次请求你根本无法判断当前 dispatch 的 payload 对应哪次请求。Reducer 里只能无脑覆盖state.users导致 UI 显示陈旧数据竞态条件。测试地狱这个useEffect依赖真实网络、真实 DOM、真实 store。写单元测试时你得 mockfetch、mockdispatch、mockuseEffect的执行时机最后测的不是业务逻辑而是一堆 mock 行为。Redux 的设计哲学是“状态可预测”而上述写法让状态更新完全取决于外部世界网络延迟、服务器响应、用户操作节奏彻底违背了这一原则。所以我们必须把副作用fetch和状态更新dispatch解耦且让副作用的执行过程本身也成为可描述、可追踪、可测试的状态。2.2 Thunk 的核心契约用函数代替对象用执行代替声明Redux Thunk 的解法极其简洁却直击要害它重新定义了 action 的形态。传统 Redux 中action 是一个必须带type字段的 plain object{ type: ADD_TODO, text: Learn Redux }而 Thunk 允许你 dispatch 一个函数const fetchUsers () (dispatch, getState) { dispatch({ type: FETCH_USERS_START }); fetch(/api/users) .then(res res.json()) .then(users dispatch({ type: FETCH_USERS_SUCCESS, payload: users })) .catch(err dispatch({ type: FETCH_USERS_FAILURE, error: err.message })); };注意这个函数的签名(dispatch, getState) void。它本身不是 action而是一个thunk creatorthunk 创建器。当你dispatch(fetchUsers())时实际传给 store 的是fetchUsers()执行后返回的那个函数。而 Thunk middleware 的唯一职责就是拦截所有非对象类型的 action识别出这是个函数然后调用它并把store.dispatch和store.getState作为参数传进去。这个设计的精妙之处在于零侵入式改造你不需要改写任何 reducer、不需要动createStore的基本用法。只需在创建 store 时用applyMiddleware(thunk)包裹一下整个系统就获得了“执行函数型 action”的能力。上下文自包含dispatch和getState是闭包捕获的函数内部可以随时读取最新状态比如检查 token 是否过期、可以多次 dispatch处理 loading/success/error、可以组合其他 thunk比如先刷新 token再拉用户数据。可测试性跃升fetchUsers()函数本身不依赖任何外部环境。你可以用 Jest 直接调用它传入 mock 的dispatch和getState断言它是否按预期 dispatch 了正确的 action 序列。测试代码干净、快速、可靠。提示Thunk 并不是 Redux 的“扩展”而是对 Redux 原有dispatch接口的一次语义升级。它没有增加新 API只是让dispatch能接受更多类型的输入并在 middleware 层统一处理。这种设计符合 Unix 哲学“做一件事并做好它”。2.3 为什么是 Thunk而不是 Promise 或 async/await——中间件模型的必然选择你可能会问既然最终目标是处理异步为什么不用async/await直接写// ❌ 错误示范async 函数不能直接 dispatch const fetchUsersAsync async () { const res await fetch(/api/users); const users await res.json(); return { type: SET_USERS, payload: users }; // 这个 return 无法被 dispatch 捕获 };这是因为dispatch的设计初衷是同步更新状态。当你dispatch(anAction)Redux 期望立即执行 reducer 并返回新 state。如果anAction是一个Promisedispatch就会收到一个 pending 状态的 Promise 对象而它既不会等待 Promise resolve也不知道如何处理 reject。这会导致 state 更新完全失控。Thunk 的优势在于它把“异步”这个概念降维成了“函数执行”。dispatch(thunk)这一行代码是同步的它立刻执行 thunk 函数而 thunk 函数内部可以自由使用async/await、Promise.then、setTimeout等任何异步手段。dispatch本身并不关心 thunk 内部做了什么它只负责把dispatch和getState这两个关键工具交给 thunk 使用。这就像给一个工人thunk发了一把万能钥匙dispatch和一张实时地图getState至于工人是坐地铁还是骑单车去工地异步方式那是他自己的事。注意async/await在 thunk 内部是完全合法的而且是推荐写法。你只需要确保dispatch调用发生在await之后const fetchUsers () async (dispatch, getState) { dispatch({ type: FETCH_USERS_START }); try { const res await fetch(/api/users); const users await res.json(); dispatch({ type: FETCH_USERS_SUCCESS, payload: users }); } catch (err) { dispatch({ type: FETCH_USERS_FAILURE, error: err.message }); } };3. 实操细节解析从零搭建一个可调试、可监控的 Thunk 工作流3.1 初始化 StoreapplyMiddleware的底层发生了什么我们从最基础的 store 创建开始。假设你用的是原生 Redux非 RTK初始化代码通常是import { createStore, applyMiddleware, compose } from redux; import thunk from redux-thunk; import rootReducer from ./reducers; // 传统方式已废弃 // const store createStore(rootReducer, applyMiddleware(thunk)); // 现代方式支持 DevTools const composeEnhancers window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; const store createStore( rootReducer, composeEnhancers(applyMiddleware(thunk)) );applyMiddleware是一个高阶函数它接收一个或多个 middleware如thunk并返回一个store enhancer增强器。这个 enhancer 会“包裹”原始的createStore在创建 store 时注入一个经过改造的dispatch方法。我们可以手动模拟applyMiddleware(thunk)的效果来理解其原理// 简化版 applyMiddleware 实现仅示意 function applyMiddleware(...middlewares) { return (createStore) (reducer, preloadedState, enhancer) { const store createStore(reducer, preloadedState, enhancer); // 创建一个“链式 dispatch” let dispatch store.dispatch; // 从右到左组合 middleware const middlewareAPI { getState: store.getState, dispatch: (action) dispatch(action) // 初始指向原始 dispatch }; // 每个 middleware 都接收 middlewareAPI并返回一个函数 // 该函数接收 next下一个 dispatch并返回最终的 dispatch const chain middlewares.map(middleware middleware(middlewareAPI)); // 用 compose 把所有 middleware 串起来形成新的 dispatch dispatch compose(...chain)(store.dispatch); return { ...store, dispatch }; }; }thunkmiddleware 的源码极简约10行核心逻辑如下// redux-thunk/src/index.js function createThunkMiddleware(extraArgument) { return ({ dispatch, getState }) (next) (action) { // 如果 action 是函数就执行它并传入 dispatch 和 getState if (typeof action function) { return action(dispatch, getState, extraArgument); } // 否则走正常流程交给下一个 middleware 或 reducer return next(action); }; } const thunk createThunkMiddleware(); export default thunk;关键点在于next(action)。next指向的是“下一个 middleware 的 dispatch”或者如果它是最后一个 middleware则指向 store 原始的dispatch。Thunk 的作用就是在next被调用之前先判断action类型如果是函数就执行它否则把action透传给next。这就形成了一个“拦截-处理-放行”的标准 middleware 模式。实操心得当你在 DevTools 中看到一个 thunk action 被 dispatch但 state 没变不要慌。这是正常现象。因为 thunk action 本身不改变 state它只是触发了一个函数执行。你需要展开这个 action看它内部 dispatch 的子 action如FETCH_USERS_SUCCESS是否被正确记录。3.2 编写可维护的 Thunk结构化、可取消、带类型提示一个生产级的 thunk绝不能是上面那个简单的fetch示例。它需要结构化、可测试、可取消并具备完整的 TypeScript 类型。第一步定义 Action Types 和 Interfaces// types/user.ts export const USER_ACTIONS { FETCH_START: USER/FETCH_START, FETCH_SUCCESS: USER/FETCH_SUCCESS, FETCH_FAILURE: USER/FETCH_FAILURE, SET_LOADING: USER/SET_LOADING } as const; export type UserActionType typeof USER_ACTIONS[keyof typeof USER_ACTIONS]; export interface User { id: number; name: string; email: string; } export interface UserState { list: User[]; loading: boolean; error: string | null; } // actions/user.ts import { USER_ACTIONS } from ../types/user; export const fetchUsersStart () ({ type: USER_ACTIONS.FETCH_START } as const); export const fetchUsersSuccess (users: User[]) ({ type: USER_ACTIONS.FETCH_SUCCESS, payload: users } as const); export const fetchUsersFailure (error: string) ({ type: USER_ACTIONS.FETCH_FAILURE, error } as const);第二步编写 Thunk集成 AbortController 实现请求取消现代浏览器支持AbortController它允许你在请求发出后主动中止。这对于用户快速切换页面、取消搜索等场景至关重要。一个健壮的 thunk 必须处理取消逻辑// thunks/user.ts import { fetchUsersStart, fetchUsersSuccess, fetchUsersFailure } from ../actions/user; import { USER_ACTIONS } from ../types/user; import { RootState } from ../store; // 假设你有全局 RootState 类型 // 定义 thunk 的返回类型它本身是一个函数返回 Promisevoid export const fetchUsers (signal?: AbortSignal) { return async (dispatch: any, getState: () RootState) { dispatch(fetchUsersStart()); try { const controller new AbortController(); if (signal) { // 如果外部传入了 signal将它与 controller 关联 signal.addEventListener(abort, () controller.abort()); } const res await fetch(/api/users, { method: GET, headers: { Content-Type: application/json, }, signal: controller.signal // 将 controller 的 signal 传给 fetch }); if (!res.ok) { throw new Error(HTTP error! status: ${res.status}); } const users: User[] await res.json(); dispatch(fetchUsersSuccess(users)); } catch (err) { // 检查是否是取消错误 if (err.name AbortError) { console.log(Fetch was aborted); return; // 不 dispatch failure因为这是用户主动取消 } dispatch(fetchUsersFailure(err.message || Failed to fetch users)); } }; };第三步在组件中安全使用React TypeScript// components/UserList.tsx import { useEffect } from react; import { useDispatch, useSelector } from react-redux; import { fetchUsers } from ../thunks/user; import { RootState } from ../store; import { User } from ../types/user; const UserList () { const dispatch useDispatch(); const { list, loading, error } useSelector((state: RootState) state.user); useEffect(() { // 创建 AbortController用于清理 const controller new AbortController(); // 发起请求 dispatch(fetchUsers(controller.signal)); // 清理函数组件卸载时中止请求 return () { controller.abort(); }; }, [dispatch]); if (loading) return divLoading.../div; if (error) return divError: {error}/div; return ( ul {list.map(user ( li key{user.id}{user.name} ({user.email})/li ))} /ul ); }; export default UserList;注意事项controller.abort()必须在useEffect的清理函数中调用这是 React 的标准实践。它确保了即使用户在请求完成前就离开了页面请求也会被优雅地中止避免了“内存泄漏”和“无效 state 更新”。3.3 状态管理进阶如何在一个 thunk 中协调多个 API 调用真实业务中一个“获取用户详情”的操作往往需要串联多个请求先拉用户基本信息再根据用户 ID 拉其订单列表再拉其收藏商品。你不能简单地把三个fetch写在try块里因为任何一个失败都会导致后续请求被跳过。你需要一种“链式”或“并行”的协调机制。方案一链式调用顺序依赖export const fetchUserWithOrders (userId: number) { return async (dispatch: any, getState: () RootState) { dispatch({ type: FETCH_USER_START }); try { // Step 1: Fetch user const userRes await fetch(/api/users/${userId}); const user await userRes.json(); dispatch({ type: FETCH_USER_SUCCESS, payload: user }); // Step 2: Fetch orders for this user const orderRes await fetch(/api/users/${userId}/orders); const orders await orderRes.json(); dispatch({ type: FETCH_ORDERS_SUCCESS, payload: orders }); // Step 3: Fetch favorites const favRes await fetch(/api/users/${userId}/favorites); const favorites await favRes.json(); dispatch({ type: FETCH_FAVORITES_SUCCESS, payload: favorites }); } catch (err) { dispatch({ type: FETCH_USER_FAILURE, error: err.message }); } }; };方案二并行调用无依赖追求速度export const fetchUserAndRelated (userId: number) { return async (dispatch: any, getState: () RootState) { dispatch({ type: FETCH_USER_START }); try { // 同时发起三个请求 const [userRes, orderRes, favRes] await Promise.all([ fetch(/api/users/${userId}), fetch(/api/users/${userId}/orders), fetch(/api/users/${userId}/favorites) ]); const [user, orders, favorites] await Promise.all([ userRes.json(), orderRes.json(), favRes.json() ]); dispatch({ type: FETCH_USER_SUCCESS, payload: user }); dispatch({ type: FETCH_ORDERS_SUCCESS, payload: orders }); dispatch({ type: FETCH_FAVORITES_SUCCESS, payload: favorites }); } catch (err) { dispatch({ type: FETCH_USER_FAILURE, error: err.message }); } }; };实操心得Promise.all是并行的黄金标准但它有一个缺点只要一个请求失败整个Promise.all就会 reject导致其他成功请求的结果也丢失。如果你需要“尽力而为”可以使用Promise.allSettled它会返回一个包含每个 Promise 状态fulfilled/rejected的对象数组让你能分别处理成功和失败的情况。4. 核心环节实现手写一个简易 Thunk Middleware彻底搞懂它的执行时序为了彻底消除黑盒感我们来手写一个最小可用的 Thunk middleware并用console.log打印每一步的执行顺序。这比读源码更直观。4.1 构建一个可观察的 Store首先创建一个简化版的 store它能让我们清晰地看到dispatch被调用的每一个环节// utils/simpleStore.ts export type Store { getState: () any; dispatch: (action: any) void; subscribe: (listener: () void) () void; }; export function createSimpleStore(reducer: (state: any, action: any) any, initialState: any): Store { let state initialState; let listeners: Array() void []; const getState () state; const dispatch (action: any) { console.log([STORE] Dispatching action:, action); state reducer(state, action); console.log([STORE] State after dispatch:, state); listeners.forEach(listener listener()); }; const subscribe (listener: () void) { listeners.push(listener); return () { listeners listeners.filter(l l ! listener); }; }; return { getState, dispatch, subscribe }; }4.2 手写 Thunk Middleware添加日志与执行追踪现在我们实现一个带详细日志的 Thunk middleware// middleware/loggedThunk.ts export function loggedThunk({ dispatch, getState }: { dispatch: any; getState: any }) { console.log([MIDDLEWARE] Thunk middleware initialized with dispatch getState); return (next: (action: any) void) (action: any) { console.log([MIDDLEWARE] Received action:, action); if (typeof action function) { console.log([MIDDLEWARE] Detected thunk function. Executing it...); // 执行 thunk并传入 dispatch 和 getState const result action(dispatch, getState); console.log([MIDDLEWARE] Thunk execution returned:, result); return result; } else { console.log([MIDDLEWARE] Not a function, passing to next middleware or reducer); return next(action); } }; }4.3 组装并运行观察完整的调用链// index.ts import { createSimpleStore } from ./utils/simpleStore; import { loggedThunk } from ./middleware/loggedThunk; // 简单的 reducer const rootReducer (state: any { count: 0 }, action: any) { switch (action.type) { case INCREMENT: return { ...state, count: state.count 1 }; default: return state; } }; // 创建 store const store createSimpleStore(rootReducer, { count: 0 }); // 手动应用 middleware我们不使用 applyMiddleware而是手动包装 dispatch const originalDispatch store.dispatch; const enhancedDispatch loggedThunk({ dispatch: originalDispatch, getState: store.getState })(originalDispatch); // 替换 store 的 dispatch (store as any).dispatch enhancedDispatch; // 定义一个 thunk const incrementAsync () (dispatch: any) { console.log([THUNK] Inside thunk, about to dispatch INCREMENT); setTimeout(() { dispatch({ type: INCREMENT }); }, 1000); }; // 现在 dispatch 这个 thunk console.log(--- Starting dispatch sequence ---); store.dispatch(incrementAsync()); console.log(--- Dispatch call returned ---);运行这段代码控制台输出将清晰地展示整个流程--- Starting dispatch sequence --- [MIDDLEWARE] Received action: [Function: incrementAsync] [MIDDLEWARE] Detected thunk function. Executing it... [THUNK] Inside thunk, about to dispatch INCREMENT --- Dispatch call returned --- ... 1秒后 ... [STORE] Dispatching action: { type: INCREMENT } [STORE] State after dispatch: { count: 1 }这个实验揭示了三个关键事实dispatch(thunk)是同步的从你调用store.dispatch(incrementAsync())到控制台打印--- Dispatch call returned ---整个过程是瞬间完成的。setTimeout的延迟发生在 thunk 内部与dispatch调用本身无关。Middleware 的拦截发生在dispatch调用入口loggedThunk的第一行console.log([MIDDLEWARE] Received action:)是最先被执行的证明了 middleware 是在 action 进入 store 处理管道的第一站。dispatch的递归性thunk 内部调用的dispatch({ type: INCREMENT })会再次进入loggedThunk的next(action)分支因为它是一个 plain object。这说明dispatch是一个“管道”无论你在哪一层调用它它都会重新流经所有 middleware。提示这个手写实验的价值在于它剥离了所有框架React、DevTools的干扰让你纯粹地看到 Redux 数据流的“骨骼”。当你下次遇到奇怪的 dispatch 行为时就可以回到这个模型一步步推演action 是什么类型它被哪个 middleware 拦截了next指向哪里这样调试就不再是盲人摸象。5. 常见问题与排查技巧实录那些只有踩过坑才懂的 Thunk 细节5.1 问题速查表高频报错与根因分析现象控制台错误信息最可能根因解决方案页面白屏无任何报错You may have an infinite update loop in a component render function.在 thunk 中dispatch了一个会触发相同 thunk 的 action例如FETCH_USERS_SUCCESS的 reducer 又触发了fetchUsers()。检查 reducer 是否意外地 dispatch 了新 action。确保所有dispatch都发生在副作用thunk、useEffect中而非纯函数reducer、selector中。dispatch is not a functionTypeError: dispatch is not a function在 thunk 内部错误地将dispatch当作普通函数调用而没有传入(dispatch, getState)参数。例如const myThunk dispatch { dispatch(...) }错误 vsconst myThunk () (dispatch) { dispatch(...) }正确。严格遵循 thunk 的签名() (dispatch, getState) void。使用 TypeScript 可以在编译期捕获此类错误。Cannot read property dispatch of undefinedTypeError: Cannot read property dispatch of undefined在创建 store 时忘记调用applyMiddleware(thunk)或者thunk没有被正确 import。检查createStore的第二个参数。确保applyMiddleware(thunk)被正确传入并且thunk是从redux-thunk正确导入的。请求发出了但FETCH_SUCCESSaction 没有被 dispatch无错误但 state 未更新thunk 内部的fetch或await抛出了未被捕获的异常导致dispatch语句没有执行到。在try/catch块中包裹所有异步操作并确保catch块中至少有一个dispatch即使是FAILUREaction或者console.error记录错误。DevTools 中看到undefinedaction在 Redux DevTools 的 action 列表中看到一个undefined的条目这通常是因为 thunk 函数本身没有return任何值而你又在某个地方console.log了dispatch(thunk)的返回值。dispatch的返回值是 thunk 执行的返回值。忽略这个undefined。它不影响功能只是日志显示。如果想让它有意义可以在 thunk 结尾return一个标识符如return FETCH_USERS_COMPLETED。5.2 独家避坑技巧提升 Thunk 可靠性的 3 个硬核实践技巧一永远为 Thunk 添加超时保护Timeout Fallback网络请求可能永远挂起如 DNS 解析失败、服务器无响应。一个没有超时的请求会让用户的 loading 状态永远持续下去。AbortController只能中止不能自动超时。因此你需要手动实现超时逻辑export const fetchUsersWithTimeout (timeoutMs 10000) { return async (dispatch: any, getState: () RootState) { dispatch(fetchUsersStart()); const controller new AbortController(); const timeoutId setTimeout(() controller.abort(), timeoutMs); try { const res await fetch(/api/users, { signal: controller.signal }); clearTimeout(timeoutId); // 请求成功清除定时器 const users await res.json(); dispatch(fetchUsersSuccess(users)); } catch (err) { clearTimeout(timeoutId); // 请求失败也要清除定时器 if (err.name AbortError) { dispatch(fetchUsersFailure(Request timed out)); } else { dispatch(fetchUsersFailure(err.message)); } } }; };技巧二利用getState实现智能缓存与条件请求不要每次都无脑发请求。在 thunk 中你可以读取当前 state决定是否跳过请求export const fetchUsersIfStale () { return async (dispatch: any, getState: () RootState) { const { user } getState(); const now Date.now(); const staleThreshold 5 * 60 * 1000; // 5分钟 // 如果已有数据且未过期直接返回 if (user.list.length 0 user.lastFetched (now - user.lastFetched staleThreshold)) { console.log(Using cached users data); return; } dispatch(fetchUsersStart()); try { const res await fetch(/api/users); const users await res.json(); dispatch(fetchUsersSuccess(users)); // 记录最后获取时间 dispatch({ type: SET_LAST_FETCHED, timestamp: now }); } catch (err) { dispatch(fetchUsersFailure(err.message)); } }; };技巧三为 Thunk 编写“单元测试”的黄金模板一个可测试的 thunk其核心是隔离外部依赖。以下是一个 Jest 测试的完整骨架// thunks/user.test.ts import { fetchUsers } from ./user; import { fetchUsersStart, fetchUsersSuccess, fetchUsersFailure } from ../actions/user; // Mock fetch global.fetch jest.fn(); describe(fetchUsers thunk, () { let dispatch: jest.Mock; let getState: jest.Mock; beforeEach(() { dispatch jest.fn(); getState jest.fn().mockReturnValue({ user: { list: [], loading: false, error: null } }); }); afterEach(() { jest.clearAllMocks(); }); it(dispatches START, SUCCESS actions on successful fetch, async () { // Arrange: Mock fetch to resolve with data (fetch as jest.Mock).mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue([{ id: 1, name: John }]) }); // Act: Call the thunk await fetchUsers()(dispatch, getState); // Assert: Check the dispatch calls expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenNthCalledWith(1, fetchUsersStart()); expect(dispatch).toHaveBeenNthCalledWith(2, fetchUsersSuccess([{ id: 1, name: John }])); }); it(dispatches START, FAILURE actions on failed fetch, async () { // Arrange: Mock fetch to reject (fetch as jest.Mock).mockRejectedValue(new Error(Network Error)); // Act await fetchUsers()(dispatch, getState); // Assert expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenNthCalledWith(1, fetchUsersStart()); expect(dispatch).toHaveBeenNthCalledWith(2, fetchUsersFailure(Network Error)); }); });这个测试模板的关键在于它完全不依赖真实的网络、真实的 store、真实的 React。你只测试了fetchUsers这个函数的行为它是否在正确的时候 dispatch 了正确的 action。这就是 Thunk 带来的最大测试红利。6. 从 Thunk 到未来RTK Query 为何成为下一代数据获取标准以及 Thunk 的不可替代性6.1 Redux Toolkit (RTK) 的崛起createAsyncThunk是 Thunk 的现代化封装随着 Redux Toolkit (RTK) 的普及createAsyncThunk已成为定义异步逻辑的首选方式。它本质上是 Thunk 的语法糖但极大地简化了样板代码// RTK 方式 import { createAsyncThunk, createSlice } from reduxjs/toolkit; // 定义异步 thunk export const fetchUsers createAsyncThunk( users/fetchUsers, // action type prefix async (_, { rejectWithValue }) { try { const res await fetch(/api/users); const users await res.json(); return users; // 自动 dispatch SUCCESS } catch (err) { return rejectWithValue(err.message); // 自动 dispatch FAILURE } } ); // createSlice 会自动为你生成 PENDING/SUCCESS/FAILURE 的 reducer cases const usersSlice createSlice({ name: users, initialState: { list: [], loading: false, error: null }, reducers: {}, extraReducers: (builder) { builder .addCase(fetchUsers.pending, (state) { state.loading true; }) .addCase(fetchUsers.fulfilled, (state, action) { state.loading false; state.list action.payload; }) .addCase(fetchUsers.rejected, (state, action) { state.loading false; state.error action.payload as string; }); } });createAsyncThunk的优势是显而易见的它自动处理了PENDING状态、自动根据return/rejectWithValue分发FULFILLED/REJECTEDaction并且与createSlice无缝集成。对于绝大多数 CRUD 场景它比手写 Thunk 更快、更安全、更不易出错。6.2 Thunk 的不可替代性当业务逻辑超越“请求-响应”范式然而createAsyncThunk并非万能。它被设计为处理标准的“发起请求 - 等待响应 - 更新状态”流程。一旦你的业务逻辑变得复杂Thunk 的灵活性就凸显出来复杂的错误恢复策略比如fetchUsers失败后你想先尝试用本地缓存数据填充 UI同时在后台静默重试三次每次间隔 1 秒。createAsyncThunk的rejected只能 dispatch 一个 action而 Thunk 可以在里面写任意