跳至主内容

Redux 基础教程,第三部分:Redux 基本数据流

非官方测试版翻译

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

你将学习
  • 如何在 React 应用中设置 Redux store
  • 如何使用 createSlice 向 Redux store 添加 reducer 逻辑的“切片”
  • 通过 useSelector 钩子在组件中读取 Redux 数据
  • 通过 useDispatch 钩子在组件中派发 action
先决条件

简介

第一部分:Redux 概述与核心概念中,我们探讨了 Redux 如何通过提供单一中心化位置存储全局应用状态,帮助我们构建可维护的应用。我们还讨论了 Redux 的核心概念,如派发 action 对象、使用返回新状态值的 reducer 函数,以及通过 thunk 编写异步逻辑。在第二部分:Redux Toolkit 应用结构中,我们了解了 Redux Toolkit 的 configureStorecreateSlice 等 API 如何与 React-Redux 的 ProvideruseSelector 协同工作,使我们能够编写 Redux 逻辑并在 React 组件中与之交互。

既然您已经对这些组成部分有了基本认识,是时候将知识付诸实践了。我们将构建一个小型社交媒体动态应用,其中包含多个展示真实场景的功能。这将帮助您理解如何在自身应用中运用 Redux。

我们将使用 TypeScript 语法编写代码。您可以使用纯 JavaScript 搭配 Redux,但 TypeScript 能帮助避免常见错误,提供内置代码文档,并让编辑器在 React 组件和 Redux reducer 等位置显示所需的变量类型。我们强烈建议在所有 Redux 应用中使用 TypeScript。

注意

示例应用并非完整的生产级项目。其目标是帮助您学习 Redux API 和典型使用模式,并通过有限示例指引正确方向。此外,前期构建的某些部分将在后续更新中展示更优实践。请通读整个教程以了解所有概念的实际应用

项目设置

本教程中,我们准备了预配置的初始项目,其中已设置好 React 和 Redux,包含默认样式,并提供了模拟 REST API 以便在应用中编写真实 API 请求。您将以此为基础编写实际应用代码。

您可以通过以下 CodeSandbox 开始项目(建议先 Fork):

您也可以从该 GitHub 仓库克隆相同项目。项目配置使用 Yarn 4 作为包管理器,但您可以使用任意包管理器(NPMPNPMBun)。安装依赖后,可通过 yarn dev 命令启动本地开发服务器。

如果你想查看我们将要构建的最终版本,可以查看tutorial-steps-ts分支,或者在这个CodeSandbox中查看最终版本

我们要感谢 Tania Rascia,她的在React中使用Redux教程启发了本页的示例。本示例还使用了她的Primitive UI CSS starter进行样式设计。

创建新的Redux + React项目

完成本教程后,你可能想尝试自己的项目。我们推荐使用Vite和Next.js的Redux模板作为创建新的Redux + React项目的最快方式。这些模板已预先配置好Redux Toolkit和React-Redux,并使用了你在第一部分中看到的“计数器”应用示例。这样你就可以直接开始编写实际的应用代码,而无需手动添加Redux包和设置store。

探索初始项目

让我们快速浏览一下初始项目包含的内容:

  • /public:基础CSS样式和其他静态文件(如图标)

  • /src

    • main.tsx:应用程序的入口文件,负责渲染 <App> 组件。在本示例中,它还会在页面加载时设置模拟REST API。
    • App.tsx:主应用组件。渲染顶部导航栏并处理其他内容的客户端路由。
    • index.css:整个应用的样式
    • /api
      • client.ts:一个简单的fetch封装客户端,允许我们发起HTTP GET和POST请求
      • server.ts:为数据提供模拟的REST API。稍后我们的应用将从这些模拟端点获取数据。
    • /app
      • Navbar.tsx:渲染顶部标题和导航内容

如果现在加载应用,你应该能看到标题和欢迎信息,但没有任何功能。

那么,让我们开始吧!

设置Redux Store

当前项目是空的,因此我们需要从为Redux部分进行一次性的设置开始。

添加Redux包

如果你查看package.json,会发现我们已经安装了使用Redux所需的两个包:

  • @reduxjs/toolkit:现代的Redux包,包含了我们构建应用所需的所有Redux功能

  • react-redux:让你的React组件与Redux store通信所需的函数

如果你是从零开始搭建项目,请先自行将这些包添加到项目中。

创建Store

第一步是创建一个实际的Redux store。Redux的原则之一是整个应用应该只有一个store实例

我们通常在单独的文件中创建并导出Redux store实例。具体的文件夹结构由你决定,但通常会将应用范围的设置和配置放在src/app/文件夹中。

我们将从添加src/app/store.ts文件并创建store开始。

Redux Toolkit包含一个名为configureStore的方法。该函数用于创建一个新的Redux store实例。它有几个选项可用于改变store的行为。它还会自动应用最常见且有用的配置设置,包括检查典型错误,以及启用Redux DevTools扩展,以便你可以查看状态内容和操作历史。

src/app/store.ts
import { configureStore } from '@reduxjs/toolkit'
import type { Action } from '@reduxjs/toolkit'

interface CounterState {
value: number
}

// An example slice reducer function that shows how a Redux reducer works inside.
// We'll replace this soon with real app logic.
function counterReducer(state: CounterState = { value: 0 }, action: Action) {
switch (action.type) {
// Handle actions here
default: {
return state
}
}
}

export const store = configureStore({
// Pass in the root reducer setup as the `reducer` argument
reducer: {
// Declare that `state.counter` will be updated by the `counterReducer` function
counter: counterReducer
}
})

configureStore 始终需要一个 reducer 选项。这通常应是一个包含应用程序不同部分独立“切片 reducer”的对象。(如有必要,您也可以单独创建根 reducer 函数并作为 reducer 参数传入。)

第一步,我们为 counter 切片传入模拟的切片 reducer 函数,展示基本设置结构。稍后我们会将其替换为实际应用所需的真正切片 reducer。

Next.js 设置

如果您使用 Next.js,设置过程需要额外步骤。详见 Next.js 设置 页面了解如何在 Next.js 中配置 Redux。

提供 Store

Redux 本身是纯 JavaScript 库,可与任何 UI 层配合使用。在本应用中我们使用 React,因此需要让 React 组件与 Redux store 交互。

为此,我们需要使用 React-Redux 库,并将 Redux store 传递给 <Provider> 组件。这利用了 React 的 Context API 使 store 对应用中所有 React 组件可访问。

技巧

重要提示:不应 尝试直接将 Redux store 导入其他应用代码文件!因为只有一个 store 文件,直接导入可能导致循环依赖问题(文件 A 导入 B 导入 C 导入 A),引发难以追踪的 bug。此外,我们需要为组件和 Redux 逻辑编写测试,这些测试需创建自己的 store 实例。通过 Context 提供 store 既能保持灵活性又可避免导入问题。

具体实现:将 store 导入 main.tsx 入口文件,用带 store 的 <Provider> 包裹 <App> 组件:

src/main.tsx
import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'

import App from './App'
import { store } from './app/store'

// skip mock API setup

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

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

检查 Redux 状态

现在有了 store,可以使用 Redux DevTools 扩展查看当前 Redux 状态。

打开浏览器 DevTools(如右键点击页面选择"检查"),点击"Redux"选项卡。这里会显示已派发的 action 历史记录和当前状态值:

Redux DevTools: 初始应用状态

当前状态值应为如下对象:

{
counter: {
value: 0
}
}

该结构由传入 configureStorereducer 选项定义:包含 counter 字段的对象,且 counter 的切片 reducer 返回类似 {value} 的状态对象。

导出 Store 类型

由于使用 TypeScript,我们将频繁引用"Redux 状态类型"和"store dispatch 函数类型"。

需要从 store.ts 文件导出这些类型。我们将使用 TS 的 typeof 操作符,让 TS 基于 store 定义推断类型:

src/app/store.ts
import { configureStore } from '@reduxjs/toolkit'

// omit counter slice setup

export const store = configureStore({
reducer: {
counter: counterReducer
}
})

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

在编辑器中悬停 RootState 类型,应看到 type RootState = { counter: CounterState; }。该类型自动派生自 store 定义,未来对 reducer 的任何更改都会自动同步到 RootState 类型。这样只需定义一次即可保持长期准确。

导出类型化钩子

我们将在组件中广泛使用 React-Redux 的 useSelectoruseDispatch 钩子。每次使用这些钩子时都需要引用 RootStateAppDispatch 类型。

我们可以简化使用方式并避免重复声明类型,只需设置"预定义类型"的钩子版本,其中已内置正确的类型。

React-Redux 9.1 包含.withTypes()方法,可为这些钩子应用正确的类型。我们可以导出这些预定义类型的钩子,然后在应用程序的其余部分使用:

src/app/hooks.ts
// This file serves as a central hub for re-exporting pre-typed Redux hooks.
import { useDispatch, useSelector } from 'react-redux'
import type { AppDispatch, RootState } from './store'

// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()

至此设置过程已完成。让我们开始构建应用程序!

主贴文动态

社交媒体动态应用的核心功能将是贴文列表。后续我们会为此功能添加更多模块,但第一步的目标是先在屏幕上显示贴文条目列表。

创建贴文切片

第一步是创建新的Redux"切片"来存储贴文数据。

"切片"(slice)是应用中单个功能对应的 Redux reducer 逻辑和 action 的集合,通常定义在同一个文件中。其名称源于将根 Redux 状态对象拆分为多个独立的"状态切片"。

将贴文数据存入Redux存储后,即可创建React组件在页面上展示这些数据。

src目录内新建features文件夹,在features内创建posts子文件夹,并添加名为postsSlice.ts的文件。

我们将使用Redux Toolkit的createSlice函数创建能处理贴文数据的reducer函数。Reducer函数需要包含初始数据,确保应用启动时Redux存储已加载这些值。

目前先创建包含模拟贴文对象的数组作为起点:

导入createSlice后定义初始贴文数组,将其传递给createSlice,最后导出由createSlice生成的贴文reducer函数:

features/posts/postsSlice.ts
import { createSlice } from '@reduxjs/toolkit'

// Define a TS type for the data we'll be using
export interface Post {
id: string
title: string
content: string
}

// Create an initial state value for the reducer, with that type
const initialState: Post[] = [
{ id: '1', title: 'First Post!', content: 'Hello!' },
{ id: '2', title: 'Second Post', content: 'More text' }
]

// Create the slice and pass in the initial state
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {}
})

// Export the generated reducer function
export default postsSlice.reducer

每次创建新切片时,都需要将其reducer函数加入Redux存储。虽然Redux存储已创建但尚无数据。打开app/store.ts文件,导入postsReducer函数,移除所有counter相关代码,更新configureStore调用使postsReducer作为名为posts的reducer字段:

app/store.ts
import { configureStore } from '@reduxjs/toolkit'

// Removed the `counterReducer` function, `CounterState` type, and `Action` import

import postsReducer from '@/features/posts/postsSlice'

export const store = configureStore({
reducer: {
posts: postsReducer
}
})

这告知Redux:我们希望顶级状态对象包含名为posts的字段,当动作派发时state.posts的所有数据都将由postsReducer函数更新。

打开Redux DevTools扩展可验证是否生效:

初始贴文状态

展示贴文列表

贴文数据存入存储后,即可创建展示贴文列表的React组件。所有动态贴文功能相关代码应位于posts文件夹,请在其中新建PostsList.tsx文件(注意:这是使用JSX语法的TypeScript组件,需用.tsx扩展名确保TypeScript正确编译)

渲染贴文列表需要获取数据。React组件可通过React-Redux的useSelector钩子读取Redux存储数据。你编写的"选择器函数"会接收整个Redux state对象作为参数,并返回该组件所需的具体数据。

由于使用TypeScript,所有组件应始终使用我们在src/app/hooks.ts添加的预定义类型钩子useAppSelector,该钩子已包含正确的RootState类型。

初始的 PostsList 组件将从 Redux store 中读取 state.posts 值,然后遍历帖子数组并在屏幕上显示每个帖子:

features/posts/PostsList.tsx
import { useAppSelector } from '@/app/hooks'

export const PostsList = () => {
// Select the `state.posts` value from the store into the component
const posts = useAppSelector(state => state.posts)

const renderedPosts = posts.map(post => (
<article className="post-excerpt" key={post.id}>
<h3>{post.title}</h3>
<p className="post-content">{post.content.substring(0, 100)}</p>
</article>
))

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

我们接下来需要更新 App.tsx 中的路由,以便显示 PostsList 组件而不是“欢迎”消息。将 PostsList 组件导入到 App.tsx 中,并将欢迎文本替换为 <PostsList />。我们还会将其包裹在一个 React 片段中,因为我们很快将在主页上添加其他内容:

App.tsx
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'

import { Navbar } from './components/Navbar'
import { PostsList } from './features/posts/PostsList'

function App() {
return (
<Router>
<Navbar />
<div className="App">
<Routes>
<Route
path="/"
element={
<>
<PostsList />
</>
}
></Route>
</Routes>
</div>
</Router>
)
}

export default App

添加完成后,我们的应用主页现在应该如下所示:

初始帖子列表

进展顺利!我们已向 Redux store 添加了一些数据,并在 React 组件中将其显示在屏幕上。

添加新帖子

查看他人撰写的帖子固然不错,但我们希望能够自己撰写帖子。让我们创建一个"添加新帖子"的表单,使我们能够编写帖子并保存它们。

我们将首先创建空表单并将其添加到页面。然后,将表单连接到 Redux store,以便在点击"保存帖子"按钮时添加新帖子。

添加新帖子表单

posts 文件夹中创建 AddPostForm.tsx。我们将添加一个用于帖子标题的文本输入框和一个用于帖子正文的文本区域:

features/posts/AddPostForm.tsx
import React from 'react'

// TS types for the input fields
// See: https://epicreact.dev/how-to-type-a-react-form-on-submit-handler/
interface AddPostFormFields extends HTMLFormControlsCollection {
postTitle: HTMLInputElement
postContent: HTMLTextAreaElement
}
interface AddPostFormElements extends HTMLFormElement {
readonly elements: AddPostFormFields
}

export const AddPostForm = () => {
const handleSubmit = (e: React.FormEvent<AddPostFormElements>) => {
// Prevent server submission
e.preventDefault()

const { elements } = e.currentTarget
const title = elements.postTitle.value
const content = elements.postContent.value

console.log('Values: ', { title, content })

e.currentTarget.reset()
}

return (
<section>
<h2>Add a New Post</h2>
<form onSubmit={handleSubmit}>
<label htmlFor="postTitle">Post Title:</label>
<input type="text" id="postTitle" defaultValue="" required />
<label htmlFor="postContent">Content:</label>
<textarea
id="postContent"
name="postContent"
defaultValue=""
required
/>
<button>Save Post</button>
</form>
</section>
)
}

注意,目前尚未包含任何 Redux 特定的逻辑——我们将在下一步添加。

在此示例中,我们使用"非受控"输入并利用 HTML5 表单验证来防止提交空输入字段,但如何从表单读取值取决于您——这是关于 React 使用模式的偏好,并非 Redux 特有的。

将该组件导入 App.tsx,并直接添加到 <PostsList /> 组件上方:

App.tsx
// omit outer `<App>` definition
<Route
path="/"
element={
<>
<AddPostForm />
<PostsList />
</>
}
></Route>

您应该可以看到表单显示在页面中,紧接在标题下方。

您应该会看到表单显示在标题正下方的页面中。

现在,让我们更新 posts slice 以向 Redux store 添加新的帖子条目。

我们的 posts slice 负责处理所有帖子数据的更新。在 createSlice 调用内部,有一个名为 reducers 的对象。目前它是空的。我们需要在其中添加一个 reducer 函数来处理添加帖子的情况。

reducers 内部添加名为 postAdded 的函数,该函数将接收两个参数:当前的 state 值和被派发的 action 对象。由于 posts slice 仅了解其负责的数据,state 参数将直接是帖子数组本身,而非整个 Redux 状态对象。

action 对象会将我们的新帖子条目作为 action.payload 字段。声明 reducer 函数时,我们还需要告知 TypeScript 实际的 action.payload 类型,以便在传入参数和访问 action.payload 内容时正确检查。为此,我们需要从 Redux Toolkit 导入 PayloadAction 类型,并将 action 参数声明为 action: PayloadAction<ThePayloadTypeHere>。在本例中,即 action: PayloadAction<Post>

实际的状态更新是将新帖子对象添加到 state 数组中,我们可以在 reducer 中通过 state.push() 实现。

警告

请谨记:Redux 的 reducer 函数必须始终以不可变方式创建新状态值,通过制作副本实现!createSlice() 中调用 Array.push() 等可变函数或修改对象字段(如 state.someField = someValue)是安全的,因为它使用 Immer 库在内部将这些变更转换为安全的不可变更新,但切勿尝试在 createSlice 外部修改任何数据!

当我们编写 postAdded reducer 函数时,createSlice 会自动生成同名的"action creator"函数。我们可以导出该 action creator 并在 UI 组件中使用,当用户点击"保存文章"时派发该 action。

features/posts/postsSlice.ts
// Import the `PayloadAction` TS type
import { createSlice, PayloadAction } from '@reduxjs/toolkit'

// omit initial state

const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
// Declare a "case reducer" named `postAdded`.
// The type of `action.payload` will be a `Post` object.
postAdded(state, action: PayloadAction<Post>) {
// "Mutate" the existing state array, which is
// safe to do here because `createSlice` uses Immer inside.
state.push(action.payload)
}
}
})

// Export the auto-generated action creator with the same name
export const { postAdded } = postsSlice.actions

export default postsSlice.reducer

从术语角度来说,这里的 postAdded 是一个**"case reducer"**示例。它是 slice 内部的一个 reducer 函数,专门处理派发的特定 action 类型。概念上类似于我们在 switch 中编写的 case 语句——"当检测到此特定 action 类型时,执行此逻辑":

function sliceReducer(state = initialState, action) {
switch (action.type) {
case 'posts/postAdded': {
// update logic here
}
}
}

派发"文章已添加" Action

我们的 AddPostForm 包含文本输入框和触发提交处理程序的"保存文章"按钮,但目前该按钮尚未生效。需要更新提交处理程序以派发 postAdded action creator,并传入包含用户输入的标题和内容的新文章对象。

文章对象还需包含 id 字段。当前初始测试文章使用了模拟数字作为 ID。虽然可以编写代码计算下一个递增值,但生成随机唯一 ID 更为合适。Redux Toolkit 提供了 nanoid 函数可实现此需求。

信息

关于生成 ID 和派发 action 的更多内容,我们将在第 4 部分:使用 Redux 数据中详述。

要在组件中派发 action,需要访问 store 的 dispatch 函数。可通过调用 React-Redux 的 useDispatch hook 获取。由于使用 TypeScript,我们应导入带有正确类型的 useAppDispatch hook。同时还需将 postAdded action creator 导入此文件。

在组件中获取 dispatch 函数后,即可在点击处理程序中调用 dispatch(postAdded())。我们可以提取表单中的标题和内容值,生成新 ID,组合成新文章对象传递给 postAdded()

features/posts/AddPostForm.tsx
import React from 'react'
import { nanoid } from '@reduxjs/toolkit'

import { useAppDispatch } from '@/app/hooks'

import { type Post, postAdded } from './postsSlice'

// omit form types

export const AddPostForm = () => {
// Get the `dispatch` method from the store
const dispatch = useAppDispatch()


const handleSubmit = (e: React.FormEvent<AddPostFormElements>) => {
// Prevent server submission
e.preventDefault()

const { elements } = e.currentTarget
const title = elements.postTitle.value
const content = elements.postContent.value

// Create the post object and dispatch the `postAdded` action
const newPost: Post = {
id: nanoid(),
title,
content
}
dispatch(postAdded(newPost))

e.currentTarget.reset()
}

return (
<section>
<h2>Add a New Post</h2>
<form onSubmit={handleSubmit}>
<label htmlFor="postTitle">Post Title:</label>
<input type="text" id="postTitle" defaultValue="" required />
<label htmlFor="postContent">Content:</label>
<textarea
id="postContent"
name="postContent"
defaultValue=""
required
/>
<button>Save Post</button>
</form>
</section>
)
}

现在尝试输入标题和内容后点击"保存文章"。您将在文章列表中看到对应新条目。

恭喜!您已成功构建首个 React + Redux 应用!

这展示了完整的 Redux 数据流循环:

  • 文章列表通过 useSelector 从 store 读取初始文章集并渲染初始 UI

  • 我们派发了包含新文章数据的 postAdded action

  • 文章 reducer 识别到 postAdded action 后更新文章数组

  • Redux store 通知 UI 数据已变更

  • 文章列表读取更新后的数组并重新渲染显示新文章

后续新增功能都将遵循此基础模式:添加状态切片、编写 reducer 函数、派发 action、根据 Redux store 数据渲染 UI。

我们可以通过 Redux DevTools 扩展查看已分发的 action,并观察 Redux 状态如何响应该 action 进行更新。如果我们在 actions 列表中点击 "posts/postAdded" 条目,"Action" 选项卡应显示如下内容:

postAdded action 内容

"Diff" 选项卡也应显示 state.posts 新增了一个索引为 2 的数据项。

请牢记:Redux store 应仅存储应用中被视为"全局"的数据! 本例中,只有 AddPostForm 组件需要知道输入字段的最新值。即使我们使用"受控"输入构建表单,也应将临时数据保存在 React 组件状态中,而非 Redux store。当用户完成表单操作时,我们再分发 Redux action 来更新 store 中的最终值。

学习要点

至此我们已搭建 Redux 应用的基础框架——包含 store、带 reducer 的 slice 以及用于分发 action 的 UI。当前应用效果如下:

让我们回顾本节核心知识点:

核心总结
  • Redux 应用通过 <Provider> 组件将单一 store 传递给 React 组件
  • Redux 状态通过"reducer 函数"更新
    • Reducer 始终以不可变方式计算新状态:复制现有状态值并用新数据修改副本
    • Redux Toolkit 的 createSlice 函数自动生成"slice reducer"函数,允许编写"可变"代码(实际转换为安全的不可变更新)
    • 这些 slice reducer 函数被添加到 configureStorereducer 字段,用于定义 Redux store 中的数据结构和状态字段
  • React 组件通过 useSelector hook 从 store 读取数据
    • Selector 函数接收完整的 state 对象并返回所需值
    • Selector 会在 Redux store 更新时重新执行,若返回数据变化则触发组件重新渲染
  • React 组件通过 useDispatch hook 分发 action 更新 store
    • createSlice 会为 slice 中的每个 reducer 自动生成 action creator 函数
    • 在组件中调用 dispatch(someActionCreator()) 即可分发 action
    • Reducer 将检测相关 action 并返回更新后的状态
    • 表单输入值等临时数据应保存在 React 组件状态或原生 HTML 输入字段中,用户完成操作时分发 Redux action 更新 store
  • 使用 TypeScript 时,初始设置需基于 store 定义 RootStateAppDispatch 的 TS 类型,并导出预定义类型的 React-Redux useSelectoruseDispatch hook

下一步是什么?

掌握 Redux 基础数据流后,请继续学习第四部分:使用 Redux 数据。我们将为应用添加更多功能,并演示如何处理 store 中的现有数据。