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,最终触发三次不同 action(START / SUCCESS / FAILURE)的完整链路。你会看到,所谓“异步 action”,本质是把一个线性、不可中断的同步 dispatch 流程,改造成一个可暂停、可分支、可携带上下文的函数执行流程。这正是它能支撑登录鉴权、文件上传进度、WebSocket 消息队列、甚至复杂表单多步骤提交的根本原因。
2. 核心设计思路:Redux Thunk 不是“加功能”,而是对 Redux 数据流的一次精准外科手术
2.1 为什么不能直接在组件里写 fetch?——副作用污染的代价
新手最常犯的错误,是在useEffect里直接调用fetch,然后在.then()里调用dispatch:
useEffect(() => { 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 creator(thunk 创建器)。当你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是一个Promise,dispatch就会收到一个 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 初始化 Store:applyMiddleware的底层发生了什么?
我们从最基础的 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 的返回类型:它本身是一个函数,返回 Promise<void> 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 <div>Loading...</div>; if (error) return <div>Error: {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 function | TypeError: 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 undefined | TypeError: 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 可以在里面写任意