Redux 要点,第 5 部分:异步逻辑和数据获取
如何使用 Redux "thunk" 中间件进行异步逻辑
¥How to use the Redux "thunk" middleware for async logic
处理异步请求状态的模式
¥Patterns for handling async request state
如何使用 Redux Toolkit
createAsyncThunk
API 管理异步调用¥How to use the Redux Toolkit
createAsyncThunk
API to manage async calls
熟悉使用 HTTP 请求从服务器 REST API 获取和更新数据
¥Familiarity with using HTTP requests to fetch and update data from a server REST API
介绍
¥Introduction
在 第 4 部分:使用 Redux 数据 中,我们了解了如何在 React 组件内部使用来自 Redux 存储的多条数据,在分派操作对象之前自定义操作对象的内容,以及在我们的 reducer 中处理更复杂的更新逻辑。
¥In Part 4: Using Redux Data, we saw how to use multiple pieces of data from the Redux store inside of React components, customize the contents of action objects before they're dispatched, and handle more complex update logic in our reducers.
到目前为止,我们使用的所有数据都直接位于 React 客户端应用内部。然而,大多数实际应用需要通过调用 HTTP API 来获取和保存项目来处理来自服务器的数据。
¥So far, all the data we've worked with has been directly inside of our React client application. However, most real applications need to work with data from a server, by making HTTP API calls to fetch and save items.
在本部分中,我们将转换社交媒体应用以从 API 获取帖子和用户数据,并通过将新帖子保存到 API 来添加新帖子。
¥In this section, we'll convert our social media app to fetch the posts and users data from an API, and add new posts by saving them to the API.
Redux 工具包包括 RTK 查询数据获取和缓存 API。RTK Query 是专门为 Redux 应用构建的数据获取和缓存解决方案,无需编写任何额外的 Redux 逻辑(如 thunk 或 Reducer)来管理数据获取。我们专门教授 RTK 查询作为数据获取的默认方法。
¥Redux Toolkit includes the RTK Query data fetching and caching API. RTK Query is a purpose built data fetching and caching solution for Redux apps, and can eliminate the need to write any additional Redux logic like thunks or reducers to manage data fetching. We specifically teach RTK Query as the default approach for data fetching.
RTK Query 是基于本页中显示的相同模式构建的,因此本节将帮助你了解数据获取如何与 Redux 协同工作的底层机制。
¥RTK Query is built on the same patterns shown in this page, so this section will help you understand the underlying mechanics of how data fetching works with Redux.
我们将从 第 7 部分:RTK 查询基础知识 开始介绍如何使用 RTK 查询。
¥We'll cover how to use RTK Query starting in Part 7: RTK Query Basics.
REST API 和客户端示例
¥Example REST API and Client
为了保持示例项目的独立性和现实性,初始项目设置已经包含了一个用于数据的虚拟内存中 REST API(使用 Mock Service Worker 模拟 API 工具 配置)。该 API 使用 /fakeApi
作为端点的基本 URL,并支持 /fakeApi/posts
、/fakeApi/users
和 fakeApi/notifications
的典型 GET/POST/PUT/DELETE
HTTP 方法。它在 src/api/server.ts
中定义。
¥To keep the example project isolated but realistic, the initial project setup already includes a fake in-memory REST API for our data (configured using the Mock Service Worker mock API tool). The API uses /fakeApi
as the base URL for the endpoints, and supports the typical GET/POST/PUT/DELETE
HTTP methods for /fakeApi/posts
, /fakeApi/users
, and fakeApi/notifications
. It's defined in src/api/server.ts
.
该项目还包括一个小型 HTTP API 客户端对象,它公开 client.get()
和 client.post()
方法,类似于 axios
等流行的 HTTP 库。它在 src/api/client.ts
中定义。
¥The project also includes a small HTTP API client object that exposes client.get()
and client.post()
methods, similar to popular HTTP libraries like axios
. It's defined in src/api/client.ts
.
在本节中,我们将使用 client
对象对内存中的虚假 REST API 进行 HTTP 调用。
¥We'll use the client
object to make HTTP calls to our in-memory fake REST API for this section.
此外,模拟服务器已设置为每次加载页面时重用相同的随机种子,以便它将生成相同的假用户和假帖子列表。如果你想重置该功能,请删除浏览器本地存储中的 'randomTimestampSeed'
值并重新加载页面,或者你可以通过编辑 src/api/server.ts
并将 useSeededRNG
设置为 false
来关闭该功能。
¥Also, the mock server has been set up to reuse the same random seed each time the page is loaded, so that it will generate the same list of fake users and fake posts. If you want to reset that, delete the 'randomTimestampSeed'
value in your browser's Local Storage and reload the page, or you can turn that off by editing src/api/server.ts
and setting useSeededRNG
to false
.
提醒一下,代码示例重点关注每个部分的关键概念和更改。请参阅 CodeSandbox 项目和 项目仓库中的 tutorial-steps
分支 以了解应用中的完整更改。
¥As a reminder, the code examples focus on the key concepts and changes for each section. See the CodeSandbox projects and the tutorial-steps
branch in the project repo for the complete changes in the application.
使用中间件启用异步逻辑
¥Using Middleware to Enable Async Logic
Redux 存储本身并不了解异步逻辑。它只知道如何同步分派操作,通过调用根 reducer 函数更新状态,并通知 UI 某些内容发生了变化。任何异步性都必须发生在存储之外。
¥By itself, a Redux store doesn't know anything about async logic. It only knows how to synchronously dispatch actions, update the state by calling the root reducer function, and notify the UI that something has changed. Any asynchronicity has to happen outside the store.
但是,如果你想通过分派操作、检查当前存储状态或某种副作用来让异步逻辑与存储交互,该怎么办?这就是 Redux 中间件 发挥作用的地方。它们扩展存储以添加其他功能,并允许你:
¥But, what if you want to have async logic interact with the store by dispatching actions, checking the current store state, or some kind of side effect? That's where Redux middleware come in. They extend the store to add additional capabilities, and allow you to:
分派任何操作时执行额外的逻辑(例如记录操作和状态)
¥Execute extra logic when any action is dispatched (such as logging the action and state)
暂停、修改、延迟、替换或停止已调度的操作
¥Pause, modify, delay, replace, or halt dispatched actions
编写可以访问
dispatch
和getState
的额外代码¥Write extra code that has access to
dispatch
andgetState
教
dispatch
如何接受除普通操作对象之外的其他值,例如函数和 Promise,通过拦截它们并分派真实的操作对象¥Teach
dispatch
how to accept other values besides plain action objects, such as functions and promises, by intercepting them and dispatching real action objects instead编写使用异步逻辑或其他副作用的代码
¥Write code that uses async logic or other side effects
使用中间件的最常见原因是允许不同类型的异步逻辑与存储交互。这允许你编写可以调度操作并检查存储状态的代码,同时将该逻辑与 UI 分开。
¥The most common reason to use middleware is to allow different kinds of async logic to interact with the store. This allows you to write code that can dispatch actions and check the store state, while keeping that logic separate from your UI.
有关中间件如何让你自定义 Redux 存储的更多详细信息,请参阅:
¥For more details on how middleware let you customize the Redux store, see:
中间件和 Redux 数据流
¥Middleware and Redux Data Flow
早些时候,我们看到了 Redux 的同步数据流是什么样的。
¥Earlier, we saw what the synchronous data flow for Redux looks like.
中间件通过在 dispatch
的开头添加额外的步骤来更新 Redux 数据流。这样,中间件可以运行 HTTP 请求等逻辑,然后分派操作。这使得异步数据流看起来像这样:
¥Middleware update the Redux data flow by adding an extra step at the start of dispatch
. That way, middleware can run logic like HTTP requests, then dispatch actions. That makes the async data flow look like this:
块和异步逻辑
¥Thunks and Async Logic
Redux 有多种异步中间件,每种都允许你使用不同的语法编写逻辑。最常见的异步中间件是 redux-thunk
,它允许你直接编写可能包含异步逻辑的普通函数。Redux Toolkit 的 configureStore
函数 默认自动设置 thunk 中间件 和 我们建议使用 thunk 作为使用 Redux 编写异步逻辑的标准方法。
¥There are many kinds of async middleware for Redux, and each lets you write your logic using different syntax. The most common async middleware is redux-thunk
, which lets you write plain functions that may contain async logic directly. Redux Toolkit's configureStore
function automatically sets up the thunk middleware by default, and we recommend using thunks as a standard approach for writing async logic with Redux.
"thunk" 一词是一个编程术语,意思是 "一段执行一些延迟工作的代码"。
¥The word "thunk" is a programming term that means "a piece of code that does some delayed work".
有关如何使用 Redux thunk 的更多详细信息,请参阅 thunk 使用指南页面:
¥For more details on how to use Redux thunks, see the thunk usage guide page:
以及这些帖子:
¥as well as these posts:
块函数
¥Thunk Functions
一旦 thunk 中间件被添加到 Redux 存储中,它就允许你将 thunk 函数直接传递给 store.dispatch
。thunk 函数将始终以 (dispatch, getState)
作为参数进行调用,你可以根据需要在 thunk 中使用它们。
¥Once the thunk middleware has been added to the Redux store, it allows you to pass thunk functions directly to store.dispatch
. A thunk function will always be called with (dispatch, getState)
as its arguments, and you can use them inside the thunk as needed.
thunk 函数可以包含任何逻辑,同步或异步。
¥A thunk function can contain any logic, sync or async.
Thunk 通常使用动作创建器来分派普通动作,例如 dispatch(increment())
:
¥Thunks typically dispatch plain actions using action creators, like dispatch(increment())
:
const store = configureStore({ reducer: counterReducer })
const exampleThunkFunction = (
dispatch: AppDispatch,
getState: () => RootState
) => {
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 内部使用的参数。
¥For consistency with dispatching normal action objects, we typically write these as thunk action creators, which return the thunk function. These action creators can take arguments that can be used inside the thunk.
const logAndAdd = (amount: number) => {
return (dispatch: AppDispatch, getState: () => RootState) => {
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 通常用 "slice" 文件 编写,因为 thunk 数据获取通常在概念上与特定切片的更新逻辑相关。在本节中,我们将介绍几种定义 thunk 的不同方法。
¥Thunks are typically written in "slice" files, since the thunk data fetching is usually conceptually related to a particular slice's update logic. We'll look at a couple different ways to define thunks as we go through this section.
编写异步 thunk
¥Writing Async Thunks
Thunk 内部可能包含异步逻辑,例如 setTimeout
、Promises 和 async/await
。这使得它们成为放置对服务器 API 的 HTTP 调用的好地方。
¥Thunks may have async logic inside of them, such as setTimeout
, Promises, and async/await
. This makes them a good place to put HTTP calls to a server API.
Redux 的数据获取逻辑通常遵循可预测的模式:
¥Data fetching logic for Redux typically follows a predictable pattern:
在请求之前调度 "start" 操作,以指示请求正在进行中。这可用于跟踪加载状态以允许跳过重复请求或在 UI 中显示加载指示器。
¥A "start" action is dispatched before the request, to indicate that the request is in progress. This may be used to track loading state to allow skipping duplicate requests or show loading indicators in the UI.
异步请求是使用
fetch
或封装器库发出的,并promise结果¥The async request is made with
fetch
or a wrapper library, with a promise for the result当请求promise解析时,异步逻辑会分派包含结果数据的 "success" 操作或包含错误详细信息的 "failure" 操作。reducer 逻辑会清除两种情况下的加载状态,并处理成功案例的结果数据,或存储错误值以供潜在显示。
¥When the request promise resolves, the async logic dispatches either a "success" action containing the result data, or a "failure" action containing error details. The reducer logic clears the loading state in both cases, and either processes the result data from the success case, or stores the error value for potential display.
这些步骤不是必需的,但很常用。(如果你只关心成功结果,则可以在请求完成时仅分派单个 "success" 操作,并跳过 "start" 和 "failure" 操作。)
¥These steps are not required, but are commonly used. (If all you care about is a successful result, you can just dispatch a single "success" action when the request finishes, and skip the "start" and "failure" actions.)
Redux Toolkit 提供了一个 createAsyncThunk
API 来实现描述异步请求的操作的创建和调度。
¥Redux Toolkit provides a createAsyncThunk
API to implement the creation and dispatching of actions describing an async request.
基本 createAsyncThunk
用法如下:
¥Basic createAsyncThunk
usage looks like this:
import { createAsyncThunk } from '@reduxjs/toolkit'
export const fetchItemById = createAsyncThunk(
'items/fetchItemById',
async (itemId: string) => {
const item = await someHttpRequest(itemId)
return item
}
)
有关 createAsyncThunk
如何简化用于分派异步请求操作的代码的更多信息,请参阅此详细信息部分。我们将很快看到它在实践中的用法。
¥See this details section for more info on how createAsyncThunk
simplifies the code for dispatching actions for async requests. We'll see how it gets used in practice shortly.
Detailed Explanation: Dispatching Request Status Actions in Thunks
如果我们手动编写典型的异步 thunk 代码,它可能如下所示:
¥If we were to write out the code for a typical async thunk by hand, it might look like this:
const getRepoDetailsStarted = () => ({
type: 'repoDetails/fetchStarted'
})
const getRepoDetailsSuccess = (repoDetails: RepoDetails) => ({
type: 'repoDetails/fetchSucceeded',
payload: repoDetails
})
const getRepoDetailsFailed = (error: any) => ({
type: 'repoDetails/fetchFailed',
error
})
const fetchIssuesCount = (org: string, repo: string) => {
return async (dispatch: AppDispatch) => {
dispatch(getRepoDetailsStarted())
try {
const repoDetails = await getRepoDetails(org, repo)
dispatch(getRepoDetailsSuccess(repoDetails))
} catch (err) {
dispatch(getRepoDetailsFailed(err.toString()))
}
}
}
然而,使用这种方法编写代码很乏味。每个单独类型的请求都需要重复类似的实现:
¥However, writing code using this approach is tedious. Each separate type of request needs repeated similar implementation:
需要为三种不同的情况定义独特的操作类型
¥Unique action types need to be defined for the three different cases
每个动作类型通常都有相应的动作创建者函数
¥Each of those action types usually has a corresponding action creator function
必须编写一个 thunk,以正确的顺序分派正确的操作
¥A thunk has to be written that dispatches the correct actions in the right sequence
createAsyncThunk
通过生成操作类型和操作创建者并生成自动分派这些操作的 thunk 来抽象此模式。你提供一个回调函数来进行异步调用并返回带有结果的 Promise。
¥createAsyncThunk
abstracts this pattern by generating the action types and action creators, and generating a thunk that dispatches those actions automatically. You provide a callback function that makes the async call and returns a Promise with the result.
自己编写 thunk 逻辑时,也很容易在错误处理方面犯错误。在这种情况下,try
块实际上会捕获失败请求和调度时的任何错误的错误。正确处理这个问题需要重构逻辑来分离它们。createAsyncThunk
已在内部为你正确处理错误。
¥It's also easy to make mistakes with error handling when writing thunk logic yourself. In this case, the try
block will actually catch errors from both a failed request, and any errors while dispatching. Handling this correctly would require restructuring the logic to separate those. createAsyncThunk
already handles errors correctly for you internally.
输入 Redux Thunks
¥Typing Redux Thunks
输入手写 Thunks
¥Typing Handwritten Thunks
如果你手动编写 thunk,则可以将 thunk 参数明确声明为 (dispatch: AppDispatch, getState: () => RootState)
。由于这很常见,你还可以定义可重用的 AppThunk
类型并改用它:
¥If you're writing a thunk by hand, you can declare explicitly type the thunk arguments as (dispatch: AppDispatch, getState: () => RootState)
. Since this is common, you can also define a reusable AppThunk
type and use that instead:
import { Action, ThunkAction, configureStore } from '@reduxjs/toolkit'
// omit actual store setup
// Infer the type of `store`
export type AppStore = typeof store
// Infer the `AppDispatch` type from the store itself
export type AppDispatch = typeof store.dispatch
// Same for the `RootState` type
export type RootState = ReturnType<typeof store.getState>
// Export a reusable type for handwritten thunks
export type AppThunk = ThunkAction<void, RootState, unknown, Action>
然后,你可以使用它来描述你正在编写的 thunk 函数:
¥Then you can use that to describe the thunk functions you're writing:
// Use `AppThunk` as the return type, since we return a thunk function
const logAndAdd = (amount: number): AppThunk => {
return (dispatch, getState) => {
const stateBefore = getState()
console.log(`Counter before: ${stateBefore.counter}`)
dispatch(incrementByAmount(amount))
const stateAfter = getState()
console.log(`Counter after: ${stateAfter.counter}`)
}
}
键入 createAsyncThunk
¥Typing createAsyncThunk
对于 createAsyncThunk
具体来说:如果你的有效负载函数接受参数,请为该参数提供一个类型,如 async (userId: string)
。默认情况下,你不需要提供返回类型 - TS 将自动推断返回类型。
¥For createAsyncThunk
specifically: if your payload function accepts an argument, provide a type for that argument, like async (userId: string)
. You do not need to provide a return type by default - TS will infer the return type automatically.
如果你需要在 createAsyncThunk
中访问 dispatch
或 getState
,RTK 提供了一种通过调用 createAsyncThunk.withTypes()
来定义具有正确 dispatch
和 getState
类型的 "pre-typed" 版本的方法,这相当于我们定义预类型版本的 useSelector
和 useDispatch
。我们将创建一个新的 src/app/withTypes
文件,并从那里导出它:
¥If you need to access dispatch
or getState
inside of createAsyncThunk
, RTK provides a way to define a "pre-typed" version that has the correct dispatch
and getState
types built in by calling createAsyncThunk.withTypes()
, equivalent to how we defined pre-typed versions of useSelector
and useDispatch
. We'll create a new src/app/withTypes
files, and export it from there:
import { createAsyncThunk } from '@reduxjs/toolkit'
import type { RootState, AppDispatch } from './store'
export const createAppAsyncThunk = createAsyncThunk.withTypes<{
state: RootState
dispatch: AppDispatch
}>()
有关使用 TypeScript 定义 thunk 的更多详细信息,请参阅:
¥For more details on defining thunks with TypeScript, see:
加载帖子
¥Loading Posts
到目前为止,我们的 postsSlice
已经使用了一些硬编码的样本数据作为其初始状态。我们将把它切换为从空的帖子数组开始,然后从服务器获取帖子列表。
¥So far, our postsSlice
has used some hardcoded sample data as its initial state. We're going to switch that to start with an empty array of posts instead, and then fetch a list of posts from the server.
为此,我们必须更改 postsSlice
中的状态结构,以便我们可以跟踪 API 请求的当前状态。
¥In order to do that, we're going to have to change the structure of the state in our postsSlice
, so that we can keep track of the current state of the API request.
请求的加载状态
¥Loading State for Requests
当我们进行 API 调用时,我们可以将其进度视为一个小型状态机,该状态机可以处于四种可能状态之一:
¥When we make an API call, we can view its progress as a small state machine that can be in one of four possible states:
请求尚未开始
¥The request hasn't started yet
请求正在进行中
¥The request is in progress
请求成功,我们现在有了我们需要的数据
¥The request succeeded, and we now have the data we need
请求失败,可能有错误消息
¥The request failed, and there's probably an error message
我们可以使用一些布尔值(例如 isLoading: true
)来跟踪该信息,但最好将这些状态作为单个枚举值来跟踪。一个好的模式是有一个如下所示的状态部分(使用 TypeScript 类型表示法):
¥We could track that information using some booleans, like isLoading: true
, but it's better to track these states as a single enum value. A good pattern for this is to have a state section that looks like this (using TypeScript type notation):
{
// Multiple possible status enum values
status: 'idle' | 'pending' | 'succeeded' | 'failed',
error: string | null
}
这些字段将与存储的任何实际数据一起存在。这些特定的字符串状态名称不是必需的 - 如果你愿意,可以随意使用其他名称,例如 'loading'
代替 'pending'
,或 'completed'
代替 'succeeded'
。
¥These fields would exist alongside whatever actual data is being stored. These specific string state names aren't required - feel free to use other names if you want, like 'loading'
instead of 'pending'
, or 'completed'
instead of 'succeeded'
.
我们可以使用这些信息来决定在请求进行时在 UI 中显示什么内容,还可以在 reducer 中添加逻辑以防止诸如加载数据两次之类的情况。
¥We can use this information to decide what to show in our UI as the request progresses, and also add logic in our reducers to prevent cases like loading data twice.
让我们更新 postsSlice
以使用此模式来跟踪 "获取帖子" 请求的加载状态。我们将把我们的状态从帖子数组本身切换为看起来像 {posts, status, error}
。我们还将从初始状态中删除旧的示例帖子条目,并为加载和错误字段添加几个新的选择器:
¥Let's update our postsSlice
to use this pattern to track loading state for a "fetch posts" request. We'll switch our state from being an array of posts by itself, to look like {posts, status, error}
. We'll also remove the old sample post entries from our initial state, and add a couple new selectors for the loading and error fields:
import { createSlice, nanoid } from '@reduxjs/toolkit'
// omit reactions and other types
interface PostsState {
posts: Post[]
status: 'idle' | 'pending' | 'succeeded' | 'failed'
error: string | null
}
const initialState: PostsState = {
posts: [],
status: 'idle',
error: null
}
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
reducer(state, action: PayloadAction<Post>) {
state.posts.push(action.payload)
},
prepare(title: string, content: string, userId: string) {
// omit prepare logic
}
},
postUpdated(state, action: PayloadAction<PostUpdate>) {
const { id, title, content } = action.payload
const existingPost = state.posts.find(post => post.id === id)
if (existingPost) {
existingPost.title = title
existingPost.content = content
}
},
reactionAdded(
state,
action: PayloadAction<{ postId: string; reaction: ReactionName }>
) {
const { postId, reaction } = action.payload
const existingPost = state.posts.find(post => post.id === postId)
if (existingPost) {
existingPost.reactions[reaction]++
}
}
},
extraReducers: builder => {
builder.addCase(userLoggedOut, state => {
// Clear out the list of posts whenever the user logs out
return initialState
})
}
})
export const { postAdded, postUpdated, reactionAdded } = postsSlice.actions
export default postsSlice.reducer
export const selectAllPosts = (state: RootState) => state.posts.posts
export const selectPostById = (state: RootState, postId: string) =>
state.posts.posts.find(post => post.id === postId)
export const selectPostsStatus = (state: RootState) => state.posts.status
export const selectPostsError = (state: RootState) => state.posts.error
作为此更改的一部分,我们还需要将 state
作为数组的任何用途更改为 state.posts
,因为数组现在更深一层。
¥As part of this change, we also need to change any uses of state
as an array to be state.posts
instead, because the array is now one level deeper.
是的,这确实意味着我们现在有一个看起来像 state.posts.posts
的嵌套对象路径,这有点重复和愚蠢:)如果我们想避免这种情况,我们可以将嵌套数组名称更改为 items
或 data
或其他名称,但我们' 暂时保持原样。
¥Yes, this does mean that we now have a nested object path that looks like state.posts.posts
, which is somewhat repetitive and silly :) We could change the nested array name to be items
or data
or something if we wanted to avoid that, but we'll leave it as-is for now.
使用 createAsyncThunk
获取数据
¥Fetching Data with createAsyncThunk
Redux Toolkit 的 createAsyncThunk
API 会生成 thunk,自动为你分派这些 "开始/成功/失败" 操作。
¥Redux Toolkit's createAsyncThunk
API generates thunks that automatically dispatch those "start/success/failure" actions for you.
让我们首先添加一个 thunk,它将发出 HTTP 请求以检索帖子列表。我们将从 src/api
文件夹导入 client
实用程序,并使用它向 '/fakeApi/posts'
发出请求。
¥Let's start by adding a thunk that will make an HTTP request to retrieve a list of posts. We'll import the client
utility from the src/api
folder, and use that to make a request to '/fakeApi/posts'
.
import { createSlice, nanoid, PayloadAction } from '@reduxjs/toolkit'
import { client } from '@/api/client'
import type { RootState } from '@/app/store'
import { createAppAsyncThunk } from '@/app/withTypes'
// omit other imports and types
export const fetchPosts = createAppAsyncThunk('posts/fetchPosts', async () => {
const response = await client.get<Post[]>('/fakeApi/posts')
return response.data
})
const initialState: PostsState = {
posts: [],
status: 'idle',
error: null
}
createAsyncThunk
接受两个参数:
¥createAsyncThunk
accepts two arguments:
将用作生成的操作类型的前缀的字符串
¥A string that will be used as the prefix for the generated action types
"有效负载创建者" 回调函数应该返回包含一些数据的 Promise,或者返回带有错误的被拒绝的 Promise
¥A "payload creator" callback function that should return a Promise containing some data, or a rejected Promise with an error
有效负载创建者通常会发出某种 HTTP 请求,并且可以直接从 HTTP 请求返回 Promise,或者从 API 响应中提取一些数据并返回。我们通常使用 JS async/await
语法编写此代码,这让我们可以编写使用promise的函数,同时使用标准 try/catch
逻辑而不是 somePromise.then()
链。
¥The payload creator will usually make an HTTP request of some kind, and can either return the Promise from the HTTP request directly, or extract some data from the API response and return that. We typically write this using the JS async/await
syntax, which lets us write functions that use promises while using standard try/catch
logic instead of somePromise.then()
chains.
在本例中,我们传入 'posts/fetchPosts'
作为操作类型前缀。
¥In this case, we pass in 'posts/fetchPosts'
as the action type prefix.
在这种情况下,fetchPosts
的有效负载创建回调不需要任何参数,它所需要做的就是等待 API 调用返回响应。响应对象看起来像 {data: []}
,我们希望调度的 Redux 操作具有只是帖子数组的有效负载。因此,我们提取 response.data
,并从回调中返回它。
¥In this case, the payload creation callback for fetchPosts
doesn't need any arguments, and all it needs to do is wait for the API call to return a response. The response object looks like {data: []}
, and we want our dispatched Redux action to have a payload that is just the array of posts. So, we extract response.data
, and return that from the callback.
如果我们尝试调用 dispatch(fetchPosts())
,fetchPosts
thunk 将首先调度 'posts/fetchPosts/pending'
的操作类型:
¥If we try calling dispatch(fetchPosts())
, the fetchPosts
thunk will first dispatch an action type of 'posts/fetchPosts/pending'
:
我们可以在 reducer 中监听此操作并将请求状态标记为 'pending'
。
¥We can listen for this action in our reducer and mark the request status as 'pending'
.
一旦 Promise 解析,fetchPosts
thunk 将获取我们从回调中返回的 response.data
数组,并调度包含帖子数组的 'posts/fetchPosts/fulfilled'
操作作为 action.payload
:
¥Once the Promise resolves, the fetchPosts
thunk takes the response.data
array we returned from the callback, and dispatches a 'posts/fetchPosts/fulfilled'
action containing the posts array as action.payload
:
reducer 和加载动作
¥Reducers and Loading Actions
接下来,我们需要在 reducer 中处理这两个操作。这需要更深入地了解我们一直在使用的 createSlice
API。
¥Next up, we need to handle both these actions in our reducers. This requires a bit deeper look at the createSlice
API we've been using.
我们已经看到 createSlice
将为我们在 reducers
字段中定义的每个 reducer 函数生成一个动作创建者,并且生成的动作类型包括切片的名称,例如:
¥We've already seen that createSlice
will generate an action creator for every reducer function we define in the reducers
field, and that the generated action types include the name of the slice, like:
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'
}
}
*/
我们还看到我们可以使用 createSlice
中的 extraReducers
字段用于响应在切片之外定义的操作。
¥We've also seen that we can use the extraReducers
field in createSlice
to respond to actions that were defined outside of the slice.
在这种情况下,我们需要监听 fetchPosts
thunk 调度的 "pending" 和 "fulfilled" 操作类型。这些动作创建者附加到我们实际的 fetchPost
函数,我们可以将它们传递给 extraReducers
来监听这些动作:
¥In this case, we need to listen for the "pending" and "fulfilled" action types dispatched by our fetchPosts
thunk. Those action creators are attached to our actual fetchPost
function, and we can pass those to extraReducers
to listen for those actions:
export const fetchPosts = createAsyncThunk('posts/fetchPosts', async () => {
const response = await client.get<Post[]>('/fakeApi/posts')
return response.data
})
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
// omit existing reducers here
},
extraReducers: builder => {
builder
.addCase(userLoggedOut, state => {
// Clear out the list of posts whenever the user logs out
return initialState
})
.addCase(fetchPosts.pending, (state, action) => {
state.status = 'pending'
})
.addCase(fetchPosts.fulfilled, (state, action) => {
state.status = 'succeeded'
// Add any fetched posts to the array
state.posts.push(...action.payload)
})
.addCase(fetchPosts.rejected, (state, action) => {
state.status = 'failed'
state.error = action.error.message ?? 'Unknown Error'
})
}
})
我们将根据返回的 Promise 处理 thunk 可以分派的所有三种操作类型:
¥We'll handle all three action types that could be dispatched by the thunk, based on the Promise we returned:
当请求开始时,我们将
status
枚举设置为'pending'
¥When the request starts, we'll set the
status
enum to'pending'
如果请求成功,我们将
status
标记为'succeeded'
,并将获取的帖子添加到state.posts
¥If the request succeeds, we mark the
status
as'succeeded'
, and add the fetched posts tostate.posts
如果请求失败,我们会将
status
标记为'failed'
,并将任何错误消息保存到状态中,以便我们可以显示它¥If the request fails, we'll mark the
status
as'failed'
, and save any error message into the state so we can display it
从组件中分派 thunk
¥Dispatching Thunks from Components
现在我们已经编写了 fetchPosts
thunk 并更新了切片来处理这些操作,让我们更新我们的 <PostsList>
组件以实际启动数据获取。
¥Now that we have the fetchPosts
thunk written and the slice updated to handle those actions, let's update our <PostsList>
component to actually kick off the data fetch for us.
我们将 fetchPosts
thunk 导入到组件中。与所有其他动作创建器一样,我们必须分派它,因此我们还需要添加 useAppDispatch
钩子。由于我们想在 <PostsList>
安装时获取此数据,我们需要导入 React useEffect
钩子并分派操作。
¥We'll import the fetchPosts
thunk into the component. Like all of our other action creators, we have to dispatch it, so we'll also need to add the useAppDispatch
hook. Since we want to fetch this data when <PostsList>
mounts, we need to import the React useEffect
hook, and dispatch the action.
重要的是我们只尝试获取帖子列表一次。如果我们每次渲染 <PostsList>
组件时都执行此操作,或者由于在视图之间切换而重新创建组件,则最终可能会多次获取帖子。我们可以使用 posts.status
枚举来帮助决定是否需要真正开始获取,方法是将其选入组件,并且仅在状态为 'idle'
(即尚未开始)时才开始获取。
¥It's important that we only try to fetch the list of posts once. If we do it every time the <PostsList>
component renders, or is re-created because we've switched between views, we might end up fetching the posts several times. We can use the posts.status
enum to help decide if we need to actually start fetching, by selecting that into the component and only starting the fetch if the status is 'idle'
, meaning it hasn't started yet.
import React, { useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useAppSelector, useAppDispatch } from '@/app/hooks'
import { TimeAgo } from '@/components/TimeAgo'
import { PostAuthor } from './PostAuthor'
import { ReactionButtons } from './ReactionButtons'
import { fetchPosts, selectAllPosts, selectPostsStatus } from './postsSlice'
export const PostsList = () => {
const dispatch = useAppDispatch()
const posts = useAppSelector(selectAllPosts)
const postStatus = useAppSelector(selectPostsStatus)
useEffect(() => {
if (postStatus === 'idle') {
dispatch(fetchPosts())
}
}, [postStatus, dispatch])
// omit rendering logic
}
这样,我们现在应该会在登录应用后看到一个新的帖子列表!
¥And with that, we should now see a fresh list of posts showing up after we log in to our app!
避免重复获取
¥Avoiding Duplicate Fetches
好消息是我们已经成功地从我们的模拟服务器 API 中获取了这些帖子对象。
¥The good news is we've successfully fetched those post objects from our mock server API.
不幸的是,我们遇到了一个问题。目前,我们的帖子列表显示每个帖子的重复项:
¥Unfortunately, we've got a problem. Right now our posts list is showing duplicates of each post:
事实上,如果我们查看 Redux DevTools,我们可以看到分派了两组 'pending'
和 'fulfilled'
操作:
¥In fact, if we look at the Redux DevTools, we can see two sets of 'pending'
and 'fulfilled'
actions were dispatched:
这是为什么?我们不是刚刚添加了对 postStatus === 'idle'
的检查吗?这还不足以确保我们只分派一次 thunk 吗?
¥Why is that? Didn't we just add a check for postStatus === 'idle'
? Shouldn't that be enough to make sure we only dispatch the thunk once?
嗯,是的……和没有 :)
¥Well, yes... and no :)
useEffect
中的实际逻辑是正确的。问题是,现在我们正在查看应用的开发版本,并且 在开发中,React 将在其 <StrictMode>
组件内部的 mount 上两次运行所有 useEffect
钩子 是为了让某些类型的错误更明显地发生。
¥The actual logic here in the useEffect
is correct. The issue is that right now we're looking at a development build of our application, and in development, React will run all useEffect
hooks twice on mount when inside of its <StrictMode>
component in order to make some kinds of bugs happen more obviously.
在这种情况下,发生的事情是:
¥In this case, what happened is:
已安装
<PostsList>
组件¥The
<PostsList>
component mounteduseEffect
钩子首次运行。postStatus
值为'idle'
,因此它会分派fetchPosts
thunk。¥The
useEffect
hook ran for the first time. ThepostStatus
value is'idle'
, so it dispatches thefetchPosts
thunk.fetchPosts
立即分派其fetchPosts.pending
操作,因此 Redux 存储确实立即将状态更新为'pending'
...¥
fetchPosts
immediately dispatches itsfetchPosts.pending
action, so the Redux store did update the status to'pending'
right away...但 React 再次运行
useEffect
而不重新渲染组件,因此效果仍然认为postStatus
是'idle'
并第二次分派fetchPosts
¥but React runs the
useEffect
again without re-rendering the component, so the effect still thinks thatpostStatus
is'idle'
and dispatchesfetchPosts
a second time两个 thunk 都完成了数据获取,分派了
fetchPosts.fulfilled
动作,fulfilled
reducer运行了两次,从而导致一组重复的帖子被添加到状态中¥Both thunks finish fetching their data, dispatch the
fetchPosts.fulfilled
action, and thefulfilled
reducer runs twice, adding resulting in a duplicate set of posts being added to the state
那么,我们该如何解决这个问题?
¥So, how can we fix this?
一种选择是从我们的应用中删除 <StrictMode>
标签。但是,React 团队建议使用它,它有助于发现其他问题。
¥One option would be to remove the <StrictMode>
tag from our app. But, the React team recommends using it, and it is helpful for catching other issues.
我们可以使用 useRef
钩子编写一些复杂的逻辑来跟踪此组件是否实际上是第一次渲染,并使用它来仅分派一次 fetchPosts
。但是,这有点丑陋。
¥We could write some complicated logic with the useRef
hook to track if this component is actually rendering for the first time, and use that to only dispatch fetchPosts
once. But, that's kind of ugly.
最后一个选项是使用 Redux 状态中的实际 state.posts.status
值来检查是否已经有请求正在进行,如果是,则让 thunk 本身退出。幸运的是,createAsyncThunk
为我们提供了一种方法来做到这一点。
¥The last option would be to use the actual state.posts.status
value from the Redux state to check if there's already a request in progress, and have the thunk itself bail out if that's the case. Fortunately, createAsyncThunk
gives us a way to do this.
检查异步 Thunk 条件
¥Checking Async Thunk Conditions
createAsyncThunk
接受可选的 condition
回调,我们可以使用它来进行该检查。如果提供,它将在 thunk 调用开始时运行,并且如果 condition
返回 false.
,它将取消整个 thunk
¥createAsyncThunk
accepts an optional condition
callback we can use to do that check. If provided, it runs at the start of the thunk call, and it will cancel the entire thunk if condition
returns false.
在这种情况下,我们知道如果 state.posts.status
字段不是 'idle'
,我们想要避免运行 thunk。我们已经有一个可以在这里使用的 selectPostsStatus
选择器,因此我们可以添加 condition
选项并检查该值:
¥In this case, we know that we want to avoid running the thunk if the state.posts.status
field is not 'idle'
. We already have a selectPostsStatus
selector that we can use here, so we can add the condition
option and check that value:
export const fetchPosts = createAppAsyncThunk(
'posts/fetchPosts',
async () => {
const response = await client.get<Post[]>('/fakeApi/posts')
return response.data
},
{
condition(arg, thunkApi) {
const postsStatus = selectPostsStatus(thunkApi.getState())
if (postsStatus !== 'idle') {
return false
}
}
}
)
现在,当我们重新加载页面并查看 <PostsList>
时,我们应该只看到一组帖子,没有重复,并且我们应该只在 Redux DevTools 中看到一组分派的操作。
¥Now when we reload the page and look at the <PostsList>
, we should only see one set of posts, with no duplicates, and we should only see one set of dispatched actions in the Redux DevTools.
你不需要将 condition
添加到所有 thunk,但有时确保一次只发出一个请求很有用。
¥You don't need to add condition
to all thunks, but there may be times it's useful to ensure only one request gets made at a time.
请注意,RTK Query 将为你管理! 会对所有组件中的请求进行数据去重,因此每个请求只发生一次,你不必担心自己这样做。
¥Note that RTK Query will manage this for you! It deduplicates requests across all components, so that each request only happens once, and you don't have to worry about doing this yourself.
显示加载状态
¥Displaying Loading State
我们的 <PostsList>
组件已经在检查 Redux 中存储的帖子的任何更新,并在列表发生变化时重新渲染自身。因此,如果我们刷新页面,我们应该会看到来自我们虚假 API 的一组随机帖子出现在屏幕上。但是,似乎存在一些滞后 - <PostsList>
最初是空的,几秒钟后显示帖子。
¥Our <PostsList>
component is already checking for any updates to the posts that are stored in Redux, and rerendering itself any time that list changes. So, if we refresh the page, we should see a random set of posts from our fake API show up on screen. But, it seems like there's some lag - the <PostsList>
is empty at first, and after a couple seconds the posts are displayed.
真正的 API 调用可能需要一些时间才能返回响应,因此通常最好在 UI 中显示某种 "正在加载..." 指示器,以便用户知道我们正在等待数据。
¥A real API call will probably take some time to return a response, so it's usually a good idea to show some kind of "loading..." indicator in the UI so the user knows we're waiting for data.
我们可以更新 <PostsList>
以根据 state.posts.status
枚举显示不同的 UI:如果我们正在加载,则显示一个加载控件;如果失败,则显示一条错误消息;如果我们有数据,则显示实际的帖子列表。
¥We can update our <PostsList>
to show a different bit of UI based on the state.posts.status
enum: a spinner if we're loading, an error message if it failed, or the actual posts list if we have the data.
当我们这样做时,这可能是提取 <PostExcerpt>
组件来封装列表中一项的渲染的好时机。
¥While we're at it, this is probably a good time to extract a <PostExcerpt>
component to encapsulate the rendering for one item in the list as well.
结果可能如下所示:
¥The result might look like this:
import React, { useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useAppSelector, useAppDispatch } from '@/app/hooks'
import { Spinner } from '@/components/Spinner'
import { TimeAgo } from '@/components/TimeAgo'
import { PostAuthor } from './PostAuthor'
import { ReactionButtons } from './ReactionButtons'
import {
Post,
selectAllPosts,
selectPostsError,
fetchPosts
} from './postsSlice'
interface PostExcerptProps {
post: Post
}
function PostExcerpt({ post }: PostExcerptProps) {
return (
<article className="post-excerpt" key={post.id}>
<h3>
<Link to={`/posts/${post.id}`}>{post.title}</Link>
</h3>
<div>
<PostAuthor userId={post.user} />
<TimeAgo timestamp={post.date} />
</div>
<p className="post-content">{post.content.substring(0, 100)}</p>
<ReactionButtons post={post} />
</article>
)
}
export const PostsList = () => {
const dispatch = useAppDispatch()
const posts = useAppSelector(selectAllPosts)
const postStatus = useAppSelector(selectPostsStatus)
const postsError = useAppSelector(selectPostsError)
useEffect(() => {
if (postStatus === 'idle') {
dispatch(fetchPosts())
}
}, [postStatus, dispatch])
let content: React.ReactNode
if (postStatus === 'pending') {
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 === 'rejected') {
content = <div>{postsError}</div>
}
return (
<section className="posts-list">
<h2>Posts</h2>
{content}
</section>
)
}
你可能会注意到 API 调用需要一段时间才能完成,并且加载加载控件会在屏幕上停留几秒钟。我们的模拟 API 服务器配置为向所有响应添加 2 秒的延迟,特别是为了帮助可视化可见加载旋转器的时间。如果你想改变这个行为,你可以打开 api/server.ts
,并改变这一行:
¥You might notice that the API calls are taking a while to complete, and that the loading spinner is staying on screen for a couple seconds. Our mock API server is configured to add a 2-second delay to all responses, specifically to help visualize times when there's a loading spinner visible. If you want to change this behavior, you can open up api/server.ts
, and alter this line:
// Add an extra delay to all endpoints, so loading spinners show up.
const ARTIFICIAL_DELAY_MS = 2000
如果你希望 API 调用更快完成,请随时打开和关闭它。
¥Feel free to turn that on and off as we go if you want the API calls to complete faster.
可选:在 createSlice
中定义 Thunk
¥Optional: Defining Thunks Inside of createSlice
现在,我们的 fetchPosts
thunk 在 postsSlice.ts
文件中定义,但在 createSlice()
调用之外。
¥Right now, our fetchPosts
thunk is defined in the postsSlice.ts
file, but outside of the createSlice()
call.
有一种可选的方法可以在 createSlice
内部定义 thunk,这需要更改 reducers
字段的定义方式。如果你想尝试,请参阅此说明以了解详细信息:
¥There's an optional way to define thunks inside of createSlice
, which requires changing how the reducers
field is defined. See this explanation for details if you want to try it:
Defining Thunks in createSlice
我们已经看到,编写 createSlice.reducers
字段的标准方法是将其作为对象,其中键成为操作名称,值是 Reducer。我们还看到,值可以是 具有 {reducer, prepare}
函数的对象,用于创建具有我们想要的值的操作对象。
¥We've seen that the standard way to write the createSlice.reducers
field is as an object, where the keys become the action names, and the values are reducers. We also saw that the values can be an object with the {reducer, prepare}
functions for creating an action object with the values we want.
或者,reducers
字段可以是接收 create
对象的回调函数。这与我们使用 extraReducers
看到的有些相似,但创建 Reducer 和操作的方法不同:
¥Alternately, the reducers
field can be a callback function that receives a create
object. This is somewhat similar to what we saw with extraReducers
, but with a different set of methods for creating reducers and actions:
create.reducer<PayloadType>(caseReducer)
:定义案例减少器¥
create.reducer<PayloadType>(caseReducer)
: defines a case reducercreate.preparedReducer(prepare, caseReducer)
:使用准备回调定义 Reducer¥
create.preparedReducer(prepare, caseReducer)
: defines a reducer with a prepare callback
然后,像以前一样返回一个以 Reducer 名称作为字段的对象,但调用 create
方法来制作每个 Reducer。将 postsSlice
转换为此语法后可能如下所示:
¥Then, return an object like before with the reducer names as the fields, but call the create
methods to make each reducer. Here's what the postsSlice
would look like converted to this syntax:
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: create => {
return {
postAdded: create.preparedReducer(
(title: string, content: string, userId: string) => {
return {
payload: {
id: nanoid(),
date: new Date().toISOString(),
title,
content,
user: userId,
reactions: initialReactions
}
}
},
(state, action) => {
state.posts.push(action.payload)
}
),
postUpdated: create.reducer<PostUpdate>((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
}
}),
reactionAdded: create.reducer<{ postId: string; reaction: ReactionName }>(
(state, action) => {
const { postId, reaction } = action.payload
const existingPost = state.posts.find(post => post.id === postId)
if (existingPost) {
existingPost.reactions[reaction]++
}
}
)
}
},
extraReducers: builder => {
// same as before
}
})
将 reducers
编写为回调为扩展 createSlice
的功能打开了大门。特别是,可以制作一个特殊版本的 createSlice
,该版本能够使用内置的 createAsyncThunk
。
¥Writing reducers
as a callback opens the door for extending the capabilities of createSlice
. In particular, it's possible to make a special version of createSlice
that has the ability to use createAsyncThunk
baked in.
首先,导入 buildCreateSlice
和 asyncThunkCreator
,然后像这样调用 buildCreateSlice
:
¥First, import buildCreateSlice
and asyncThunkCreator
, then call buildCreateSlice
like this:
import { buildCreateSlice, asyncThunkCreator } from '@reduxjs/toolkit'
export const createAppSlice = buildCreateSlice({
creators: { asyncThunk: asyncThunkCreator }
})
这为你提供了一个 createSlice
版本,可以在其中编写 thunk。
¥That gives you a version of createSlice
with the ability to write thunks inside.
最后,我们可以使用该 createAppSlice
方法定义包含 fetchPosts
thunk 的 postsSlice
。当我们这样做时,其他一些事情会发生变化:
¥Finally, we can use that createAppSlice
method to define our postsSlice
with the fetchPosts
thunk inside. When we do that, a couple other things change:
我们不能直接传入
RootState
泛型,所以我们必须执行getState() as RootState
来强制转换它¥We can't pass in the
RootState
generic directly, so we have to dogetState() as RootState
to cast it我们可以将处理 thunk 操作的所有 Reducer 作为选项的一部分传递给
create.asyncThunk()
,并从extraReducers
字段中删除它们:¥We can pass in all of the reducers that handle the thunk actions as part of the options to
create.asyncThunk()
, and remove those from theextraReducers
field:
const postsSlice = createAppSlice({
name: 'posts',
initialState,
reducers: create => {
return {
// omit the other reducers
fetchPosts: create.asyncThunk(
// Payload creator function to fetch the data
async () => {
const response = await client.get<Post[]>('/fakeApi/posts')
return response.data
},
{
// Options for `createAsyncThunk`
options: {
condition(arg, thunkApi) {
const { posts } = thunkApi.getState() as RootState
if (posts.status !== 'idle') {
return false
}
}
},
// The case reducers to handle the dispatched actions.
// Each of these is optional, but must use these names.
pending: (state, action) => {
state.status = 'pending'
},
fulfilled: (state, action) => {
state.status = 'succeeded'
// Add any fetched posts to the array
state.posts.push(...action.payload)
},
rejected: (state, action) => {
state.status = 'rejected'
state.error = action.error.message ?? 'Unknown Error'
}
}
)
}
},
extraReducers: builder => {
builder.addCase(userLoggedOut, state => {
// Clear out the list of posts whenever the user logs out
return initialState
})
// The thunk handlers have been removed here
}
})
请记住,create
回调语法是可选的!唯一需要使用它的时候是你真的想在 createSlice
内部编写 thunk。也就是说,它确实消除了使用 PayloadAction
类型的需要,并且也减少了 extraReducers
。
¥Remember, the create
callback syntax is optional! The only time you have to use it is if you really want to write thunks inside of createSlice
. That said, it does remove the need to use the PayloadAction
type, and cuts down on extraReducers
as well.
加载用户
¥Loading Users
我们现在正在获取并显示我们的帖子列表。但是,如果我们看一下帖子,就会发现一个问题:他们现在都说 "作者未知" 作为作者:
¥We're now fetching and displaying our list of posts. But, if we look at the posts, there's a problem: they all now say "Unknown author" as the authors:
这是因为帖子条目是由虚假 API 服务器随机生成的,每次我们重新加载页面时,该服务器也会随机生成一组虚假用户。我们需要更新用户切片以在应用启动时获取这些用户。
¥This is because the post entries are being randomly generated by the fake API server, which also randomly generates a set of fake users every time we reload the page. We need to update our users slice to fetch those users when the application starts.
和上次一样,我们将创建另一个异步 thunk 以从 API 获取用户并返回它们,然后处理 extraReducers
切片字段中的 fulfilled
操作。我们现在暂时不用担心加载状态:
¥Like last time, we'll create another async thunk to get the users from the API and return them, then handle the fulfilled
action in the extraReducers
slice field. We'll skip worrying about loading state for now:
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { client } from '@/api/client'
import type { RootState } from '@/app/store'
import { createAppAsyncThunk } from '@/app/withTypes'
interface User {
id: string
name: string
}
export const fetchUsers = createAppAsyncThunk('users/fetchUsers', async () => {
const response = await client.get<User[]>('/fakeApi/users')
return response.data
})
const initialState: User[] = []
const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {},
extraReducers(builder) {
builder.addCase(fetchUsers.fulfilled, (state, action) => {
return action.payload
})
}
})
export default usersSlice.reducer
// omit selectors
你可能已经注意到,这次 case 缩减器根本不使用 state
变量。相反,我们直接返回 action.payload
。Immer 让我们可以通过两种方式更新状态:要么改变现有的状态值,要么返回新的结果。如果我们返回一个新值,它将用我们返回的值完全替换现有状态。(请注意,如果你想手动返回新值,则需要你编写可能需要的任何不可变更新逻辑。)
¥You may have noticed that this time the case reducer isn't using the state
variable at all. Instead, we're returning the action.payload
directly. Immer lets us update state in two ways: either mutating the existing state value, or returning a new result. If we return a new value, that will replace the existing state completely with whatever we return. (Note that if you want to manually return a new value, it's up to you to write any immutable update logic that might be needed.)
初始状态是一个空数组,我们可能已经完成了 state.push(...action.payload)
来改变它。但是,在我们的例子中,我们确实希望用服务器返回的任何内容替换用户列表,这可以避免意外复制状态中的用户列表的任何机会。
¥The initial state was an empty array, and we probably could have done state.push(...action.payload)
to mutate it. But, in our case we really want to replace the list of users with whatever the server returned, and this avoids any chance of accidentally duplicating the list of users in state.
要了解有关 Immer 状态更新如何工作的更多信息,请参阅 RTK 文档中的 "使用 Immer 编写 reducer" 指南。
¥To learn more about how state updates with Immer work, see the "Writing Reducers with Immer" guide in the RTK docs.
我们只需要获取用户列表一次,并且我们希望在应用启动时正确执行此操作。我们可以在 main.tsx
文件中执行此操作,并直接分派 fetchUsers
thunk,因为我们有 store
:
¥We only need to fetch the list of users once, and we want to do it right when the application starts. We can do that in our main.tsx
file, and directly dispatch the fetchUsers
thunk because we have the store
right there:
// omit other imports
import store from './app/store'
import { fetchUsers } from './features/users/usersSlice'
import { worker } from './api/server'
async function start() {
// Start our mock API server
await worker.start({ onUnhandledRequest: 'bypass' })
store.dispatch(fetchUsers())
const root = createRoot(document.getElementById('root')!)
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
)
}
start()
请注意,这是在启动时获取数据的有效方法。这实际上在我们开始渲染 React 组件之前启动了获取过程,因此数据应该更快地可用。(请注意,此原则也可以通过使用 React Router 数据加载器 来应用。)
¥Notice that this is a valid way to fetch data on startup. This actually starts the fetching process before we start rendering our React components, so the data should be available sooner. (Note that this principle can be applied by using React Router data loaders as well.)
现在,每个帖子都应该再次显示用户名,并且我们还应该在 <AddPostForm>
的 "作者" 下拉列表中显示相同的用户列表。
¥Now, each of the posts should be showing a username again, and we should also have that same list of users shown in the "Author" dropdown in our <AddPostForm>
.
添加新帖子
¥Adding New Posts
本节我们还有一个步骤。当我们从 <AddPostForm>
添加新帖子时,该帖子只会添加到我们应用内的 Redux 存储中。我们实际上需要进行一个 API 调用,在我们的假 API 服务器中创建新的帖子条目,因此它是 "saved"。(由于这是一个假 API,因此如果我们重新加载页面,新帖子将不会保留,但如果我们有一个真正的后端服务器,那么下次重新加载时它将可用。)
¥We have one more step for this section. When we add a new post from the <AddPostForm>
, that post is only getting added to the Redux store inside our app. We need to actually make an API call that will create the new post entry in our fake API server instead, so that it's "saved". (Since this is a fake API, the new post won't persist if we reload the page, but if we had a real backend server it would be available next time we reload.)
使用 Thunk 发送数据
¥Sending Data with Thunks
我们可以使用 createAsyncThunk
来帮助发送数据,而不仅仅是获取数据。我们将创建一个 thunk,它接受 <AddPostForm>
中的值作为参数,并对假 API 进行 HTTP POST 调用以保存数据。
¥We can use createAsyncThunk
to help with sending data, not just fetching it. We'll create a thunk that accepts the values from our <AddPostForm>
as an argument, and makes an HTTP POST call to the fake API to save the data.
在此过程中,我们将更改在 reducer 中使用新 post 对象的方式。目前,我们的 postsSlice
正在 prepare
回调中为 postAdded
创建一个新的帖子对象,并为该帖子生成一个新的唯一 ID。在大多数将数据保存到服务器的应用中,服务器将负责生成唯一 ID 并填写任何额外字段,并且通常会在其响应中返回完整的数据。因此,我们可以向服务器发送一个像 { title, content, user: userId }
这样的请求体,然后获取它发回的完整的 post 对象并将其添加到我们的 postsSlice
状态中。我们还将提取一个 NewPost
类型来表示传递到 thunk 中的对象。
¥In the process, we're going to change how we work with the new post object in our reducers. Currently, our postsSlice
is creating a new post object in the prepare
callback for postAdded
, and generating a new unique ID for that post. In most apps that save data to a server, the server will take care of generating unique IDs and filling out any extra fields, and will usually return the completed data in its response. So, we can send a request body like { title, content, user: userId }
to the server, and then take the complete post object it sends back and add it to our postsSlice
state. We'll also extract a NewPost
type to represent the object that gets passed into the thunk.
type PostUpdate = Pick<Post, 'id' | 'title' | 'content'>
type NewPost = Pick<Post, 'title' | 'content' | 'user'>
export const addNewPost = createAppAsyncThunk(
'posts/addNewPost',
// The payload creator receives the partial `{title, content, user}` object
async (initialPost: NewPost) => {
// We send the initial data to the fake API server
const response = await client.post<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) {
builder
// omit the cases for `fetchPosts` and `userLoggedOut`
.addCase(addNewPost.fulfilled, (state, action) => {
// We can directly add the new post object to our posts array
state.posts.push(action.payload)
})
}
})
// Remove `postAdded`
export const { postUpdated, reactionAdded } = postsSlice.actions
检查组件中的 Thunk 结果
¥Checking Thunk Results in Components
最后,我们将更新 <AddPostForm>
以调度 addNewPost
thunk,而不是旧的 postAdded
操作。由于这是对服务器的另一个 API 调用,因此需要一些时间并且可能会失败。addNewPost()
thunk 会自动将其 pending/fulfilled/rejected
操作分派到我们已经在处理的 Redux 存储。
¥Finally, we'll update <AddPostForm>
to dispatch the addNewPost
thunk instead of the old postAdded
action. Since this is another API call to the server, it will take some time and could fail. The addNewPost()
thunk will automatically dispatch its pending/fulfilled/rejected
actions to the Redux store, which we're already handling.
如果愿意,我们可以使用第二个加载枚举来跟踪 postsSlice
中的请求状态。但是,对于此示例,让我们将加载状态跟踪限制在组件内,以显示还有什么可能。
¥We could track the request status in postsSlice
using a second loading enum if we wanted to. But, for this example let's keep the loading state tracking limited to the component, to show what else is possible.
如果我们在等待请求时至少可以禁用 "保存帖子" 按钮,那就太好了,这样用户就不会意外地尝试保存帖子两次。如果请求失败,我们可能还想在表单中显示错误消息,或者只是将其记录到控制台。
¥It would be good if we can at least disable the "Save Post" button while we're waiting for the request, so the user can't accidentally try to save a post twice. If the request fails, we might also want to show an error message here in the form, or perhaps just log it to the console.
我们可以让组件逻辑等待 async thunk 完成,并在完成后检查结果:
¥We can have our component logic wait for the async thunk to finish, and check the result when it's done:
import React, { useState } from 'react'
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { selectCurrentUsername } from '@/features/auth/authSlice'
import { addNewPost } from './postsSlice'
// omit field types
export const AddPostForm = () => {
const [addRequestStatus, setAddRequestStatus] = useState<'idle' | 'pending'>(
'idle'
)
const dispatch = useAppDispatch()
const userId = useAppSelector(selectCurrentUsername)!
const handleSubmit = async (e: React.FormEvent<AddPostFormElements>) => {
// Prevent server submission
e.preventDefault()
const { elements } = e.currentTarget
const title = elements.postTitle.value
const content = elements.postContent.value
const form = e.currentTarget
try {
setAddRequestStatus('pending')
await dispatch(addNewPost({ title, content, user: userId })).unwrap()
form.reset()
} catch (err) {
console.error('Failed to save the post: ', err)
} finally {
setAddRequestStatus('idle')
}
}
// omit rendering logic
}
我们可以添加一个加载状态枚举字段作为 React useState
钩子,类似于我们在 postsSlice
中跟踪加载状态以获取帖子的方式。在这种情况下,我们只想知道请求是否正在进行。
¥We can add a loading status enum field as a React useState
hook, similar to how we're tracking loading state in postsSlice
for fetching posts. In this case, we just want to know if the request is in progress or not.
当我们调用 dispatch(addNewPost())
时,异步 thunk 从 dispatch
返回一个 Promise。我们可以在这里确保 await
知道 thunk 何时完成其请求。但是,我们还不知道该请求是成功还是失败。
¥When we call dispatch(addNewPost())
, the async thunk returns a Promise from dispatch
. We can await
that promise here to know when the thunk has finished its request. But, we don't yet know if that request succeeded or failed.
createAsyncThunk
在内部处理任何错误,因此我们在日志中看不到任何有关 "拒绝的 Promise" 的消息。然后它返回它调度的最终操作:如果成功则执行 fulfilled
操作,如果失败则执行 rejected
操作。这意味着 await dispatch(someAsyncThunk())
始终是 "succeeds",结果是操作对象本身。
¥createAsyncThunk
handles any errors internally, so that we don't see any messages about "rejected Promises" in our logs. It then returns the final action it dispatched: either the fulfilled
action if it succeeded, or the rejected
action if it failed. That means that await dispatch(someAsyncThunk())
always "succeeds", and the result is the action object itself.
但是,通常希望编写逻辑来查看所发出的实际请求的成功或失败。Redux Toolkit 向返回的 Promise 添加了一个 .unwrap()
函数,它将返回一个新的 Promise,该 Promise 要么具有来自 fulfilled
操作的实际 action.payload
值,要么如果是 rejected
操作则会抛出错误。这让我们可以使用正常的 try/catch
逻辑来处理组件中的成功和失败。因此,如果帖子创建成功,我们将清除输入字段以重置表单,如果失败,我们将把错误记录到控制台。
¥However, it's common to want to write logic that looks at the success or failure of the actual request that was made. Redux Toolkit adds a .unwrap()
function to the returned Promise, which will return a new Promise that either has the actual action.payload
value from a fulfilled
action, or throws an error if it's the rejected
action. This lets us handle success and failure in the component using normal try/catch
logic. So, we'll clear out the input fields to reset the form if the post was successfully created, and log the error to the console if it failed.
如果你想查看 addNewPost
API 调用失败时会发生什么,请尝试创建一个新帖子,其中 "内容" 字段仅包含单词 "error"(不带引号)。服务器将看到这一点并发回失败的响应,因此你应该看到一条记录到控制台的消息。
¥If you want to see what happens when the addNewPost
API call fails, try creating a new post where the "Content" field only has the word "error" (without quotes). The server will see that and send back a failed response, so you should see a message logged to the console.
你学到了什么
¥What You've Learned
异步逻辑和数据获取始终是一个复杂的主题。正如你所看到的,Redux Toolkit 包含一些工具来自动执行典型的 Redux 数据获取模式。
¥Async logic and data fetching are always a complex topic. As you've seen, Redux Toolkit includes some tools to automate the typical Redux data fetching patterns.
现在我们从那个假 API 获取数据,我们的应用如下所示:
¥Here's what our app looks like now that we're fetching data from that fake API:
提醒一下,这是我们在本节中介绍的内容:
¥As a reminder, here's what we covered in this section:
Redux 使用名为 "中间件" 的插件来启用异步逻辑
¥Redux uses plugins called "middleware" to enable async logic
标准异步中间件称为
redux-thunk
,它包含在 Redux Toolkit 中¥The standard async middleware is called
redux-thunk
, which is included in Redux ToolkitThunk 函数接收
dispatch
和getState
作为参数,并可以将它们用作异步逻辑的一部分¥Thunk functions receive
dispatch
andgetState
as arguments, and can use those as part of async logic
你可以调度其他操作来帮助跟踪 API 调用的加载状态
¥You can dispatch additional actions to help track the loading status of an API call
典型的模式是在调用之前调度 "pending" 操作,然后调度包含数据的 "success" 或包含错误的 "failure" 操作
¥The typical pattern is dispatching a "pending" action before the call, then either a "success" containing the data or a "failure" action containing the error
加载状态通常应存储为枚举,例如
'idle' | 'pending' | 'succeeded' | 'rejected'
¥Loading state should usually be stored as an enum, like
'idle' | 'pending' | 'succeeded' | 'rejected'
Redux Toolkit 有一个
createAsyncThunk
API 可以为你分派这些操作¥Redux Toolkit has a
createAsyncThunk
API that dispatches these actions for youcreateAsyncThunk
接受应返回 Promise 的 "有效负载创建者" 回调,并自动生成pending/fulfilled/rejected
操作类型¥
createAsyncThunk
accepts a "payload creator" callback that should return a Promise, and generatespending/fulfilled/rejected
action types automatically生成的动作创建器(如
fetchPosts
)会根据你返回的 Promise 调度这些动作¥Generated action creators like
fetchPosts
dispatch those actions based on the Promise you return你可以使用
extraReducers
字段监听createSlice
中的这些操作类型,并根据这些操作更新 reducer 中的状态。¥You can listen for these action types in
createSlice
using theextraReducers
field, and update the state in reducers based on those actions.createAsyncThunk
有一个condition
选项,可用于根据 Redux 状态取消请求¥
createAsyncThunk
has acondition
option that can be used to cancel a request based on the Redux statethunks 可以返回 promise。具体来说,对于
createAsyncThunk
,你可以await dispatch(someThunk()).unwrap()
在组件级别处理请求成功或失败。¥Thunks can return promises. For
createAsyncThunk
specifically, you canawait dispatch(someThunk()).unwrap()
to handle the request success or failure at the component level.
下一步是什么?
¥What's Next?
我们还有一组主题来涵盖核心 Redux Toolkit API 和使用模式。在 第 6 部分:性能和标准化数据 中,我们将了解 Redux 的使用如何影响 React 性能,以及我们可以优化应用以提高性能的一些方法。
¥We've got one more set of topics to cover the core Redux Toolkit APIs and usage patterns. In Part 6: Performance and Normalizing Data, we'll look at how Redux usage affects React performance, and some ways we can optimize our application for improved performance.