跳至主要内容

Redux Essentials,第 5 部分:异步逻辑和数据获取

您将学到什么
  • 如何使用 Redux "thunk" 中间件进行异步逻辑
  • 处理异步请求状态的模式
  • 如何使用 Redux Toolkit 的 createAsyncThunk API 简化异步调用
先决条件
  • 熟悉使用 AJAX 请求从服务器获取和更新数据

简介

第 4 部分:使用 Redux 数据 中,我们了解了如何在 React 组件中使用 Redux 存储中的多个数据片段,在分派操作对象之前自定义其内容,以及在 reducer 中处理更复杂更新逻辑。

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

在本节中,我们将把我们的社交媒体应用程序转换为从 API 获取帖子和用户数据,并通过将新帖子保存到 API 来添加新帖子。

提示

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

我们将在 第 7 部分:RTK Query 基础 中介绍如何使用 RTK Query。

示例 REST API 和客户端

为了使示例项目保持隔离但现实,初始项目设置已经包含一个用于我们数据的模拟内存中 REST API(使用 Mock Service Worker 模拟 API 工具 配置)。API 使用 /fakeApi 作为端点的基本 URL,并支持 /fakeApi/posts/fakeApi/usersfakeApi/notifications 的典型 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 请求。

此外,模拟服务器已设置为每次页面加载时重用相同的随机种子,以便它生成相同的假用户和假帖子列表。如果您想重置它,请删除浏览器本地存储中的 'randomTimestampSeed' 值并重新加载页面,或者您可以通过编辑 src/api/server.js 并将 useSeededRNG 设置为 false 来关闭它。

信息

提醒一下,代码示例侧重于每个部分的关键概念和更改。有关应用程序中的完整更改,请参阅 CodeSandbox 项目和 项目仓库中的 tutorial-steps 分支

Thunk 和异步逻辑

使用中间件启用异步逻辑

Redux 存储本身并不知道任何关于异步逻辑的信息。它只知道如何同步地分派操作、通过调用根 reducer 函数来更新状态,以及通知 UI 发生了更改。任何异步操作都必须在存储之外进行。

但是,如果您想让异步逻辑通过分派操作或检查当前存储状态来与存储交互怎么办?这就是 Redux 中间件 的用武之地。它们扩展了存储,并允许您

  • 在分派任何操作时执行额外的逻辑(例如记录操作和状态)
  • 暂停、修改、延迟、替换或停止分派的 action
  • 编写可以访问 dispatchgetState 的额外代码
  • 通过拦截它们并分派真正的 action 对象,来教 dispatch 如何接受除了普通 action 对象之外的其他值,例如函数和 promise

使用中间件最常见的原因是允许不同类型的异步逻辑与存储交互。这使您可以编写可以分派操作和检查存储状态的代码,同时将该逻辑与 UI 分开。

Redux 有许多类型的异步中间件,每种中间件都允许您使用不同的语法编写逻辑。最常见的异步中间件是 redux-thunk,它允许您直接编写可能包含异步逻辑的普通函数。Redux Toolkit 的 configureStore 函数 默认情况下会自动设置 thunk 中间件,并且 我们建议使用 thunk 作为使用 Redux 编写异步逻辑的标准方法

之前,我们看到了Redux 的同步数据流是什么样的。当我们引入异步逻辑时,我们添加了一个额外的步骤,中间件可以在其中运行像 AJAX 请求这样的逻辑,然后分发动作。这使得异步数据流看起来像这样

Redux async data flow diagram

Thunk 函数

一旦 thunk 中间件被添加到 Redux 存储中,它就允许你直接将thunk 函数传递给store.dispatch。thunk 函数将始终以(dispatch, getState)作为其参数被调用,你可以在 thunk 中根据需要使用它们。

Thunk 通常使用动作创建者分发普通动作,例如dispatch(increment())

const store = configureStore({ reducer: counterReducer })

const exampleThunkFunction = (dispatch, getState) => {
const stateBefore = getState()
console.log(`Counter before: ${stateBefore.counter}`)
dispatch(increment())
const stateAfter = getState()
console.log(`Counter after: ${stateAfter.counter}`)
}

store.dispatch(exampleThunkFunction)

为了与分发普通动作对象保持一致,我们通常将这些写成thunk 动作创建者,它们返回 thunk 函数。这些动作创建者可以接受可以在 thunk 内部使用的参数。

const logAndAdd = amount => {
return (dispatch, getState) => {
const stateBefore = getState()
console.log(`Counter before: ${stateBefore.counter}`)
dispatch(incrementByAmount(amount))
const stateAfter = getState()
console.log(`Counter after: ${stateAfter.counter}`)
}
}

store.dispatch(logAndAdd(5))

Thunk 通常写在“切片”文件中。createSlice本身没有对定义 thunk 的任何特殊支持,因此你应该将它们作为同一个切片文件中的单独函数编写。这样,它们就可以访问该切片的普通动作创建者,并且很容易找到 thunk 所在的位置。

信息

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

以及以下文章

编写异步 Thunk

Thunk 可能在内部包含异步逻辑,例如setTimeoutPromiseasync/await。这使得它们成为放置对服务器 API 的 AJAX 调用的好地方。

Redux 的数据获取逻辑通常遵循一个可预测的模式

  • 在请求之前分发一个“开始”动作,以指示请求正在进行。这可以用于跟踪加载状态,以允许跳过重复请求或在 UI 中显示加载指示器。
  • 发出异步请求
  • 根据请求结果,异步逻辑会分发一个包含结果数据的“成功”操作,或一个包含错误详细信息的“失败”操作。在两种情况下,reducer 逻辑都会清除加载状态,并处理成功情况下的结果数据,或存储错误值以备将来显示。

这些步骤不是必需的,但通常使用。(如果你只关心成功的结果,你可以在请求完成后只分发一个“成功”操作,并跳过“开始”和“失败”操作。)

Redux Toolkit 提供了一个createAsyncThunk API 来实现这些操作的创建和分发,我们很快就会看看如何使用它。

详细说明:在 Thunk 中分发请求状态操作

如果我们要手动编写典型异步 thunk 的代码,它可能看起来像这样

const getRepoDetailsStarted = () => ({
type: 'repoDetails/fetchStarted'
})
const getRepoDetailsSuccess = repoDetails => ({
type: 'repoDetails/fetchSucceeded',
payload: repoDetails
})
const getRepoDetailsFailed = error => ({
type: 'repoDetails/fetchFailed',
error
})
const fetchIssuesCount = (org, repo) => async dispatch => {
dispatch(getRepoDetailsStarted())
try {
const repoDetails = await getRepoDetails(org, repo)
dispatch(getRepoDetailsSuccess(repoDetails))
} catch (err) {
dispatch(getRepoDetailsFailed(err.toString()))
}
}

但是,使用这种方法编写代码很繁琐。每种类型的请求都需要重复类似的实现

  • 需要为三种不同的情况定义唯一的操作类型
  • 每种操作类型通常都有一个相应的操作创建器函数
  • 需要编写一个 thunk 来按正确顺序分发正确的操作

createAsyncThunk 通过生成操作类型和操作创建器,并生成自动分发这些操作的 thunk 来抽象出这种模式。你提供一个回调函数来进行异步调用并返回包含结果的 Promise。


加载帖子

到目前为止,我们的postsSlice 使用了一些硬编码的示例数据作为其初始状态。我们将把它改为从一个空的帖子数组开始,然后从服务器获取帖子列表。

为了做到这一点,我们必须改变postsSlice 中状态的结构,以便我们可以跟踪 API 请求的当前状态。

提取帖子选择器

现在,postsSlice 状态是一个包含posts 数组的单个数组。我们需要把它改为一个对象,它包含posts 数组,以及加载状态字段。

同时,像<PostsList>这样的 UI 组件正在尝试从state.posts中读取帖子,并在它们的useSelector钩子中假设该字段是一个数组。我们需要更改这些位置以匹配新的数据。

如果我们不必每次在 reducer 中更改数据格式时都重写组件,那就太好了。避免这种情况的一种方法是在切片文件中定义可重用的选择器函数,让组件使用这些选择器来提取它们需要的数据,而不是在每个组件中重复选择器逻辑。这样,如果我们再次更改状态结构,我们只需要更新切片文件中的代码。

<PostsList>组件需要读取所有帖子的列表,而<SinglePostPage><EditPostForm>组件需要通过其 ID 查找单个帖子。让我们从postsSlice.js导出两个小的选择器函数来涵盖这些情况

features/posts/postsSlice.js
const postsSlice = createSlice(/* omit slice code*/)

export const { postAdded, postUpdated, reactionAdded } = postsSlice.actions

export default postsSlice.reducer

export const selectAllPosts = state => state.posts

export const selectPostById = (state, postId) =>
state.posts.find(post => post.id === postId)

请注意,这些选择器函数的state参数是根 Redux 状态对象,就像我们在useSelector内部直接编写的内联匿名选择器一样。

然后我们可以在组件中使用它们

features/posts/PostsList.js
// omit imports
import { selectAllPosts } from './postsSlice'

export const PostsList = () => {
const posts = useSelector(selectAllPosts)
// omit component contents
}
features/posts/SinglePostPage.js
// omit imports
import { selectPostById } from './postsSlice'

export const SinglePostPage = ({ match }) => {
const { postId } = match.params

const post = useSelector(state => selectPostById(state, postId))
// omit component logic
}
features/posts/EditPostForm.js
// omit imports
import { postUpdated, selectPostById } from './postsSlice'

export const EditPostForm = ({ match }) => {
const { postId } = match.params

const post = useSelector(state => selectPostById(state, postId))
// omit component logic
}

通常,通过编写可重用的选择器来封装数据查找是一个好主意。您还可以创建“记忆”选择器,可以帮助提高性能,我们将在本教程的后面部分介绍。

但是,就像任何抽象一样,这不是你应该一直到处都做的事情。编写选择器意味着需要理解和维护更多代码。不要觉得你需要为状态的每个字段都编写选择器。尝试从不使用任何选择器开始,并在发现自己在应用程序代码的许多部分中查找相同的值时,稍后添加一些选择器。

请求的加载状态

当我们进行 API 调用时,我们可以将它的进度视为一个小的状态机,它可以处于四种可能状态中的一种

  • 请求尚未开始
  • 请求正在进行中
  • 请求成功,我们现在拥有了所需的数据
  • 请求失败,可能存在错误消息

我们可以使用一些布尔值来跟踪这些信息,比如isLoading: true,但最好将这些状态跟踪为单个枚举值。一个好的模式是拥有一个看起来像这样的状态部分(使用 TypeScript 类型表示法)

{
// Multiple possible status enum values
status: 'idle' | 'loading' | 'succeeded' | 'failed',
error: string | null
}

这些字段将与正在存储的任何实际数据并存。这些特定的字符串状态名称不是必需的 - 如果你愿意,可以使用其他名称,比如'pending'而不是'loading',或者'complete'而不是'succeeded'

我们可以使用这些信息来决定在请求进行时在 UI 中显示什么,并在我们的 reducer 中添加逻辑来防止重复加载数据的情况。

让我们更新我们的 postsSlice 以使用这种模式来跟踪“获取帖子”请求的加载状态。我们将把我们的状态从仅包含帖子数组更改为类似 {posts, status, error} 的形式。我们还将从初始状态中删除旧的示例帖子条目。作为此更改的一部分,我们还需要将任何使用 state 作为数组的用法更改为 state.posts,因为数组现在深了一层。

features/posts/postsSlice.js
import { createSlice, nanoid } from '@reduxjs/toolkit'

const initialState = {
posts: [],
status: 'idle',
error: null
}

const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
reducer(state, action) {
state.posts.push(action.payload)
},
prepare(title, content, userId) {
// omit prepare logic
}
},
reactionAdded(state, action) {
const { postId, reaction } = action.payload
const existingPost = state.posts.find(post => post.id === postId)
if (existingPost) {
existingPost.reactions[reaction]++
}
},
postUpdated(state, action) {
const { id, title, content } = action.payload
const existingPost = state.posts.find(post => post.id === id)
if (existingPost) {
existingPost.title = title
existingPost.content = content
}
}
}
})

export const { postAdded, postUpdated, reactionAdded } = postsSlice.actions

export default postsSlice.reducer

export const selectAllPosts = state => state.posts.posts

export const selectPostById = (state, postId) =>
state.posts.posts.find(post => post.id === postId)

是的,这确实意味着我们现在有一个嵌套对象路径,看起来像 state.posts.posts,这有点重复且愚蠢 :) 如果我们想避免这种情况,我们可以将嵌套数组名称更改为 itemsdata 或其他名称,但我们现在将保持原样。

使用 createAsyncThunk 获取数据

Redux Toolkit 的 createAsyncThunk API 生成 thunk,这些 thunk 会自动为您调度这些“开始/成功/失败”操作。

让我们从添加一个 thunk 开始,该 thunk 将进行 AJAX 调用以检索帖子列表。我们将从 src/api 文件夹导入 client 实用程序,并使用它向 '/fakeApi/posts' 发出请求。

features/posts/postsSlice
import { createSlice, nanoid, createAsyncThunk } from '@reduxjs/toolkit'
import { client } from '../../api/client'

const initialState = {
posts: [],
status: 'idle',
error: null
}

export const fetchPosts = createAsyncThunk('posts/fetchPosts', async () => {
const response = await client.get('/fakeApi/posts')
return response.data
})

createAsyncThunk 接受两个参数

  • 一个字符串,将用作生成的 action 类型的前缀
  • 一个“有效负载创建者”回调函数,该函数应返回包含一些数据的 Promise,或返回带有错误的拒绝的 Promise

有效负载创建者通常会进行某种 AJAX 调用,并且可以返回 AJAX 调用的 Promise 本身,或者从 API 响应中提取一些数据并返回它。我们通常使用 JS async/await 语法编写它,这使我们能够编写使用 Promise 的函数,同时使用标准 try/catch 逻辑而不是 somePromise.then() 链。

在本例中,我们将 'posts/fetchPosts' 作为 action 类型前缀传入。我们的有效负载创建回调等待 API 调用返回响应。响应对象看起来像 {data: []},我们希望我们分派的 Redux action 具有一个有效负载,该有效负载仅仅是帖子数组。因此,我们提取 response.data,并将其从回调中返回。

如果我们尝试调用 dispatch(fetchPosts())fetchPosts thunk 将首先调度一个类型为 'posts/fetchPosts/pending' 的 action。

`createAsyncThunk`: posts pending action

我们可以在 reducer 中监听此 action,并将请求状态标记为 'loading'

Promise 解析后,fetchPosts thunk 会获取我们在回调函数中返回的 response.data 数组,并分发一个包含帖子数组作为 action.payload'posts/fetchPosts/fulfilled' 动作。

`createAsyncThunk`: posts pending action

从组件中分发 Thunks

因此,让我们更新我们的 <PostsList> 组件,以便自动为我们获取这些数据。

我们将把 fetchPosts thunk 导入到组件中。与我们所有其他动作创建者一样,我们必须分发它,因此我们还需要添加 useDispatch 钩子。由于我们希望在 <PostsList> 挂载时获取这些数据,因此我们需要导入 React useEffect 钩子。

features/posts/PostsList.js
import React, { useEffect } from 'react'
import { useSelector, useDispatch } from 'react-redux'
// omit other imports
import { selectAllPosts, fetchPosts } from './postsSlice'

export const PostsList = () => {
const dispatch = useDispatch()
const posts = useSelector(selectAllPosts)

const postStatus = useSelector(state => state.posts.status)

useEffect(() => {
if (postStatus === 'idle') {
dispatch(fetchPosts())
}
}, [postStatus, dispatch])

// omit rendering logic
}

重要的是,我们只尝试获取一次帖子列表。如果我们每次渲染 <PostsList> 组件,或者因为我们在视图之间切换而重新创建它,我们可能会最终多次获取帖子。我们可以使用 posts.status 枚举来帮助决定是否需要实际开始获取,方法是将其选择到组件中,并且只有在状态为 'idle' 时才开始获取。

Reducers 和加载动作

接下来,我们需要在我们的 reducers 中处理这两个动作。这需要更深入地了解我们一直在使用的 createSlice API。

我们已经看到,createSlice 会为我们在 reducers 字段中定义的每个 reducer 函数生成一个动作创建者,并且生成的动作类型包括切片的名称,例如

console.log(
postUpdated({ id: '123', title: 'First Post', content: 'Some text here' })
)
/*
{
type: 'posts/postUpdated',
payload: {
id: '123',
title: 'First Post',
content: 'Some text here'
}
}
*/

但是,有时切片 reducer 需要响应其他未定义为该切片 reducers 字段一部分的动作。我们可以使用切片的 extraReducers 字段来做到这一点。

extraReducers 选项应该是一个接收名为 builder 的参数的函数。builder 对象提供了一些方法,让我们可以定义额外的 case reducers,这些 reducers 将响应在切片之外定义的动作。我们将使用 builder.addCase(actionCreator, reducer) 来处理我们的异步 thunk 分发的每个动作。

详细解释:向切片添加额外的 Reducers

extraReducers 中的 builder 对象提供了方法,让我们可以定义额外的 case reducers,这些 reducers 将在响应切片之外定义的动作时运行。

  • builder.addCase(actionCreator, reducer):定义一个 case reducer,它处理基于 RTK action creator 或普通 action 类型字符串的单个已知 action 类型。
  • builder.addMatcher(matcher, reducer):定义一个 case reducer,它可以在 matcher 函数返回 true 的任何 action 响应时运行。
  • builder.addDefaultCase(reducer):定义一个 case reducer,如果此 action 没有执行其他 case reducers,它将运行。

您可以将这些方法链接在一起,例如 builder.addCase().addCase().addMatcher().addDefaultCase()。如果多个匹配器匹配 action,它们将按照定义的顺序运行。

import { increment } from '../features/counter/counterSlice'

const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
// slice-specific reducers here
},
extraReducers: builder => {
builder
.addCase('counter/decrement', (state, action) => {})
.addCase(increment, (state, action) => {})
}
})

在这种情况下,我们需要监听 fetchPosts thunk 分派的 "pending" 和 "fulfilled" action 类型。这些 action creator 附加到我们实际的 fetchPost 函数,我们可以将它们传递给 extraReducers 来监听这些 action。

export const fetchPosts = createAsyncThunk('posts/fetchPosts', async () => {
const response = await client.get('/fakeApi/posts')
return response.data
})

const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
// omit existing reducers here
},
extraReducers(builder) {
builder
.addCase(fetchPosts.pending, (state, action) => {
state.status = 'loading'
})
.addCase(fetchPosts.fulfilled, (state, action) => {
state.status = 'succeeded'
// Add any fetched posts to the array
state.posts = state.posts.concat(action.payload)
})
.addCase(fetchPosts.rejected, (state, action) => {
state.status = 'failed'
state.error = action.error.message
})
}
})

我们将处理 thunk 可能分派的这三种 action 类型,这些类型基于我们返回的 Promise

  • 当请求开始时,我们将 status 枚举设置为 'loading'
  • 如果请求成功,我们将 status 标记为 'succeeded',并将获取的帖子添加到 state.posts 中。
  • 如果请求失败,我们将 status 标记为 'failed',并将任何错误消息保存到状态中,以便我们可以显示它。

显示加载状态

我们的 <PostsList> 组件已经检查了存储在 Redux 中的帖子的任何更新,并在该列表更改时重新渲染自身。因此,如果我们刷新页面,我们应该看到来自我们伪造的 API 的一组随机帖子显示在屏幕上。

我们使用的伪造 API 会立即返回数据。但是,真正的 API 调用可能需要一些时间才能返回响应。通常最好在 UI 中显示某种 "loading..." 指示器,以便用户知道我们正在等待数据。

我们可以更新我们的 <PostsList>,以根据 state.posts.status 枚举显示不同的 UI 部分:如果正在加载,则显示一个旋转器;如果失败,则显示错误消息;如果我们有数据,则显示实际的帖子列表。趁此机会,现在可能是提取 <PostExcerpt> 组件以封装列表中一项的渲染的好时机。

结果可能如下所示

features/posts/PostsList.js
import { Spinner } from '../../components/Spinner'
import { PostAuthor } from './PostAuthor'
import { TimeAgo } from './TimeAgo'
import { ReactionButtons } from './ReactionButtons'
import { selectAllPosts, fetchPosts } from './postsSlice'

const PostExcerpt = ({ post }) => {
return (
<article className="post-excerpt">
<h3>{post.title}</h3>
<div>
<PostAuthor userId={post.user} />
<TimeAgo timestamp={post.date} />
</div>
<p className="post-content">{post.content.substring(0, 100)}</p>

<ReactionButtons post={post} />
<Link to={`/posts/${post.id}`} className="button muted-button">
View Post
</Link>
</article>
)
}

export const PostsList = () => {
const dispatch = useDispatch()
const posts = useSelector(selectAllPosts)

const postStatus = useSelector(state => state.posts.status)
const error = useSelector(state => state.posts.error)

useEffect(() => {
if (postStatus === 'idle') {
dispatch(fetchPosts())
}
}, [postStatus, dispatch])

let content

if (postStatus === 'loading') {
content = <Spinner text="Loading..." />
} else if (postStatus === 'succeeded') {
// Sort posts in reverse chronological order by datetime string
const orderedPosts = posts
.slice()
.sort((a, b) => b.date.localeCompare(a.date))

content = orderedPosts.map(post => (
<PostExcerpt key={post.id} post={post} />
))
} else if (postStatus === 'failed') {
content = <div>{error}</div>
}

return (
<section className="posts-list">
<h2>Posts</h2>
{content}
</section>
)
}

您可能会注意到 API 调用需要一段时间才能完成,并且加载动画会停留在屏幕上几秒钟。我们的模拟 API 服务器配置为向所有响应添加 2 秒的延迟,专门用于帮助可视化出现加载动画的时间。如果您想更改此行为,可以打开 api/server.js,并更改此行

api/server.js
// Add an extra delay to all endpoints, so loading spinners show up.
const ARTIFICIAL_DELAY_MS = 2000

您可以随时打开或关闭它,如果您希望 API 调用更快完成。

加载用户

我们现在正在获取和显示我们的帖子列表。但是,如果我们查看帖子,就会发现一个问题:它们现在都显示为“未知作者”作为作者

Unknown post authors

这是因为帖子条目是由假的 API 服务器随机生成的,该服务器在每次我们重新加载页面时也会随机生成一组假的用户。我们需要更新我们的用户切片,以便在应用程序启动时获取这些用户。

与上次一样,我们将创建一个异步 thunk 来从 API 获取用户并返回它们,然后在 extraReducers 切片字段中处理 fulfilled 操作。我们现在将跳过担心加载状态

features/users/usersSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import { client } from '../../api/client'

const initialState = []

export const fetchUsers = createAsyncThunk('users/fetchUsers', async () => {
const response = await client.get('/fakeApi/users')
return response.data
})

const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {},
extraReducers(builder) {
builder.addCase(fetchUsers.fulfilled, (state, action) => {
return action.payload
})
}
})

export default usersSlice.reducer

您可能已经注意到,这次 case reducer 根本没有使用 state 变量。相反,我们直接返回 action.payload。Immer 允许我们通过两种方式更新状态:要么修改现有状态值,要么返回一个新结果。如果我们返回一个新值,它将完全用我们返回的内容替换现有状态。(请注意,如果您想手动返回一个新值,您需要编写任何可能需要的不可变更新逻辑。)

在本例中,初始状态是一个空数组,我们可能可以使用 state.push(...action.payload) 来修改它。但是,在我们的例子中,我们真的想用服务器返回的内容替换用户列表,这避免了在状态中意外复制用户列表的可能性。

信息

要了解有关 Immer 如何更新状态的更多信息,请参阅 RTK 文档中的“使用 Immer 编写 Reducer”指南

我们只需要获取一次用户列表,并且我们希望在应用程序启动时立即执行此操作。我们可以在 index.js 文件中执行此操作,并直接调度 fetchUsers thunk,因为我们有 store

index.js
// omit other imports

import store from './app/store'
import { fetchUsers } from './features/users/usersSlice'

import { worker } from './api/server'

async function main() {
// Start our mock API server
await worker.start({ onUnhandledRequest: 'bypass' })

store.dispatch(fetchUsers())

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

现在,每个帖子都应该再次显示用户名,我们还应该在 <AddPostForm> 中的“作者”下拉列表中显示相同的用户列表。

添加新帖子

本节还有一个步骤。当我们从 <AddPostForm> 添加新帖子时,该帖子只会被添加到我们应用程序中的 Redux 存储中。我们需要实际进行一个 API 调用,该调用将在我们的假 API 服务器中创建新的帖子条目,以便“保存”它。(由于这是一个假的 API,如果我们重新加载页面,新帖子将不会持久化,但如果我们有一个真正的后端服务器,它将在下次我们重新加载时可用。)

使用 Thunk 发送数据

我们可以使用createAsyncThunk来帮助发送数据,而不仅仅是获取数据。我们将创建一个thunk,它接受来自我们<AddPostForm>的值作为参数,并向假 API 发出 HTTP POST 请求以保存数据。

在此过程中,我们将改变我们在 reducer 中处理新帖子对象的方式。目前,我们的postsSlicepostAddedprepare回调中创建了一个新的帖子对象,并为该帖子生成了一个新的唯一 ID。在大多数将数据保存到服务器的应用程序中,服务器将负责生成唯一 ID 并填写任何额外的字段,并且通常会在其响应中返回完成的数据。因此,我们可以向服务器发送类似{ title, content, user: userId }的请求主体,然后获取它发回的完整帖子对象并将其添加到我们的postsSlice状态中。

features/posts/postsSlice.js
export const addNewPost = createAsyncThunk(
'posts/addNewPost',
// The payload creator receives the partial `{title, content, user}` object
async initialPost => {
// We send the initial data to the fake API server
const response = await client.post('/fakeApi/posts', initialPost)
// The response includes the complete post object, including unique ID
return response.data
}
)

const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
// The existing `postAdded` reducer and prepare callback were deleted
reactionAdded(state, action) {}, // omit logic
postUpdated(state, action) {} // omit logic
},
extraReducers(builder) {
// omit posts loading reducers
builder.addCase(addNewPost.fulfilled, (state, action) => {
// We can directly add the new post object to our posts array
state.posts.push(action.payload)
})
}
})

在组件中检查 Thunk 结果

最后,我们将更新<AddPostForm>以分派addNewPost thunk 而不是旧的postAdded操作。由于这是对服务器的另一个 API 调用,它将需要一些时间,并且可能会失败。addNewPost() thunk 将自动将它的pending/fulfilled/rejected操作分派到 Redux 存储中,我们已经处理了这些操作。我们可以使用第二个加载枚举在postsSlice中跟踪请求状态,但在这个例子中,让我们将加载状态跟踪限制在组件中。

如果我们可以在等待请求时至少禁用“保存帖子”按钮,那就太好了,这样用户就不会意外地尝试两次保存帖子。如果请求失败,我们可能还想在表单中显示错误消息,或者只是将其记录到控制台。

我们可以让我们的组件逻辑等待异步 thunk 完成,并在完成时检查结果

features/posts/AddPostForm.js
import React, { useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'

import { addNewPost } from './postsSlice'

export const AddPostForm = () => {
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [userId, setUserId] = useState('')
const [addRequestStatus, setAddRequestStatus] = useState('idle')

// omit useSelectors and change handlers

const canSave =
[title, content, userId].every(Boolean) && addRequestStatus === 'idle'

const onSavePostClicked = async () => {
if (canSave) {
try {
setAddRequestStatus('pending')
await dispatch(addNewPost({ title, content, user: userId })).unwrap()
setTitle('')
setContent('')
setUserId('')
} catch (err) {
console.error('Failed to save the post: ', err)
} finally {
setAddRequestStatus('idle')
}
}
}

// omit rendering logic
}

我们可以添加一个加载状态枚举字段作为 React useState hook,类似于我们在postsSlice中跟踪获取帖子的加载状态的方式。在这种情况下,我们只想了解请求是否正在进行。

当我们调用dispatch(addNewPost())时,异步 thunk 从dispatch返回一个Promise。我们可以在此处await该 promise 以了解 thunk 何时完成其请求。但是,我们还不知道该请求是成功还是失败。

createAsyncThunk在内部处理任何错误,因此我们在日志中看不到任何关于“拒绝的 Promise”的消息。然后,它返回它分派的最终操作:如果成功,则为fulfilled操作,如果失败,则为rejected操作。

然而,通常需要编写逻辑来查看实际请求的成功或失败。Redux Toolkit 在返回的 Promise 中添加了一个 .unwrap() 函数,该函数将返回一个新的 Promise,该 Promise 或者包含来自 fulfilled 操作的实际 action.payload 值,或者在它是 rejected 操作时抛出错误。这使我们能够使用正常的 try/catch 逻辑在组件中处理成功和失败。因此,如果帖子成功创建,我们将清除输入字段以重置表单,如果失败,则将错误记录到控制台。

如果您想查看 addNewPost API 调用失败时会发生什么,请尝试创建一个新的帖子,其中“内容”字段仅包含单词“error”(不带引号)。服务器将看到这一点并发送失败的响应,因此您应该会看到一条消息记录到控制台。

您学到了什么

异步逻辑和数据获取始终是一个复杂的话题。正如您所见,Redux Toolkit 包含一些工具来自动化典型的 Redux 数据获取模式。

以下是我们现在从那个假 API 获取数据后的应用程序的样子

提醒一下,以下是本节中涵盖的内容

总结
  • 您可以编写可重用的“选择器”函数来封装从 Redux 状态读取值
    • 选择器是接收 Redux state 作为参数并返回一些数据的函数
  • Redux 使用称为“中间件”的插件来启用异步逻辑
    • 标准异步中间件称为 redux-thunk,它包含在 Redux Toolkit 中
    • Thunk 函数接收 dispatchgetState 作为参数,并且可以使用它们作为异步逻辑的一部分
  • 您可以调度其他操作来帮助跟踪 API 调用的加载状态
    • 典型的模式是在调用之前调度一个“pending”操作,然后调度一个包含数据的“success”操作或一个包含错误的“failure”操作
    • 加载状态通常应该存储为枚举,例如 'idle' | 'loading' | 'succeeded' | 'failed'
  • Redux Toolkit 有一个 createAsyncThunk API,它可以为你分发这些操作
    • createAsyncThunk 接受一个“有效载荷创建器”回调函数,该函数应该返回一个 Promise,并自动生成 pending/fulfilled/rejected 操作类型
    • 生成的 action creators,例如 fetchPosts,会根据你返回的 Promise 分发这些操作
    • 你可以在 createSlice 中使用 extraReducers 字段监听这些操作类型,并根据这些操作在 reducers 中更新状态。
    • action creators 可以用来自动填充 extraReducers 对象的键,以便 slice 知道要监听哪些操作。
    • Thunk 可以返回 promises。对于 createAsyncThunk 来说,你可以 await dispatch(someThunk()).unwrap() 在组件级别处理请求成功或失败。

下一步

我们还有最后一组主题要涵盖 Redux Toolkit 的核心 API 和使用模式。在 第 6 部分:性能和数据规范化 中,我们将探讨 Redux 使用如何影响 React 性能,以及一些优化应用程序以提高性能的方法。