跳至主要内容

Redux 基础知识,第 6 部分:异步逻辑和数据获取

Redux 基础知识,第 6 部分:异步逻辑和数据获取

您将学到什么
  • Redux 数据流如何与异步数据协同工作
  • 如何使用 Redux 中间件处理异步逻辑
  • 处理异步请求状态的模式
先决条件
  • 熟悉使用 AJAX 请求从服务器获取和更新数据
  • 理解 JS 中的异步逻辑,包括 Promise

简介

第 5 部分:UI 和 React 中,我们看到了如何使用 React-Redux 库让我们的 React 组件与 Redux store 交互,包括调用 useSelector 来读取 Redux 状态,调用 useDispatch 来获取 dispatch 函数,以及将我们的应用程序包装在 <Provider> 组件中,以便这些钩子可以访问 store。

到目前为止,我们使用过的一切数据都直接位于我们的 React+Redux 客户端应用程序中。但是,大多数实际应用程序需要与服务器上的数据进行交互,通过发出 HTTP API 调用来获取和保存项目。

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

注意

请注意,本教程有意展示了比我们今天教授的“现代 Redux”模式(使用 Redux Toolkit)需要更多代码的旧式 Redux 逻辑模式,以便解释 Redux 背后的原理和概念。不是一个生产就绪的项目。

请查看以下页面,了解如何使用 Redux Toolkit 的“现代 Redux”

提示

Redux Toolkit 包含 RTK Query 数据获取和缓存 API。RTK Query 是一个专门为 Redux 应用程序构建的数据获取和缓存解决方案,可以消除编写任何 thunk 或 reducer 来管理数据获取的需要。我们专门教授 RTK Query 作为数据获取的默认方法,并且 RTK Query 建立在与本页中显示的相同模式之上。

了解如何在 Redux Essentials,第 7 部分: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() 方法,类似于流行的 HTTP 库,如 axios。它在 src/api/client.js 中定义。

在本节中,我们将使用 client 对象对我们的内存中假 REST API 发出 HTTP 调用。

Redux 中间件和副作用

Redux 存储本身并不知道异步逻辑。它只知道如何同步地分发动作,通过调用根 reducer 函数更新状态,并通知 UI 有变化。任何异步操作都必须在存储之外进行。

之前我们说过 Redux reducer 绝不能包含“副作用”。“副作用”是指任何对状态或行为的更改,这些更改可以在函数返回值之外看到。一些常见的副作用类型包括:

  • 将值记录到控制台
  • 保存文件
  • 设置异步计时器
  • 发出 AJAX HTTP 请求
  • 修改函数外部存在的某些状态,或修改函数的参数
  • 生成随机数或唯一的随机 ID(例如 Math.random()Date.now()

但是,任何真实的应用程序都需要在某个地方执行这些操作。所以,如果我们不能将副作用放在 reducer 中,我们可以把它们放在哪里呢?

Redux 中间件旨在使编写具有副作用的逻辑成为可能.

正如我们在第 4 部分中所说,Redux 中间件在看到分发动作时可以做任何事情:记录一些内容,修改动作,延迟动作,进行异步调用等等。此外,由于中间件在真正的 store.dispatch 函数周围形成一个管道,这也意味着我们实际上可以将不是普通动作对象的某些内容传递给 dispatch,只要中间件拦截该值并且不将其传递给 reducer 即可。

中间件还可以访问 dispatchgetState。这意味着你可以编写一些异步逻辑,并仍然能够通过分发动作与 Redux 存储进行交互。

使用中间件启用异步逻辑

让我们看几个中间件如何使我们能够编写与 Redux 存储交互的异步逻辑的示例。

一种可能性是编写一个中间件,它查找特定的动作类型,并在看到这些动作时运行异步逻辑,例如以下示例

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)
}
info

有关 Redux 为什么以及如何使用中间件进行异步逻辑的更多详细信息,请参阅 Redux 创建者 Dan Abramov 在 StackOverflow 上的以下答案

编写异步函数中间件

上一节中的两个中间件都非常具体,只做一件事。如果我们有一种方法可以提前编写任何异步逻辑,与中间件本身分开,并且仍然可以访问dispatchgetState以便与存储交互,那就太好了。

如果我们编写一个中间件,让我们向dispatch传递一个函数,而不是一个动作对象?我们可以让我们的中间件检查“动作”是否实际上是一个函数,如果是,则立即调用该函数。这将使我们能够在中间件定义之外的单独函数中编写异步逻辑。

以下是该中间件可能的样子

异步函数中间件示例
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 请求),然后在请求完成后调度一个正常的动作对象。

Redux 异步数据流

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

就像使用普通动作一样,我们首先需要处理应用程序中的用户事件,例如单击按钮。然后,我们调用dispatch(),并传入某些内容,无论是普通动作对象、函数还是中间件可以查找的其他值。

一旦该调度值到达中间件,它就可以进行异步调用,然后在异步调用完成后调度一个真正的动作对象。

之前,我们看到了一个表示正常同步 Redux 数据流的图表。当我们在 Redux 应用程序中添加异步逻辑时,我们会添加一个额外的步骤,中间件可以在其中运行像 AJAX 请求这样的逻辑,然后调度动作。这使得异步数据流看起来像这样

Redux async data flow diagram

使用 Redux Thunk 中间件

事实证明,Redux 已经有一个官方版本的“异步函数中间件”,叫做 Redux "Thunk" 中间件。Thunk 中间件允许我们编写接收 dispatchgetState 作为参数的函数。Thunk 函数可以在内部包含任何异步逻辑,并且该逻辑可以根据需要分发操作并读取存储状态。

将异步逻辑编写为 thunk 函数允许我们在不知道事先使用的是哪个 Redux 存储的情况下重用该逻辑。.

info

"Thunk" 这个词是一个编程术语,意思是 "执行一些延迟工作的代码片段"。有关如何使用 thunk 的更多详细信息,请参阅 thunk 使用指南页面。

以及以下文章

配置存储

Redux thunk 中间件在 NPM 上作为一个名为 redux-thunk 的包提供。我们需要安装该包才能在我们的应用程序中使用它。

npm install redux-thunk

安装完成后,我们可以更新 todo 应用程序中的 Redux 存储以使用该中间件。

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

const composedEnhancer = composeWithDevTools(applyMiddleware(thunkMiddleware))

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

从服务器获取 Todos

现在我们的 todo 条目只能存在于客户端的浏览器中。我们需要一种方法在应用程序启动时从服务器加载 todo 列表。

我们将从编写一个 thunk 函数开始,该函数对我们的 /fakeApi/todos 端点进行 AJAX 调用以请求一个 todo 对象数组,然后分发一个包含该数组作为有效负载的操作。由于这与 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 文件中,在导入存储之后。

现在,让我们尝试直接在 index.js 中放置它。

src/index.js
import React from 'react'
import ReactDOM from 'react-dom'
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)

ReactDOM.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>,
document.getElementById('root')
)

如果我们重新加载页面,UI 上没有明显的改变。但是,如果我们打开 Redux DevTools 扩展,我们应该会看到一个 'todos/todosLoaded' 动作被分发了,它应该包含一些由我们伪造的服务器 API 生成的待办事项对象。

Devtools - todosLoaded action contents

注意,即使我们已经分发了动作,但没有任何改变状态。**我们需要在我们的待办事项 reducer 中处理这个动作,才能更新状态。**

让我们在 reducer 中添加一个 case 来将这些数据加载到 store 中。由于我们是从服务器获取数据,我们希望完全替换任何现有的待办事项,所以我们可以返回 action.payload 数组,使其成为新的待办事项 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 })
}

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

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' 动作,而应该使用初始数据向服务器发出 API 调用,等待服务器发送回新保存的待办事项的副本,然后使用该待办事项分发动作。

但是,如果我们开始尝试将此逻辑编写为 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 的内容的模式称为“动作创建者”模式,我们将在下一节中详细讨论。

我们现在可以看到更新的 'todos/todoAdded' 动作被分发了。

Devtools - async todoAdded action contents

我们这里需要更改的最后一件事是更新我们的待办事项 reducer。当我们向 /fakeApi/todos 发出 POST 请求时,服务器将返回一个全新的待办事项对象(包括一个新的 ID 值)。这意味着我们的 reducer 不需要计算新的 ID,也不需要填写其他字段 - 它只需要创建一个包含新待办事项的新 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
}
}

现在添加新的待办事项将正常工作。

Devtools - async todoAdded state diff

提示

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

你学到了什么

我们现在已经成功更新了我们的待办事项应用程序,以便我们可以使用“thunk”函数获取待办事项列表并保存新的待办事项,这些函数用于向我们的伪服务器 API 发出 AJAX 调用。

在此过程中,我们看到了 Redux 中间件如何用于让我们进行异步调用并通过在异步调用完成后调度操作来与存储进行交互。

以下是当前应用程序的外观

总结
  • Redux 中间件旨在使编写具有副作用的逻辑成为可能
    • “副作用”是指更改函数外部状态/行为的代码,例如 AJAX 调用、修改函数参数或生成随机值。
  • 中间件在标准 Redux 数据流中添加了一个额外的步骤。
    • 中间件可以拦截传递给 dispatch 的其他值。
    • 中间件可以访问 dispatchgetState,因此它们可以调度更多操作作为异步逻辑的一部分。
  • Redux “Thunk” 中间件允许我们将函数传递给 dispatch
    • “Thunk” 函数允许我们在不知道使用哪个 Redux 存储的情况下提前编写异步逻辑。
    • Redux thunk 函数接收 dispatchgetState 作为参数,并且可以调度诸如“从 API 响应接收此数据”之类的操作。

下一步

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

  • 编写根据调度操作更新状态的 reducer,
  • 使用 reducer、增强器和中间件创建和配置 Redux 存储。
  • 使用中间件编写异步逻辑来分发动作

第 7 部分:标准 Redux 模式 中,我们将介绍一些通常由真实世界 Redux 应用程序使用的代码模式,这些模式可以使我们的代码更加一致,并在应用程序增长时更好地扩展。