跳至主内容

Redux 基础教程,第六部分:异步逻辑与数据获取

非官方测试版翻译

本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →

你将学到
  • Redux 数据流如何处理异步数据
  • 如何使用 Redux 中间件处理异步逻辑
  • 处理异步请求状态的模式
预备知识
  • 熟悉使用 HTTP 请求从服务器获取和更新数据
  • 理解 JavaScript 中的异步逻辑,包括 Promise

简介

第五部分:UI 与 React中,我们学习了如何使用 React-Redux 库让 React 组件与 Redux store 交互,包括调用 useSelector 读取 Redux 状态、调用 useDispatch 获取 dispatch 函数,以及使用 <Provider> 组件包裹应用以使这些钩子能够访问 store。

迄今为止,我们处理的所有数据都直接位于 React+Redux 客户端应用内部。然而,大多数实际应用需要通过 HTTP API 调用来获取和保存数据,以使用来自服务器的数据。

在本节中,我们将更新待办事项应用,从 API 获取待办事项,并通过保存至 API 来添加新事项。

非官方测试版翻译

本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →

注意

请注意,本教程有意展示旧式的 Redux 逻辑模式(这些模式比我们当前推荐的 "现代 Redux" 模式需要编写更多代码),目的是为了解释 Redux 背后的原理和概念。它_并非_用于生产环境的项目。

学习使用 Redux Toolkit 实现 "现代 Redux" 模式,请参考以下文档:

技巧

Redux Toolkit 包含 RTK Query 数据获取与缓存 API。RTK Query 是专为 Redux 应用构建的数据获取与缓存解决方案,可消除编写管理数据获取的 任何 thunk 或 reducer 的需要。我们特别将 RTK Query 作为数据获取的默认方法进行教学,且 RTK Query 构建于本页所示的相同模式之上。

学习如何在 Redux 要点教程,第七部分:RTK Query 基础 中使用 RTK Query 进行数据获取。

REST API 与客户端示例

为使示例项目独立且贴近实际,初始项目设置已包含一个用于数据的模拟内存 REST API(使用 Mirage.js 模拟 API 工具配置)。该 API 使用 /fakeApi 作为端点的基础 URL,并为 /fakeApi/todos 支持标准的 GET/POST/PUT/DELETE HTTP 方法。它定义于 src/api/server.js

项目还包含一个简易 HTTP API 客户端对象,提供 client.get()client.post() 方法,类似于 axios 等流行的 HTTP 库。它定义于 src/api/client.js

本节我们将使用 client 对象向内存模拟 REST API 发起 HTTP 调用。

Redux 中间件与副作用

Redux store 本身并不感知异步逻辑。它仅知道如何同步分发 action、通过调用根 reducer 函数更新状态,以及通知 UI 变更。所有异步操作必须在 store 外部处理。

此前我们提到,Redux reducer 绝不应包含"副作用"。所谓"副作用",是指在函数返回值之外可被观察到的任何状态或行为变更。常见的副作用包括:

  • 在控制台打印值

  • 保存文件

  • 设置异步定时器

  • 发起 HTTP 请求

  • 修改函数外部的状态,或更改函数参数

  • 生成随机数或唯一随机 ID(如 Math.random()Date.now()

然而,任何实际应用都_需要_在某个地方执行此类操作。那么,既然不能将副作用放入 reducer,我们该将它们放在_哪里_呢?

Redux 中间件的设计初衷正是为了支持编写包含副作用的逻辑

正如我们在第4部分提到的,当Redux中间件捕获到分发的action时,几乎可以执行_任何_操作:记录日志、修改action、延迟action、发起异步请求等等。由于中间件形成了围绕真实store.dispatch函数的处理管道,这意味着只要中间件能拦截并处理,我们甚至可以传递_非普通action对象_的值给dispatch

中间件还可以访问dispatchgetState。这意味着您可以在中间件中编写异步逻辑,同时仍能通过分发action与Redux store交互。

使用中间件实现异步逻辑

我们通过几个示例来了解中间件如何实现与Redux store交互的异步逻辑:

一种常见模式是编写能识别特定action类型的中间件,当检测到这些action时触发异步逻辑,例如:

import { client } from '../api/client'

const delayedActionMiddleware = storeAPI => next => action => {
if (action.type === 'todos/todoAdded') {
setTimeout(() => {
// Delay this action by one second
next(action)
}, 1000)
return
}

return next(action)
}

const fetchTodosMiddleware = storeAPI => next => action => {
if (action.type === 'todos/fetchTodos') {
// Make an API call to fetch todos from the server
client.get('todos').then(todos => {
// Dispatch an action with the todos we received
storeAPI.dispatch({ type: 'todos/todosLoaded', payload: todos })
})
}

return next(action)
}
信息

有关Redux为何及如何使用中间件处理异步逻辑的更多细节,请参阅Redux创建者Dan Abramov在StackOverflow的回答:

编写异步函数中间件

上节的两个中间件功能单一且高度定制化。理想情况下,我们希望提前编写_任意_异步逻辑(与中间件本身分离),同时仍能访问dispatchgetState来与store交互。

如果编写一个允许向dispatch传递_函数_而非action对象的中间件会怎样? 这样中间件可以检测"action"是否为函数,若是则立即执行该函数。通过这种方式,我们可以在中间件定义之外编写异步逻辑。

这种中间件的实现可能如下:

Example async function middleware
const asyncFunctionMiddleware = storeAPI => next => action => {
// If the "action" is actually a function instead...
if (typeof action === 'function') {
// then call the function and pass `dispatch` and `getState` as arguments
return action(storeAPI.dispatch, storeAPI.getState)
}

// Otherwise, it's a normal action - send it onwards
return next(action)
}

随后我们可以这样使用该中间件:

const middlewareEnhancer = applyMiddleware(asyncFunctionMiddleware)
const store = createStore(rootReducer, middlewareEnhancer)

// Write a function that has `dispatch` and `getState` as arguments
const fetchSomeData = (dispatch, getState) => {
// Make an async HTTP request
client.get('todos').then(todos => {
// Dispatch an action with the todos we received
dispatch({ type: 'todos/todosLoaded', payload: todos })
// Check the updated store state after dispatching
const allTodos = getState().todos
console.log('Number of todos after loading: ', allTodos.length)
})
}

// Pass the _function_ we wrote to `dispatch`
store.dispatch(fetchSomeData)
// logs: 'Number of todos after loading: ###'

请注意,这个"异步函数中间件"允许我们向dispatch传递_函数_! 在该函数内部,我们可以编写异步逻辑(如HTTP请求),并在请求完成后分发常规action对象。

Redux异步数据流

中间件和异步逻辑如何影响Redux应用的整体数据流?

与常规action类似,首先需要处理用户事件(如按钮点击)。然后调用dispatch()并传入_某个值_——无论是普通action对象、函数还是中间件能识别的其他值。

当被分发的值到达中间件时,它可以执行异步调用,并在调用完成后分发真正的action对象。

此前我们见过描述Redux同步数据流的示意图。在Redux应用中添加异步逻辑后,会增加中间件处理环节(如执行HTTP请求),此时异步数据流如下:

Redux 异步数据流示意图

使用Redux Thunk中间件

实际上,Redux官方已提供了这种"异步函数中间件"的实现,称为Redux "Thunk"中间件。Thunk中间件允许我们编写接收dispatchgetState作为参数的函数。这些thunk函数可包含任意异步逻辑,并能根据需要分发action和读取store状态。

将异步逻辑编写为 thunk 函数使我们能够复用该逻辑,而无需预先知道要使用哪个 Redux store

信息

"thunk" 是一个编程术语,意为"一段执行延迟工作的代码"。有关如何使用 thunk 的更多详情,请参阅 thunk 使用指南页面:

以及以下文章:

配置 Store

Redux thunk 中间件在 NPM 上以 redux-thunk 包的形式提供。我们需要安装该包以在应用中使用:

npm install redux-thunk

安装完成后,我们可以在待办事项应用中更新 Redux store 以使用该中间件:

src/store.js
import { createStore, applyMiddleware } from 'redux'
import { thunk } from 'redux-thunk'
import { composeWithDevTools } from 'redux-devtools-extension'
import rootReducer from './reducer'

const composedEnhancer = composeWithDevTools(applyMiddleware(thunk))

// The store now has the ability to accept thunk functions in `dispatch`
const store = createStore(rootReducer, composedEnhancer)
export default store

从服务器获取待办事项

目前我们的待办事项条目仅存在于客户端浏览器中。我们需要一种在应用启动时从服务器加载待办事项列表的方法。

我们将首先编写一个 thunk 函数,该函数向 /fakeApi/todos 端点发起 HTTP 调用以请求待办事项对象数组,然后分发一个包含该数组作为有效负载的 action。由于这一般与 todos 功能相关,我们将在 todosSlice.js 文件中编写该 thunk 函数:

src/features/todos/todosSlice.js
import { client } from '../../api/client'

const initialState = []

export default function todosReducer(state = initialState, action) {
// omit reducer logic
}

// Thunk function
export async function fetchTodos(dispatch, getState) {
const response = await client.get('/fakeApi/todos')
dispatch({ type: 'todos/todosLoaded', payload: response.todos })
}

我们只想在应用首次加载时发起该 API 调用。我们_可以_将其放在几个位置:

  • <App> 组件的 useEffect 钩子中

  • <TodoList> 组件的 useEffect 钩子中

  • 直接放在 index.js 文件中,紧接在导入 store 之后

现在,让我们尝试直接将其放在 index.js 中:

src/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'

import store from './store'
import { fetchTodos } from './features/todos/todosSlice'

store.dispatch(fetchTodos)

const root = createRoot(document.getElementById('root'))

root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
)

如果重新加载页面,UI 不会有可见变化。但是,如果打开 Redux DevTools 扩展,我们现在应该能看到已分发了一个 'todos/todosLoaded' action,并且它应包含由模拟服务器 API 生成的一些待办事项对象:

DevTools - todosLoaded action 内容

请注意,尽管我们已经分发了 action,但状态并未发生任何变化。我们需要在 todos reducer 中处理此 action 才能更新状态。

让我们在 reducer 中添加一个 case 来将此数据加载到 store 中。由于我们是从服务器获取数据,我们希望完全替换所有现有待办事项,因此可以返回 action.payload 数组作为新的 todos state 值:

src/features/todos/todosSlice.js
import { client } from '../../api/client'

const initialState = []

export default function todosReducer(state = initialState, action) {
switch (action.type) {
// omit other reducer cases
case 'todos/todosLoaded': {
// Replace the existing state entirely by returning the new value
return action.payload
}
default:
return state
}
}

export async function fetchTodos(dispatch, getState) {
const response = await client.get('/fakeApi/todos')
dispatch({ type: 'todos/todosLoaded', payload: response.todos })
}

由于分发 action 会立即更新 store,我们也可以在 thunk 中调用 getState 来读取分发后的更新状态值。例如,我们可以在分发 'todos/todosLoaded' action 前后将待办事项总数打印到控制台:

export async function fetchTodos(dispatch, getState) {
const response = await client.get('/fakeApi/todos')

const stateBefore = getState()
console.log('Todos before dispatch: ', stateBefore.todos.length)

dispatch({ type: 'todos/todosLoaded', payload: response.todos })

const stateAfter = getState()
console.log('Todos after dispatch: ', stateAfter.todos.length)
}

保存待办事项

我们还需要在尝试创建新待办事项时更新服务器。我们不应立即分发 'todos/todoAdded' action,而应使用初始数据向服务器发起 API 调用,等待服务器返回新保存的待办事项副本,然后_再_分发包含该待办事项的 action。

然而,如果我们尝试将此逻辑编写为 thunk 函数,会遇到一个问题:由于我们将 thunk 作为单独的函数写在 todosSlice.js 文件中,发起 API 调用的代码并不知道新待办事项的文本内容应是什么:

src/features/todos/todosSlice.js
async function saveNewTodo(dispatch, getState) {
// ❌ We need to have the text of the new todo, but where is it coming from?
const initialTodo = { text }
const response = await client.post('/fakeApi/todos', { todo: initialTodo })
dispatch({ type: 'todos/todoAdded', payload: response.todo })
}

我们需要一种方法来编写一个函数,它接受 text 作为参数,然后创建实际的 thunk 函数,使其能够使用 text 值发起 API 调用。外层函数应返回这个 thunk 函数,以便在组件中传递给 dispatch

src/features/todos/todosSlice.js
// Write a synchronous outer function that receives the `text` parameter:
export function saveNewTodo(text) {
// And then creates and returns the async thunk function:
return async function saveNewTodoThunk(dispatch, getState) {
// ✅ Now we can use the text value and send it to the server
const initialTodo = { text }
const response = await client.post('/fakeApi/todos', { todo: initialTodo })
dispatch({ type: 'todos/todoAdded', payload: response.todo })
}
}

现在我们可以在 <Header> 组件中使用它:

src/features/header/Header.js
import React, { useState } from 'react'
import { useDispatch } from 'react-redux'

import { saveNewTodo } from '../todos/todosSlice'

const Header = () => {
const [text, setText] = useState('')
const dispatch = useDispatch()

const handleChange = e => setText(e.target.value)

const handleKeyDown = e => {
// If the user pressed the Enter key:
const trimmedText = text.trim()
if (e.which === 13 && trimmedText) {
// Create the thunk function with the text the user wrote
const saveNewTodoThunk = saveNewTodo(trimmedText)
// Then dispatch the thunk function itself
dispatch(saveNewTodoThunk)
setText('')
}
}

// omit rendering output
}

由于我们知道会立即在组件中将 thunk 函数传递给 dispatch,因此可以跳过创建临时变量。相反,我们可以直接调用 saveNewTodo(text),并将生成的 thunk 函数直接传递给 dispatch

src/features/header/Header.js
const handleKeyDown = e => {
// If the user pressed the Enter key:
const trimmedText = text.trim()
if (e.which === 13 && trimmedText) {
// Create the thunk function and immediately dispatch it
dispatch(saveNewTodo(trimmedText))
setText('')
}
}

现在组件实际上并不知道它分发的是 thunk 函数——saveNewTodo 函数封装了实际发生的过程。<Header> 组件只知道当用户按下回车时需要分发_某个值_。

这种编写函数来准备将传递给 dispatch 内容的模式称为**"action creator"模式**,我们将在下一节中详细讨论。

现在我们可以看到更新后的 'todos/todoAdded' action 被分发:

Devtools - 异步 todoAdded action 内容

这里我们需要做的最后一件事是更新 todos reducer。当我们向 /fakeApi/todos 发起 POST 请求时,服务器会返回一个全新的 todo 对象(包含新 ID 值)。这意味着 reducer 不必计算新 ID 或填充其他字段——它只需创建包含新 todo 项的新 state 数组:

src/features/todos/todosSlice.js
const initialState = []

export default function todosReducer(state = initialState, action) {
switch (action.type) {
case 'todos/todoAdded': {
// Return a new todos state array with the new todo item at the end
return [...state, action.payload]
}
// omit other cases
default:
return state
}
}

现在添加新 todo 将正常工作:

Devtools - 异步 todoAdded 状态差异

技巧

Thunk 函数既可用于异步逻辑,也可用于同步逻辑。Thunks 提供了一种方法来编写需要访问 dispatchgetState 的任何可重用逻辑。

学习要点

现在我们已经成功更新了待办事项应用,可以使用"thunk"函数向模拟服务器 API 发起 HTTP 请求,从而获取待办事项列表并保存新的待办事项。

在此过程中,我们了解了 Redux 中间件如何让我们发起异步调用,并在异步调用完成后通过分发 action 与 store 交互。

当前应用效果如下:

总结
  • Redux 中间件旨在支持编写具有副作用的逻辑
    • "副作用"指在函数外部改变状态/行为的代码,如 HTTP 请求、修改函数参数或生成随机值
  • 中间件为标准 Redux 数据流添加了额外步骤
    • 中间件可以拦截传递给 dispatch 的其他值
    • 中间件可以访问 dispatchgetState,因此它们能分发更多 action 作为异步逻辑的一部分
  • Redux "Thunk"中间件允许我们将函数传递给 dispatch
    • "Thunk"函数让我们可以提前编写异步逻辑,无需知道正在使用哪个 Redux store
    • Redux thunk 函数接收 dispatchgetState 作为参数,并能分发类似"此数据来自 API 响应"的 action

下一步是什么?

现在我们已经涵盖了使用 Redux 的所有核心部分!您已了解如何:

  • 编写根据分发的 action 更新状态的 reducers

  • 使用 reducer、增强器和中间件创建并配置 Redux store

  • 使用中间件编写分发 action 的异步逻辑

第 7 部分:标准 Redux 模式中,我们将探讨真实 Redux 应用中常用的几种代码模式,以使代码更一致并在应用增长时更好地扩展。