代码下载

异步逻辑和数据获取

介绍

在使用 Redux 数据 中,了解了如何在 React 组件内部使用来自 Redux Store 的多条数据,在分派操作对象之前自定义操作对象的内容,以及在 reducer 中处理更复杂的更新逻辑。

到目前为止,使用的所有数据都直接位于 React 客户端应用内部。然而,大多数实际应用需要通过调用 HTTP API 来获取和保存项目来处理来自服务器的数据。在本部分中,将转换社交媒体应用以从 API 获取帖子和用户数据,并通过将新帖子保存到 API 来添加新帖子。

提示:Redux 工具包包括 RTK 查询数据获取和缓存 API。RTK Query 是专门为 Redux 应用构建的数据获取和缓存的解决方案,无需编写任何额外的 Redux 逻辑(如 thunk 或 Reducer)来管理数据获取。RTK 查询作为数据获取的默认方法,本节了解数据获取如何与 Redux 协同工作的底层机制。

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 来关闭该功能。

使用中间件启用异步逻辑

Redux Store 本身并不了解异步逻辑。它只知道如何同步分派操作,通过调用根 reducer 函数更新状态,并通知 UI 某些内容发生了变化。任何异步性都必须发生在存储之外。

如果想通过分派操作、检查当前 Store 状态或某种副作用来让异步逻辑与 Store 交互,该怎么办?这就是 Redux 中间件发挥作用的地方。它们扩展 Store 以添加其他功能,并允许:

  • 分派任何操作时执行额外的逻辑(例如记录操作和状态)
  • 暂停、修改、延迟、替换或停止已调度的操作
  • 编写可以访问 dispatch 和 getState 的额外代码
  • 教 dispatch 如何接受除普通操作对象之外的其他值,例如函数和 Promise,通过拦截它们并分派真实的操作对象
  • 编写使用异步逻辑或其他副作用的代码

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

中间件和 Redux 数据流

中间件通过在 dispatch 的开头添加额外的步骤来更新 Redux 数据流。这样,中间件可以运行 HTTP 请求等逻辑,然后分派操作。这使得异步数据流看起来像这样:
在这里插入图片描述

thunks 和异步逻辑

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

thunk 函数

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

thunk 函数可以包含任何逻辑,同步或异步。Thunk 通常使用动作创建器来分派普通动作,例如 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 内部使用的参数。

const logAndAdd = (amount) => {
    return (dispatch, getState) => {
        const stateBefore = getState()
        console.log('Counter before: ', stateBefore);
        dispatch(incrementByAmount(amount))
        const stateAfter = getState()
        console.log('Counter after: ', stateAfter);
    }
}
store.dispatch(logAndAdd(10))

Thunk 通常用 “slice” 文件 编写,因为 thunk 数据获取通常在概念上与特定切片的更新逻辑相关。

编写异步 thunk

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

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

  • 在请求之前调度 “start” 操作,以指示请求正在进行中。这可用于跟踪加载状态以允许跳过重复请求或在 UI 中显示加载指示器。
  • 异步请求是使用 fetch 或封装库发出的,得到 promise 结果。
  • 当请求 promise 完成时,异步逻辑会分派包含结果数据的 “success” 操作或包含错误详细信息的 “failure” 操作。reducer 逻辑会清除两种情况下的加载状态,并处理成功案例的结果数据,或存储错误值以供潜在显示。

这些步骤不是必需的,但很常用。(如果只关心成功结果,则可以在请求完成时仅分派单个 “success” 操作,并跳过 “start” 和 “failure” 操作。)Redux Toolkit 提供了一个 createAsyncThunk API 来实现描述异步请求的操作的创建和调度。基本 createAsyncThunk 用法如下:

import { createAsyncThunk } from "@reduxjs/toolkit";
import { fetchCount } from "./counterAPI";

export const incrementAsync = createAsyncThunk('counter/fetchCount', async (amount) => {
    const response = await fetchCount(amount)
    return response.data
})
Redux Thunks
手写 Thunks

如果手动编写 thunk,则可以将 thunk 参数声明为 (dispatch, getState)

const logAndAdd = (amount) => {
    return (dispatch, getState) => {
        const stateBefore = getState()
        console.log('Counter before: ', stateBefore);
        dispatch(incrementByAmount(amount))
        const stateAfter = getState()
        console.log('Counter after: ', stateAfter);
    }
}
createAsyncThunk

见上文,略。

加载帖子

postsSlice 已经使用了一些硬编码的样本数据作为其初始状态。将把它切换为从空的帖子数组开始,然后从服务器获取帖子列表。必须更改 postsSlice 中的状态结构,以便我们可以跟踪 API 请求的当前状态。

请求的加载状态

当进行 API 调用时,可以将其进度视为一个小型状态机,该状态机可以处于四种可能状态之一:

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

可以使用一些布尔值(例如 isLoading: true)来跟踪该信息,但最好将这些状态作为单个枚举值来跟踪。一个好的模式是有一个如下所示的状态部分:

{
    // 多个可能的状态值
    status: 'idle', //'idle' | 'pending' | 'succeeded' | 'failed',
    error: null// string | null
}

这些字段将与存储的任何实际数据一起存在。这些特定的字符串状态名称不是必需的,可以随意使用其他名称,例如 ‘loading’ 代替 ‘pending’,或 ‘completed’ 代替 ‘succeeded’。可以使用这些信息来决定在请求进行时在 UI 中显示什么内容,还可以在 reducer 中添加逻辑以防止诸如加载数据两次之类的情况。

新建 asyncPostsSlice.js 以使用此模式来跟踪 “获取帖子” 请求的加载状态。把状态从帖子数组本身切换为 {posts, status, error}。还将从初始状态中删除旧的示例帖子条目,并为加载和错误字段添加几个新的选择器:

import { createSlice, nanoid } from "@reduxjs/toolkit";
import { userLoggedOut } from "../auth/authSlice";


const initialReactions = {
    thumbsUp: 0,
    tada: 0,
    heart: 0,
    rocket: 0,
    eyes: 0
  }

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

const asyncPostsSlice = createSlice({
    name: 'asyncPosts',
    initialState: initialState,
    reducers: {
        postAdded: {
            reducer: (state, action) => {
                state.posts.push(action.payload)
            },
            prepare: (title, content, userId) => {
                return {payload: {id: nanoid(), title, content, user: userId, 
                    date: new Date().toISOString(),
                    reactions: {...initialReactions}
                }}
            }
        },
        postUpdated: (state, action) => {
            const {id, title, content} = action.payload
            const existingPost = state.posts.find(post => post.id === id)
            console.log('existingPost: ', existingPost);

            if (existingPost) {
                existingPost.title = title
                existingPost.content = content
            }
        },
        reactionAdded: (state, action) => {
            const {postId, reaction} = action.payload
            const existingPost = state.posts.find(post => post.id === postId)
            console.log('existingPost: ', existingPost);
            
            if (existingPost) {
                existingPost.reactions[reaction]++
            }
        }
    },
    selectors: {
        selectAllPosts: (postsState) => postsState.posts,
        selectPostById: (postsState, postId) => postsState.posts.find(post => post.id === postId),
        selectPostsStatus: (postsState) => postsState.status,
        selectPostsError: (postsState) => postsState.error
    },
    extraReducers: (builder) => {
        builder.addCase(userLoggedOut, (state) => {
            return initialState
        })
    }
})

export const {selectAllPosts, selectPostById, selectPostsStatus, selectPostsError} = asyncPostsSlice.selectors

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

export default asyncPostsSlice.reducer

作为更改的一部分,还需要将 state 作为数组的任何用途更改为 state.posts,因为数组现在更深一层。现在有一个 state.posts.posts 的嵌套对象路径,这有点重复和愚蠢,如果想避免这种情况,可以将嵌套数组名称更改为 items 或 data 或其他名称,但暂时保持原样。

使用 createAsyncThunk 获取数据

Redux Toolkit 的 createAsyncThunk API 会生成 thunk,自动分派 “开始/成功/失败” 操作。首先添加一个 thunk,它将发出 HTTP 请求以检索帖子列表。从 src/api 文件夹导入 client,并使用它向 /fakeApi/posts 发出请求。

import { createAsyncThunk } from "@reduxjs/toolkit";
import { client } from "../../api/client";

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

createAsyncThunk 接受两个参数:

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

payload 创建者通常会发出某种 HTTP 请求,并且可以直接从 HTTP 请求返回 Promise,或者从 API 响应中提取一些数据并返回。通常使用 JS async/await 语法编写此代码,这可以编写使用 promise 的函数,同时使用标准 try/catch 逻辑而不是 somePromise.then() 链。

传入 asyncPosts/fetchPosts 作为操作类型前缀。fetchPosts 的 payload 创建回调不需要任何参数,它所需要做的就是等待 API 调用返回响应。响应对象类似 {data: []},希望调度的 Redux 操作具有只是帖子数组的 payload。因此提取 response.data,并从回调中返回它。

如果尝试调用 dispatch(fetchPosts()),fetchPosts thunk 将首先调度 asyncPosts/fetchPosts/pending 的操作类型,可以在 reducer 中监听此操作并将请求状态标记为 ‘pending’。一旦 Promise 解析,fetchPosts thunk 将获取从回调中返回的 response.data 数组,并调度包含帖子数组的 asyncPosts/fetchPosts/fulfilled 操作作为 action.payload

reducer 和加载动作

接下来需要在 reducer 中处理这两个操作。这需要更深入地了解一直在使用的 createSlice API。createSlice 将在 reducers 字段中定义的每个 reducer 函数生成一个动作创建者,并且生成的动作类型包括切片的名称。

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

使用 createSlice 中的 extraReducers 字段用于响应在切片之外定义的操作。在这种情况下,需要监听 fetchPosts thunk 调度的 “pending” 和 “fulfilled” 操作类型。这些动作创建者附加到实际的 fetchPost 函数,可以将它们传递给 extraReducers 来监听这些动作:

    extraReducers: (builder) => {
        builder.addCase(userLoggedOut, (state) => {
            return initialState
        })
        .addCase(fetchPosts.pending, (state, action) => {
            state.status = 'pending'
        })
        .addCase(fetchPosts.fulfilled, (state, action) => {
            state.status = 'succeeded'
            state.posts.push(...action.payload)
        })
        .addCase(fetchPosts.rejected, (state, action) => {
            state.status = 'failed'
            state.error = action.error.message ?? 'Unknown Error'
        })
    }

根据返回的 Promise 处理 thunk 可以分派的所有三种操作类型:

  • 当请求开始时,将 status 设置为 ‘pending’
  • 如果请求成功,将 status 标记为 ‘succeeded’,并将获取的帖子添加到 state.posts
  • 如果请求失败,会将 status 标记为 ‘failed’,并将任何错误消息保存到状态中,以便可以显示它
从组件中分派 thunk

已经编写了 fetchPosts thunk 并更新了切片来处理这些操作,重新构建 <AsyncPostsList> 组件以实际启动数据获取。

将 fetchPosts thunk 导入到组件中。与所有其他动作创建器一样,必须分派它,因此还需要添加 useDispatch 钩子。由于想在 <AsyncPostsList> 安装时获取此数据,需要导入 React useEffect 钩子并分派操作。

重要的是只尝试获取帖子列表一次。如果每次渲染 <AsyncPostsList> 组件时都执行此操作,或者由于在视图之间切换而重新创建组件,则最终可能会多次获取帖子。可以使用 posts.status 来帮助决定是否需要真正开始获取,方法是将其选入组件,并且仅在状态为 ‘idle’(即尚未开始)时才开始获取。

import { useSelector, useDispatch } from "react-redux";
import { fetchPosts, selectAllPosts, selectPostsStatus } from "./asyncPostsSlice";
import { useEffect } from "react";

export default function AsyncPostsList() {
    const posts = useSelector(selectAllPosts)
    const orderedPosts = posts.slice().sort((a, b) => b.date.localeCompare(a.date))

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

    ……
}

data.js 中添加路由数据后,看到一个新的帖子列表:

  {
    path: '/async/postsList',
    title: '异步数据帖子列表示例',
    element: <AsyncPostsList></AsyncPostsList>,
    showHome: true
  }
避免重复获取

遇到了一个问题,目前帖子列表显示每个帖子的重复项。这是为什么?刚刚添加了对 postStatus === 'idle' 的检查,这还不足以确保我们只分派一次 thunk 吗?

useEffect 中的实际逻辑是正确的。问题是,在开发中 React 将在其 <StrictMode> 组件内部运行两次所有 useEffect 钩子 是为了让某些类型的错误更明显地发生。

在这种情况下,发生的事情是:

  • 已安装 组件
  • useEffect 钩子首次运行。postStatus 值为 ‘idle’,因此它会分派 fetchPosts thunk。
  • fetchPosts 立即分派其 fetchPosts.pending 操作,因此 Redux 存储确实立即将状态更新为 ‘pending’…
  • 但 React 再次运行 useEffect 而没有重新渲染组件,因此仍然认为 postStatus 是 ‘idle’ 并第二次分派 fetchPosts
  • 两个 thunk 都完成了数据获取,分派了 fetchPosts.fulfilled 动作,fulfilled reducer运行了两次,从而导致一组重复的帖子被添加到状态中

那么,该如何解决这个问题?

  • 一种选择是从我们的应用中删除 <StrictMode> 标签。但是,React 团队建议使用它,它有助于发现其他问题。
  • 可以使用 useRef 钩子编写一些复杂的逻辑来跟踪此组件是否实际上是第一次渲染,并使用它来仅分派一次 fetchPosts。但是,这有点丑陋。
  • 最后一个选项是使用 Redux 状态中的实际 state.posts.status 值来检查是否已经有请求正在进行,如果是,则让 thunk 本身退出。幸运的是,createAsyncThunk 提供了一种方法来做到这一点。
检查异步 Thunk 条件

createAsyncThunk 接受可选的 condition 回调,可以使用它来进行该检查。如果提供,它将在 thunk 调用开始时运行,并且如果 condition 返回 false,它将取消整个 thunk

在这种情况下,如果 state.posts.status 字段不是 ‘idle’,想要避免运行 thunk。已经有一个可以在这里使用的 selectPostsStatus 选择器,因此可以添加 condition 选项并检查该值:

export const fetchPosts = createAsyncThunk('asyncPosts/fetchPosts', async () => {
    console.log('asyncPosts');
    const response = await client.get('/fakeApi/posts')
    console.log('data: ', response.data);
    return response.data
}, {
    condition: (arg, thunkApi) => {
        const postsStatus = selectPostsStatus(thunkApi.getState())
        return postsStatus === 'idle'
    }
})

重新加载页面并查看 <AsyncPostsList> 时,只看到一组帖子,没有重复,并且只在 Redux DevTools 中看到一组分派的操作。不需要将 condition 添加到所有 thunk,但有时确保一次只发出一个请求很有用。

显示加载状态

<AsyncPostsList> 组件已经在检查 Redux 中存储的帖子的任何更新,并在列表发生变化时重新渲染自身。因此,如果刷新页面,应该会看到来自虚假 API 的一组随机帖子出现在屏幕上。但是存在一些滞后 - <AsyncPostsList> 最初是空的,几秒钟后显示帖子。

真正的 API 调用可能需要一些时间才能返回响应,因此通常最好在 UI 中显示某种 “正在加载…” 指示器,以便用户知道正在等待数据。可以更新 <AsyncPostsList> 以根据 state.posts.status 枚举显示不同的 UI:如果正在加载,则显示一个加载控件;如果失败,则显示一条错误消息;如果有数据,则显示实际的帖子列表。

此时提取 <PostExcerpt> 组件来封装列表中一项的渲染,结果如下所示:

import { useSelector, useDispatch } from "react-redux";
import { Link } from "react-router-dom";
import { fetchPosts, selectAllPosts, selectPostsStatus, selectPostsError } from "./asyncPostsSlice";
import PostAuthor from "../posts/PostAuthor";
import AsyncReactionButtons from "./AsyncReactionButtons";
import { useEffect } from "react";
import Spinner from "../../components/Spinner";

function PostExcerpt({post}) {
    return <article>
        <h3><Link to={`/async/posts/${post.id}`}>{post.title}</Link></h3>
        <p>{post.content.substring(0, 100)}</p>
        <PostAuthor userId={post.user}></PostAuthor>
        <AsyncReactionButtons post={post}></AsyncReactionButtons>
    </article>
}

export default function AsyncPostsList() {
    const dispatch = useDispatch()
    const postStatus = useSelector(selectPostsStatus)
    useEffect(() => {
        if (postStatus === 'idle') {
            dispatch(fetchPosts())
        }
    }, [postStatus, dispatch])

    const posts = useSelector(selectAllPosts)
    const postsError= useSelector(selectPostsError)
    let content = null
    if (postStatus === 'pending') {
        content = <Spinner text='Loading...'></Spinner>
    } else if (postStatus === 'succeeded') {
        const orderedPosts = posts.slice().sort((a, b) => b.date.localeCompare(a.date))
        content = orderedPosts.map((post) => <PostExcerpt key={post.id} post={post}>
        </PostExcerpt>)
    } else if (postStatus === 'rejected') {
        content = <div>{postsError}</div>
    }

    return (<section>
        <h2>Posts</h2>
        {content}
    </section>)
}

可能会注意到 API 调用需要一段时间才能完成,并且加载控件会在屏幕上停留几秒钟。模拟 API 服务器配置为向所有响应添加 2 秒的延迟,如果想改变这个行为,可以打开 api/server.js,并改变这一行:

const ARTIFICIAL_DELAY_MS = 2000
可选:在 createSlice 中定义 Thunk

现在 fetchPosts thunk 在 postsSlice.js 文件中定义,但在 createSlice() 调用之外。有一种可选的方法可以在 createSlice 内部定义 thunk,这需要更改 reducers 字段的定义方式。

编写 createSlice.reducers 字段的标准方法是将其作为对象,其中键成为操作名称,值是 Reducer。值还可以是具有 {reducer, prepare} 函数的对象,用于创建具有想要的值的操作对象。

或者,reducers 字段可以是接收 create 对象的回调函数。这与使用 extraReducers 看到的有些相似,但创建 Reducer 和操作的方法不同:

  • create.reducer(caseReducer):定义 case reducer
  • create.preparedReducer(prepare, caseReducer):使用准备回调定义 Reducer

像以前一样返回一个以 Reducer 名称作为字段的对象,但调用 create 方法来制作每个 Reducer。将 postsSlice 转换为此语法后如下所示:

const asyncPostsSlice = createSlice({
    name: 'asyncPosts',
    initialState: initialState,
    reducers: (create) => {
        return {
            postAdded: create.preparedReducer((title, content, userId) => {
                return {payload: {id: nanoid(), title, content, user: userId, 
                    date: new Date().toISOString(),
                    reactions: {...initialReactions}
                }}
            }, (state, action) => {
                state.posts.push(action.payload)
            }),
            postUpdated: create.reducer((state, action) => {
                const {id, title, content} = action.payload
                const existingPost = state.posts.find(post => post.id === id)
                console.log('existingPost: ', existingPost);
    
                if (existingPost) {
                    existingPost.title = title
                    existingPost.content = content
                }
            }),
            reactionAdded: create.reducer((state, action) => {
                const {postId, reaction} = action.payload
                const existingPost = state.posts.find(post => post.id === postId)
                console.log('existingPost: ', existingPost);
                
                if (existingPost) {
                    existingPost.reactions[reaction]++
                }
            })
        }
    },
    ……
})

将 reducers 编写为回调为扩展 createSlice 的功能打开了大门。特别是,可以制作一个特殊版本的 createSlice,该版本能够使用内置的 createAsyncThunk。导入 buildCreateSlice 和 asyncThunkCreator,然后像这样调用 buildCreateSlice:

import { buildCreateSlice, asyncThunkCreator } from '@reduxjs/toolkit'

export const createAppSlice = buildCreateSlice({
  creators: { asyncThunk: asyncThunkCreator }
})

这提供了一个可以在其中编写 thunk 的 createSlice 版本。可以使用该 createAppSlice 方法定义包含 fetchPosts thunk 的 postsSlice。当这样做时,其他一些事情会发生变化:

  • 在使用 ts 时,不能直接传入 RootState 泛型,所以必须执行 getState() as RootState 来强制转换它
  • 可以将处理 thunk 操作的所有 Reducer 作为选项的一部分传递给 create.asyncThunk(),并从 extraReducers 字段中删除它们:

const asyncPostsSlice = createAppSlice({
    name: 'asyncPosts',
    initialState: initialState,
    reducers: (create) => {
        return {
            fetchPosts: create.asyncThunk(async () => {
                const response = await client.get('/fakeApi/posts')
                return response.data
            }, {
                options: {
                    condition: (arg, thunkApi) => {
                        const postsStatus = thunkApi.getState().asyncPosts.status
                        return postsStatus === 'idle'
                    }
                },
                pending: (state, action) => {
                    state.status = 'pending'
                },
                fulfilled: (state, action) => {
                    state.status = 'succeeded'
                    state.posts.push(...action.payload)
                },
                rejected: (state, action) => {
                    state.status = 'failed'
                    state.error = action.error.message ?? 'Unknown Error'
                }
            })
        }
    },
    extraReducers: (builder) => {
        builder.addCase(userLoggedOut, (state) => {
            return initialState
        })
    }
})

export const { postAdded, postUpdated, reactionAdded, fetchPosts } = asyncPostsSlice.actions

请记住,create 回调语法是可选的!唯一需要使用它的时候是真的想在 createSlice 内部编写 thunk。也就是说,它确实消除了使用 PayloadAction 类型的需要,并且也减少了 extraReducers。

加载用户

看一下帖子,就会发现一个问题:作者未知。这是因为帖子条目是由虚假 API 服务器随机生成的,每次重新加载页面时,该服务器也会随机生成一组虚假用户。需要更新用户切片以在应用启动时获取这些用户。

新建 users/asyncUsersSlice.js 文件,创建另一个异步 thunk 以从 API 获取用户并返回它们,然后处理 extraReducers 切片字段中的 fulfilled 操作。暂时不用担心加载状态:

import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import { selectCurrentUsername } from "../auth/authSlice";
import { client } from "../../api/client";

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

const initialState = []

const usersSlice = createSlice({
    name: 'asyncUsers',
    initialState,
    reducers: {},
    selectors: {
        selectAllUsers: usersState => usersState,
        selectUserById: (usersState, id) => usersState.find(user => user.id === id)
    },
    extraReducers: (builder) => {
        builder.addCase(fetchUsers.fulfilled, (state, action) => {
            return action.payload
        })
    }
})

export default usersSlice.reducer

export const {selectAllUsers, selectUserById} = usersSlice.selectors

export const selectCurrentUser = (state) => {
    const currentUsername = selectCurrentUsername(state)
    return selectUserById(state, currentUsername)
}

这次 case reducer 根本不使用 state 变量。而是直接返回 action.payload。Immer 使得可以通过两种方式更新状态:要么改变现有的状态值,要么返回新的结果。如果返回一个新值,它将用返回的值完全替换现有状态。(请注意,如果想手动返回新值,则需要编写可能需要的任何不可变更新逻辑。)

初始状态是一个空数组,可能已经完成了 state.push(...action.payload) 来改变它。但是,在我们的例子中,我们确实希望用服务器返回的任何内容替换用户列表,这可以避免意外复制状态中的用户列表的任何机会。

只需要获取用户列表一次,并且希望在应用启动时正确执行此操作。可以在 main.tsx 文件中执行此操作,并直接分派 fetchUsers thunk,因为有 store:

import { worker } from "./api/server";
import { fetchUsers } from "./features/users/asyncUsersSlice";

async function mockStart() {
  await worker.start({ onUnhandledRequest: 'bypass' })

  store.dispatch(fetchUsers())

  const root = ReactDOM.createRoot(document.getElementById('root'));
  root.render(
    <React.StrictMode>
      <Provider store={store}>
        <React.Suspense>
          <Router><App /></Router>
        </React.Suspense>
      </Provider>
    </React.StrictMode>
  );
}
mockStart()

注意,这是在启动时获取数据的有效方法。这实际上在我们开始渲染 React 组件之前启动了获取过程,因此数据应该更快地可用。(请注意,也可以通过使用 React Router 数据加载器 来实现。) 现在,每个帖子都应该显示用户名,并且还应该在 <AsyncAddPostForm> 的 “作者” 下拉列表中显示相同的用户列表。

添加新帖子

当从 <AddPostForm> 添加新帖子时,该帖子只会添加到应用内的 Redux Store 中。实际上需要进行一个 API 调用,在假 API 服务器中创建新的帖子条目,因此它是 “saved”。(由于这是一个假 API,如果重新加载页面,新帖子将不会保留,但如果有一个真正的后端服务器,那么下次重新加载时它将可用。)

使用 Thunk 发送数据

可以使用 createAsyncThunk 来发送数据,而不仅仅是获取数据。创建一个 thunk,它接受 <AsyncAddPostForm> 中的值作为参数,并对假 API 进行 HTTP POST 调用以保存数据。

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

export const fetchPosts = createAsyncThunk('asyncPosts/fetchPosts', async () => {
    console.log('asyncPosts');
    const response = await client.get('/fakeApi/posts')
    console.log('data: ', response.data);
    return response.data
}, {
    condition: (arg, thunkApi) => {
        const postsStatus = selectPostsStatus(thunkApi.getState())
        return postsStatus === 'idle'
    }
})

const asyncPostsSlice = createSlice({
    name: 'asyncPosts',
    initialState: initialState,
    reducers: {
        postUpdated: (state, action) => {
            ……
        },
        reactionAdded: (state, action) => {
            ……
        }
    },
    selectors: {
        ……
    },
    extraReducers: (builder) => {
        builder.addCase(addNewPost.fulfilled, (state, action) => {
            state.posts.push(action.payload)
        })
    }
})

export const { postAdded, postUpdated, reactionAdded } = asyncPostsSlice.actions
检查组件中的 Thunk 结果

新建 <AsyncAddPostForm> 以调度 addNewPost thunk,而不是旧的 postAdded 操作。由于这是对服务器的另一个 API 调用,因此需要一些时间并且可能会失败。addNewPost thunk 会自动将其 pending/fulfilled/rejected 操作分派到 Redux Store。

如果愿意,我们可以使用第二个加载枚举来跟踪 postsSlice 中的请求状态。但是,对于此示例,让我们将加载状态跟踪限制在组件内,以显示还有什么可能。

如果在等待请求时可以禁用 “保存帖子” 按钮,那就太好了,这样用户就不会意外地尝试保存帖子两次。如果请求失败,还想在表单中显示错误消息,或者只是将其记录到控制台。可以让组件逻辑等待 async thunk 完成,并在完成后检查结果:

import { useDispatch, useSelector } from "react-redux";
import { selectAllUsers } from "../users/asyncUsersSlice";
import { addNewPost } from "./asyncPostsSlice";
import { useState } from "react";

export default function AddPostForm() {
    const dispatch = useDispatch()
    const users = useSelector(selectAllUsers)

    const [addRequestStatus, setAddRequestStatus] = useState('idle')

    const handleSubmit = async (e) => {
        e.preventDefault()

        const { elements } = e.target
        const title = elements.postTitle.value
        const content = elements.postContent.value
        const userId = elements.postAuthor.value
        try {
            setAddRequestStatus('pending')
            await dispatch(addNewPost({title, content, user: userId})).unwrap()
            e.target.reset()
        } catch (error) {
            console.error('Failed to save the post: ', error)
        } finally {
            setAddRequestStatus('idle')
        }
    }

    const usersOptions = users.map((user) => <option key={user.id} value={user.id}>{user.name}</option>)
    return (<section>
        <h2>Add a New Post</h2>
        <form onSubmit={handleSubmit}>
            <label htmlFor="postTitle">Post Title: </label>
            <input id="postTitle" type="text" required></input>
            <br></br>
            <label htmlFor="postAuthor">Author: </label>
            <select id="postAuthor" required>
                <option value=''></option>
                {usersOptions}
            </select>
            <br></br>
            <label htmlFor="postContent">Post Content: </label>
            <textarea id="postContent" name="postContent" required></textarea>
            <br></br>
            <button disabled={addRequestStatus !== 'idle'}>Save Post</button>
        </form>
    </section>)
}

可以添加一个加载状态字段作为 React useState 钩子,类似于在 postsSlice 中跟踪加载状态。在这里只想知道请求是否正在进行。

当调用 dispatch(addNewPost()) 时,异步 thunk 从 dispatch 返回一个 Promise。await 可以在这里确保知道 thunk 何时完成其请求。但是还不知道该请求是成功还是失败。

createAsyncThunk 在内部处理任何错误,因此在日志中看不到任何有关 “rejected Promise” 的消息。然后它返回它调度的最终操作:如果成功则执行 fulfilled 操作,如果失败则执行 rejected 操作。这意味着 await dispatch(someAsyncThunk()) 始终是 “succeeds”,结果是操作对象本身。

通常希望编写逻辑来查看所发出的实际请求的成功或失败。Redux Toolkit 向返回的 Promise 添加了一个 unwrap() 函数,它将返回一个新的 Promise,该 Promise 要么具有来自 fulfilled 操作的实际 action.payload 值,要么如果是 rejected 操作则会抛出错误。这就可以使用正常的 try/catch 逻辑来处理组件中的成功和失败。因此,如果帖子创建成功,将清除输入字段以重置表单,如果失败,将把错误记录到控制台。

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

总结

Redux 使用名为 “中间件” 的插件来启用异步逻

  • 标准异步中间件称为 redux-thunk,它包含在 Redux Toolkit 中
  • Thunk 函数接收 dispatch 和 getState 作为参数,并可以将它们用作异步逻辑的一部分

可以调度其他操作来帮助跟踪 API 调用的加载状态

  • 典型的模式是在调用之前调度 “pending” 操作,然后调度包含数据的 “success” 或包含错误的 “failure” 操作
  • 加载状态通常应存储为枚举,例如 ‘idle’ 、 ‘pending’ 、 ‘succeeded’ 、 ‘rejected’

Redux Toolkit 有一个 createAsyncThunk API 可以分派这些操作

  • createAsyncThunk 接受应返回 Promise 的 “payload 创建者” 回调,并自动生成 pending/fulfilled/rejected 操作类型
  • 生成的动作创建器(如 fetchPosts)会根据返回的 Promise 调度这些动作
  • 可以使用 extraReducers 字段监听 createSlice 中的这些操作类型,并根据这些操作更新 reducer 中的状态。
  • createAsyncThunk 有一个 condition 选项,可用于根据 Redux 状态取消请求
  • thunks 可以返回 promise。具体来说,对于 createAsyncThunk,可以 await dispatch(someThunk()).unwrap() 在组件级别处理请求成功或失败。
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐