Redux 基础教程实战:用中间件与 Redux Thunk 实现异步逻辑和数据获取

📅 发布时间:2026/9/5 16:39:48
Redux 基础教程实战:用中间件与 Redux Thunk 实现异步逻辑和数据获取
Redux 基础教程实战用中间件与 Redux Thunk 实现异步逻辑和数据获取【免费下载链接】reduxA JS library for predictable global state management项目地址: https://gitcode.com/gh_mirrors/re/redux本篇内容基于 Redux 官方基金教程第 6 篇part-6-async-logic.md讲解当应用数据不再只存在于客户端、而需要通过与服务器进行 HTTP 请求来读写时Redux 是如何通过**中间件middleware**与Thunk 函数来承载异步逻辑的Redux 数据流在引入异步后会多出哪些环节、为什么 reducer 中不能写副作用、applyMiddleware在源码层面如何把多个中间件组装成一条 dispatch 管线以及如何用 thunk 函数从服务端拉取待办列表并保存新待办。读完本篇你将能够配置带 thunk 中间件的 store、编写接收dispatch与getState的异步函数、在组件中 dispatch thunk 完成真实的数据获取流程并在源码与测试层面验证这套机制的每个环节。前置知识与你将学到的内容本教程第 6 篇建立在 第 5 篇UI 和 React 的基础上你已经会用react-redux让组件与 Redux store 交互——调用useSelector读取状态、调用useDispatch拿到dispatch函数、并用Provider组件让 hooks 访问到 store。本篇学习目标Redux 数据流在处理异步数据时是如何工作的如何用 Redux 中间件编写异步逻辑处理异步请求状态request state的模式。前置要求熟悉使用 HTTP 请求从服务器获取和更新数据理解 JS 中的异步逻辑包括 Promise。值得先了解的一点Redux Toolkit 提供了专门的数据获取与缓存方案 RTK Query它可以消除为数据获取编写任何 thunk 或 reducer 的需要。Redux 官方教程将 RTK Query 作为数据获取的默认推荐方案而 RTK Query 正是建立在本篇讲解的同一套模式之上参见 RTK Query 概述 与 Redux Essentials, Part 7: RTK Query Basics。本篇讲的是这些底层机制本身。示例 REST API 与 HTTP 客户端为了让示例项目既隔离又贴近现实教程配套的示例应用redux-fundamentals-example-app在初始搭建时就内置了一个内存版的假 REST API使用 Mirage.js 这个 mock API 工具配置。该 API 以/fakeApi作为所有端点的基础 URL并对/fakeApi/todos支持常见的GET/POST/PUT/DELETEHTTP 方法定义在示例应用的src/api/server.js中。项目还包含一个小型 HTTP API 客户端对象暴露client.get()和client.post()方法用法类似 axios 等常见 HTTP 库定义在示例应用的src/api/client.js中。本节的 HTTP 调用都通过这个client对象发往内存中的假 REST API。Redux 中间件与副作用单看一个 Redux store它对异步逻辑一无所知它只会同步地 dispatch 动作、通过调用根 reducer 函数更新状态并通知 UI 发生了变化。任何异步性都必须发生在 store 之外。在前面的教程中我们说过Redux reducer 绝不能包含副作用。副作用指的是任何能够被函数返回值之外的外部世界观察到的状态或行为改变。常见的副作用包括向控制台打印日志保存文件设置异步定时器发起 HTTP 请求修改函数外部存在的某个状态或直接改变mutate函数参数生成随机数或唯一随机 ID如Math.random()或Date.now()。然而任何真实应用都总要在某个地方做这些事情。既然 reducer 里不能放副作用那副作用应该放在哪里Redux 中间件正是为了让你可以编写带副作用的逻辑而设计的。正如 第 4 篇 中所述Redux 中间件在看到一个被 dispatch 的动作时可以做任何事打印日志、修改动作、延迟动作、发起异步调用等等。而且由于中间件构成了包裹在真实store.dispatch函数外的一整条管线这意味着你实际上可以向dispatch传递不是一个纯 action 对象的值——只要某个中间件拦截了这个值、并且不让它一路到达 reducer 就行。中间件还能访问dispatch和getState。也就是说你可以在中间件里编写异步逻辑并且依然能够通过 dispatch 动作与 Redux store 保持交互。Redux 作者在 StackOverflow 上多次解释过为什么异步流需要中间件其核心论点与本节一致状态更新必须同步、可预测而副作用必须被移出 reducer 到 dispatch 管线上。用中间件启用异步逻辑下面看两个例子展示中间件如何让我们编写能与 Redux store 交互的异步逻辑。一种可能是写一个查找特定 action 类型的中间件在看到这些动作时运行异步逻辑import { client } from ../api/client const delayedActionMiddleware storeAPI next action { if (action.type todos/todoAdded) { setTimeout(() { // 延迟这个 action 一秒 next(action) }, 1000) return } return next(action) } const fetchTodosMiddleware storeAPI next action { if (action.type todos/fetchTodos) { // 发起 API 调用从服务器拉取 todos client.get(todos).then(todos { // 用接收到的 todos dispatch 一个 action storeAPI.dispatch({ type: todos/todosLoaded, payload: todos }) }) } return next(action) }注意这两个中间件的写法都是三层嵌套函数——最外层接收storeAPI中间层接收next最内层接收action。这与本仓库src/types/middleware.ts中Middleware类型的定义完全对应// src/types/middleware.ts节选 export interface MiddlewareAPID extends Dispatch Dispatch, S any { dispatch: D getState: () S } export interface Middleware _DispatchExt {}, S any, D extends Dispatch Dispatch { ( api: MiddlewareAPID, S ): (next: (action: unknown) unknown) (action: unknown) unknown }也就是说中间件的契约就是(api) (next) (action) 任意返回值其中最内层函数的参数类型是unknown——这从类型层面就承认了dispatch 进来的一切都不保证是普通 action 对象拦截非对象值比如函数正是中间件的合法职责。源码视角applyMiddleware 如何组装中间件管线以上三层嵌套函数最终是由applyMiddleware串成管线的。看本仓库的实现 src/applyMiddleware.tsexport default function applyMiddleware(...middlewares: Middleware[]): StoreEnhancerany { return createStore (reducer, preloadedState) { const store createStore(reducer, preloadedState) let dispatch: Dispatch () { throw new Error( Dispatching while constructing your middleware is not allowed. Other middleware would not be applied to this dispatch. ) } const middlewareAPI: MiddlewareAPI { getState: store.getState, dispatch: (action, ...args) dispatch(action, ...args) } const chain middlewares.map(middleware middleware(middlewareAPI)) dispatch composetypeof dispatch(...chain)(store.dispatch) return { ...store, dispatch } } }从这段源码可以读出几个关键事实applyMiddleware本身是一个 store enhancer它返回createStore (reducer, preloadedState) store这样的高阶函数最终返回一个覆盖了dispatch的新 store。这也解释了教程第 4 篇中中间件是构建在一个非常特殊的内建 store enhancer 之上的这句话。函数注释中还特别提示由于中间件可能是异步的applyMiddleware应当是 enhancer 组合链中的第一个。每个中间件拿到的dispatch是指向管线入口的递归引用middlewareAPI.dispatch调用的是闭包中会被重新赋值的dispatch变量最终被赋值为组合后的完整管线。因此中间件内部再 dispatch 的动作会从第一个中间件重新走完整条管线而不会被漏掉。管线的组装发生在构建期middlewares.map(middleware middleware(middlewareAPI))先对每个中间件调用最外层函数拿到各自的wrapDispatch再compose(...chain)(store.dispatch)把它们从右向左嵌套把原始的store.dispatch作为链尾即最后一个中间件的next。compose的实现见 src/compose.ts即compose(f, g, h) (...args) f(g(h(...args)))——这决定了applyMiddleware(a, b)时a是管线最外层、最先执行b最后执行并紧邻真实store.dispatch。构建期禁止 dispatch在中间件装配完成之前dispatch被临时指向一个会抛出 Dispatching while constructing your middleware is not allowed 的函数src/applyMiddleware.ts防止你在中间件外层函数里 dispatch 时绕过尚未装配的管线。这些行为都有测试背书见 test/applyMiddleware.spec.tswarns when dispatching during middleware setupL9-L20验证了第 4 点的抛错行为wraps dispatch method with middleware onceL22-L45验证中间件外层函数只被调用一次且拿到的 API 上确实有getState和dispatchpasses recursive dispatches through the middleware chainL47-L65验证中间件内部 dispatch 的动作会重新经过整条管线。编写一个异步函数中间件上一节的两个中间件都太具体各只干一件事。我们当然希望有一种办法把任意异步逻辑提前写成一个独立函数与中间件定义解耦同时这个函数还能访问dispatch和getState来与 store 交互。如果我们写一个允许向dispatch传一个函数而不是 action 对象的中间件呢这个中间件检查一下动作是否其实是函数如果是就立刻调用它。这样异步逻辑就能写在中间件定义之外的独立函数里。这样的中间件大概长这样// 示例异步函数中间件 const asyncFunctionMiddleware storeAPI next action { // 如果这个action其实是个函数…… if (typeof action function) { // 那就调用它并把 dispatch 和 getState 作为参数传入 return action(storeAPI.dispatch, storeAPI.getState) } // 否则它是一个普通 action——继续往下传 return next(action) }有了它我们就可以这样使用const middlewareEnhancer applyMiddleware(asyncFunctionMiddleware) const store createStore(rootReducer, middlewareEnhancer) // 写一个以 dispatch 和 getState 为参数的函数 const fetchSomeData (dispatch, getState) { // 发起一次异步 HTTP 请求 client.get(todos).then(todos { // 用接收到的 todos dispatch 一个 action dispatch({ type: todos/todosLoaded, payload: todos }) // dispatch 之后读取更新后的 store 状态 const allTodos getState().todos console.log(Number of todos after loading: , allTodos.length) }) } // 把上面写的函数传给 dispatch store.dispatch(fetchSomeData) // 日志输出Number of todos after loading: ###注意这个异步函数中间件让我们向dispatch传入了一个函数。在函数内部我们先写了一段异步逻辑HTTP 请求请求完成后再 dispatch 一个普通的 action 对象。这里有一个容易忽略的边界如果没有中间件拦截把函数传给dispatch会在 store 的原始dispatch里直接失败。从源码看src/createStore.ts 中对 action 的校验会检查action.type是否为字符串非普通对象的值根本无法通过 reducer 校验流程仓库也提供了 src/utils/isAction.ts 这样的工具函数其判定标准就是是纯对象且type为字符串。换言之函数型 dispatch 之所以可行完全依赖于中间件先于 store 内部逻辑拦截了它——这正是文档所说只要中间件拦截该值、不让它到达 reducer的底层依据。本仓库的测试辅助代码里就有一个与上面asyncFunctionMiddleware几乎一模一样的最小 thunk 实现见 test/helpers/middleware.tsexport const thunk: Middleware{ R(thunk: (dispatch: Dispatch, getState: () any) R): R } ({ dispatch, getState }) next action typeof action function ? action(dispatch, getState) : next(action)而 test/applyMiddleware.spec.ts 中名为works with thunk middleware的测试用例就用它验证了完整闭环dispatch 一个 thunk 函数后thunk 内部 dispatch 的普通 action 能正确更新 store 状态。Redux 的异步数据流那么中间件和异步逻辑对 Redux 应用的整体数据流有什么影响和普通 action 一样我们首先要处理一个用户事件比如点击按钮然后调用dispatch()传入某种东西——一个纯 action 对象、一个函数或其他可以被中间件查找识别的值。这个被 dispatch 的值到达中间件后中间件可以发起一次异步调用等异步调用完成后再 dispatch 一个真正的 action 对象。在 第 2 篇 中我们见过表示常规同步 Redux 数据流的示意图。当 Redux 应用加入异步逻辑后就多了中间件运行 HTTP 请求等逻辑、再 dispatch action 的额外环节。异步数据流看起来就是文章开头那张图UI 事件触发dispatch值进入中间件管线中间件在异步等待HTTP 请求、定时器等结束后 dispatch 真实 actionaction 再经过剩余中间件到达 reducer状态更新后通知 UI 重新渲染。使用 Redux Thunk 中间件实际上Redux 官方早就有了那个异步函数中间件的标准版本叫做Redux Thunk 中间件npm 包redux-thunk。thunk 中间件让我们编写以dispatch和getState为参数的函数。thunk 函数内部可以包含我们想要的任何异步逻辑这些逻辑可以按需 dispatch action、读取 store 状态。把异步逻辑写成 thunk 函数让我们可以在事先不知道将使用哪个 Redux store 的情况下复用这些逻辑。术语说明thunk 是一个编程术语指一段执行延迟工作delayed work的代码。更多用法可参考 Writing Logic with Thunks 使用指南。配置 StoreRedux thunk 中间件作为 npm 包redux-thunk发布需要先安装npm install redux-thunk安装后更新 todo 应用的 Redux store 以使用这个中间件import { createStore, applyMiddleware } from redux // highlight-next-line import { thunk } from redux-thunk import { composeWithDevTools } from redux-devtools-extension import rootReducer from ./reducer // highlight-next-line const composedEnhancer composeWithDevTools(applyMiddleware(thunk)) // store 现在具备了在 dispatch 中接受 thunk 函数的能力 const store createStore(rootReducer, composedEnhancer) export default storecomposeWithDevTools(applyMiddleware(thunk))正是把applyMiddleware返回的 enhancer 交给 DevTools 组合函数再传给createStore对应 src/applyMiddleware.ts 中 enhancer 包 enhancer 的高阶结构。从服务器获取 Todos现在我们的 todo 条目只能存在于客户端浏览器里。我们首先需要一种方式在应用启动时从服务器加载待办列表。先写一个 thunk 函数它发起 HTTP 调用请求/fakeApi/todos端点、取得 todo 对象数组然后 dispatch 一个以该数组为 payload 的 action。由于这与 todos 功能整体相关thunk 函数写在todosSlice.js里示例应用文件import { client } from ../../api/client const initialState [] export default function todosReducer(state initialState, action) { // 省略 reducer 逻辑 } // Thunk 函数 // highlight-start export async function fetchTodos(dispatch, getState) { const response await client.get(/fakeApi/todos) dispatch({ type: todos/todosLoaded, payload: response.todos }) } // highlight-end这个 API 调用只希望在应用第一次加载时执行一次。可以放的地方有好几处在App组件的useEffecthook 里在TodoList组件的useEffecthook 里直接在index.js里紧跟在导入 store 之后。这里先试放在index.js里import React from react import { createRoot } from react-dom/client import { Provider } from react-redux import ./index.css import App from ./App import ./api/server // highlight-start import store from ./store import { fetchTodos } from ./features/todos/todosSlice store.dispatch(fetchTodos) // highlight-end const root createRoot(document.getElementById(root)) root.render( React.StrictMode Provider store{store} App / /Provider /React.StrictMode )刷新页面后UI 上没有可见变化。但如果打开 Redux DevTools 扩展应该能看到一个todos/todosLoaded动作被 dispatch 了其中包含由我们的假服务器 API 生成的一些 todo 对象注意虽然我们已经 dispatch 了动作但状态并没有发生任何变化。我们需要在 todos reducer 中处理这个动作状态才会更新。给 reducer 加一个 case 来把数据加载进 store。因为数据是从服务器取来的我们要完全替换已有的 todos所以直接返回action.payload数组让它成为 todos 的新state值import { client } from ../../api/client const initialState [] export default function todosReducer(state initialState, action) { switch (action.type) { // 省略其他 reducer case // highlight-start case todos/todosLoaded: { // 直接返回新值整体替换现有 state return action.payload } // highlight-end default: return state } } export async function fetchTodos(dispatch, getState) { const response await client.get(/fakeApi/todos) dispatch({ type: todos/todosLoaded, payload: response.todos }) }由于 dispatch 一个动作会立刻更新 store我们也可以在 thunk 里调用getState在 dispatch 之后读取更新后的状态值。例如在 dispatchtodos/todosLoaded动作前后各打印一次 todo 总数export async function fetchTodos(dispatch, getState) { const response await client.get(/fakeApi/todos) // highlight-next-line const stateBefore getState() console.log(Todos before dispatch: , stateBefore.todos.length) dispatch({ type: todos/todosLoaded, payload: response.todos }) // highlight-next-line const stateAfter getState() console.log(Todos after dispatch: , stateAfter.todos.length) }这正体现了中间件 API 中getState的价值从 src/applyMiddleware.ts 可以看到middlewareAPI.getState直接引用了 store 的getState因此 thunk 内部读到的永远是当下最新的状态。保存 Todo 条目接下来每当创建新待办条目时我们也需要更新服务器。正确做法是不要立即 dispatchtodos/todoAdded动作而是先向服务器发起一次携带初始数据的 API 调用等待服务器返回新保存的 todo 条目副本然后再用这个 todo 条目 dispatch 动作。但如果直接把它写成一个 thunk 函数会立刻遇到一个问题thunk 是写在todosSlice.js里的独立函数发起 API 调用的代码并不知道新 todo 的文本是什么async function saveNewTodo(dispatch, getState) { // ❌ 我们需要新 todo 的文本但它从哪来 // highlight-next-line const initialTodo { text } const response await client.post(/fakeApi/todos, { todo: initialTodo }) dispatch({ type: todos/todoAdded, payload: response.todo }) }我们需要一种方式写一个接收text参数的函数由它创建真正的 thunk 函数使得 thunk 能利用text值发起 API 调用。外层函数返回这个 thunk 函数让我们可以把它传给组件里的dispatch// 写一个同步的外层函数接收 text 参数 export function saveNewTodo(text) { // 然后创建并返回异步 thunk 函数 return async function saveNewTodoThunk(dispatch, getState) { // ✅ 现在可以用 text 值并把它发送到服务器 const initialTodo { text } const response await client.post(/fakeApi/todos, { todo: initialTodo }) dispatch({ type: todos/todoAdded, payload: response.todo }) } }现在可以在Header组件中使用它import React, { useState } from react import { useDispatch } from react-redux // highlight-next-line import { saveNewTodo } from ../todos/todosSlice const Header () { const [text, setText] useState() const dispatch useDispatch() const handleChange e setText(e.target.value) const handleKeyDown e { // 如果用户按下了回车键 const trimmedText text.trim() if (e.which 13 trimmedText) { // highlight-start // 用用户输入的文本创建 thunk 函数 const saveNewTodoThunk saveNewTodo(trimmedText) // 然后把 thunk 函数本身 dispatch 出去 dispatch(saveNewTodoThunk) // highlight-end setText() } } // 省略渲染输出 }由于我们清楚自己会立刻把 thunk 函数传给组件中的dispatch可以省去那个临时变量直接调用saveNewTodo(text)把得到的 thunk 函数原样传给dispatchconst handleKeyDown e { // 如果用户按下了回车键 const trimmedText text.trim() if (e.which 13 trimmedText) { // highlight-start // 创建 thunk 函数并立刻 dispatch dispatch(saveNewTodo(trimmedText)) // highlight-end setText() } }此时组件实际上并不知道自己在 dispatch 一个 thunk 函数——saveNewTodo函数封装了真正发生的事情。Header组件只知道用户按回车时它需要 dispatch某个值。这种写一个函数来准备将要传给dispatch的东西的模式就是所谓的action creator 模式在 Part 7: 标准 Redux 模式 中会进一步展开。现在可以看到更新后的todos/todoAdded动作被 dispatch最后需要修改的是 todos reducer。当我们向/fakeApi/todos发起 POST 请求时服务器会返回一个全新的 todo 对象包含新的 ID 值。这意味着 reducer 不必自己计算新 ID、也不必填其他字段——它只需要构造一个包含新 todo 条目的新state数组const initialState [] export default function todosReducer(state initialState, action) { switch (action.type) { // highlight-start case todos/todoAdded: { // 返回一个新的 todos 状态数组新 todo 条目追加在末尾 return [...state, action.payload] } // highlight-end // 省略其他 case default: return state } }这样新增 todo 就完全正常工作了状态 diff 如下提示thunk 函数既能用于异步逻辑也能用于同步逻辑。thunk 提供了一种方式来编写任何需要访问dispatch和getState的可复用逻辑。本教程小结到目前为止我们已经成功更新了 todo 应用可以用 thunk 函数向假服务器 API 发起 HTTP 请求从而获取待办列表并保存新的待办条目。在这个过程中我们看到了 Redux 中间件是如何让我们发起异步调用、并在异步调用完成后通过 dispatch 动作与 store 交互的。核心结论回顾Redux 中间件被设计用于编写带副作用的逻辑副作用是改变函数外部状态/行为的代码例如 HTTP 请求、修改函数参数、生成随机值中间件在标准 Redux 数据流中加了一个额外环节中间件可以拦截被传给dispatch的其他值中间件可以访问dispatch和getState因此可以在异步逻辑中 dispatch 更多动作Redux Thunk 中间件让我们可以向dispatch传函数thunk 函数让我们可以提前写好异步逻辑而无需事先知道将使用哪个 Redux storeRedux thunk 函数接收dispatch和getState作为参数可以 dispatch 已从 API 响应中收到这些数据之类的动作。从本仓库源码看这套机制的骨架非常紧凑applyMiddlewaresrc/applyMiddleware.ts以 enhancer 形式把中间件链组合成新的dispatchMiddleware/MiddlewareAPI类型src/types/middleware.ts定义了三层嵌套函数契约与dispatch/getState两个能力composesrc/compose.ts完成从右到左的管线嵌套而 test/applyMiddleware.spec.ts 中的用例则逐一验证了构建期禁 dispatch、中间件仅装配一次、内部 dispatch 重走管线、以及与 thunk 配合的完整行为。下一步到这里我们已经覆盖了使用 Redux 的所有核心部分编写根据 dispatch 的动作更新状态的 reducer用 reducer、enhancer 和中间件创建并配置 Redux store使用中间件编写会 dispatch 动作的异步逻辑。在 Part 7: 标准 Redux 模式 中我们将研究真实 Redux 应用常用的若干代码模式让代码更一致并随应用增长更好地扩展。【免费下载链接】reduxA JS library for predictable global state management项目地址: https://gitcode.com/gh_mirrors/re/redux创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考