跳至主要内容

Redux 基础知识,第 5 部分:UI 和 React

您将学到什么
  • Redux store 如何与 UI 协同工作
  • 如何在 React 中使用 Redux

介绍

第 4 部分:Store 中,我们了解了如何创建 Redux store、调度动作和读取当前状态。我们还了解了 store 在内部是如何工作的,增强器和中间件如何让我们通过额外的功能来定制 store,以及如何添加 Redux DevTools 来让我们在调度动作时看到应用程序内部发生了什么。

在本节中,我们将为我们的待办事项应用程序添加一个用户界面。我们将了解 Redux 如何与 UI 层协同工作,并具体介绍 Redux 如何与 React 协同工作。

注意

请注意,本页以及所有“Essentials”教程都讲解如何使用 我们现代的 React-Redux hooks API。旧式的 connect API 仍然有效,但今天我们希望所有 Redux 用户使用 hooks API。

此外,本教程中的其他页面故意展示了旧式的 Redux 逻辑模式,这些模式需要比我们今天教的“现代 Redux”模式(使用 Redux Toolkit)更多的代码,以便解释 Redux 背后的原理和概念。

请查看 “Redux Essentials”教程,了解使用 Redux Toolkit 和 React-Redux hooks 为真实世界应用程序构建应用程序的“正确使用 Redux 的方法”的完整示例。

将 Redux 与 UI 集成

Redux 是一个独立的 JS 库。正如我们已经看到的那样,即使没有设置用户界面,您也可以创建和使用 Redux store。这也意味着您可以将 Redux 与任何 UI 框架一起使用(甚至不使用任何 UI 框架),并在客户端和服务器上使用它。您可以使用 React、Vue、Angular、Ember、jQuery 或原生 JavaScript 编写 Redux 应用程序。

也就是说,Redux 是专门为与 React 良好协作而设计的。React 允许您将 UI 描述为状态的函数,而 Redux 包含状态并根据操作更新状态。

因此,我们将使用 React 来构建本教程中的待办事项应用程序,并介绍如何将 React 与 Redux 一起使用。

在我们开始之前,让我们快速了解一下 Redux 如何与 UI 层交互。

基本的 Redux 和 UI 集成

将 Redux 与任何 UI 层一起使用都需要一些一致的步骤

  1. 创建 Redux store
  2. 订阅更新
  3. 在订阅回调函数内部
    1. 获取当前的商店状态
    2. 提取此 UI 部分所需的数据
    3. 使用数据更新 UI
  4. 如果需要,使用初始状态渲染 UI
  5. 通过分派 Redux 操作来响应 UI 输入

让我们回到我们在第一部分看到的计数器应用程序示例,看看它是如何遵循这些步骤的

// 1) Create a new Redux store with the `createStore` function
const store = Redux.createStore(counterReducer)

// 2) Subscribe to redraw whenever the data changes in the future
store.subscribe(render)

// Our "user interface" is some text in a single HTML element
const valueEl = document.getElementById('value')

// 3) When the subscription callback runs:
function render() {
// 3.1) Get the current store state
const state = store.getState()
// 3.2) Extract the data you want
const newValue = state.value.toString()

// 3.3) Update the UI with the new value
valueEl.innerHTML = newValue
}

// 4) Display the UI with the initial store state
render()

// 5) Dispatch actions based on UI inputs
document.getElementById('increment').addEventListener('click', function () {
store.dispatch({ type: 'counter/incremented' })
})

无论您使用的是哪一层 UI,Redux 都以相同的方式与每个 UI 协同工作。实际的实现通常会更复杂,以帮助优化性能,但每次都是相同的步骤。

由于 Redux 是一个独立的库,因此有不同的“绑定”库可以帮助您将 Redux 与给定的 UI 框架一起使用。这些 UI 绑定库处理订阅商店和在状态更改时有效更新 UI 的细节,因此您不必自己编写该代码。

将 Redux 与 React 一起使用

官方的React-Redux UI 绑定库 是 Redux 核心之外的单独包。您需要另外安装它

npm install react-redux

在本教程中,我们将介绍使用 React 和 Redux 协同工作所需的最重要的模式和示例,并了解它们如何在我们的待办事项应用程序中实际工作。

info

请参阅官方 React-Redux 文档,网址为 https://react.redux.js.cn,以获取有关如何将 Redux 和 React 协同使用的完整指南,以及有关 React-Redux API 的参考文档。

设计组件树

就像我们根据需求设计状态结构一样,我们也可以设计应用程序的整体 UI 组件集以及它们之间的关系。

根据应用程序的业务需求列表,至少我们需要以下组件集

  • <App>:渲染所有其他内容的根组件。
    • <Header>:包含“新待办事项”文本输入和“完成所有待办事项”复选框
    • <TodoList>:基于过滤结果的当前可见待办事项列表
      • <TodoListItem>:单个待办事项列表项,带有一个复选框,可以单击以切换待办事项的已完成状态,以及一个颜色类别选择器
    • <Footer>:显示活动待办事项的数量,并控制根据已完成状态和颜色类别过滤列表

除了这个基本的组件结构之外,我们还可以以多种不同的方式划分组件。例如,<Footer> 组件可以是一个较大的组件,也可以包含多个较小的组件,例如 <CompletedTodos><StatusFilter><ColorFilters>。没有一种划分方式是绝对正确的,您会发现根据您的情况,编写较大的组件或将事物拆分为多个较小的组件可能更好。

为了方便理解,我们先从这个简短的组件列表开始。另外,鉴于我们假设您已经了解 React我们将跳过如何编写这些组件的布局代码的细节,而专注于如何在 React 组件中实际使用 React-Redux 库

以下是我们在添加任何 Redux 相关逻辑之前,这个应用程序的初始 React UI

使用 useSelector 从 Store 读取状态

我们知道我们需要能够显示一个待办事项列表。让我们从创建一个 <TodoList> 组件开始,该组件可以从 Store 读取待办事项列表,遍历它们,并为每个待办事项条目显示一个 <TodoListItem> 组件。

您应该熟悉 React 钩子,例如 useState,它可以在 React 函数组件中调用,以使它们能够访问 React 状态值。React 还允许我们编写 自定义钩子,这使我们能够提取可重用的钩子,以在 React 的内置钩子之上添加我们自己的行为。

与许多其他库一样,React-Redux 包含 它自己的自定义钩子,您可以在自己的组件中使用它们。React-Redux 钩子使您的 React 组件能够通过读取状态和分派操作来与 Redux Store 交互。

我们将要查看的第一个 React-Redux 钩子是 useSelector 钩子,它允许您的 React 组件从 Redux Store 读取数据

useSelector 接受一个单一函数,我们称之为选择器函数。选择器是一个函数,它以整个 Redux Store 状态作为其参数,从状态中读取一些值,并返回该结果

例如,我们知道我们的待办事项应用程序的 Redux 状态将待办事项数组保留为 state.todos。我们可以编写一个小的选择器函数来返回该待办事项数组

const selectTodos = state => state.todos

或者,也许我们想找出当前有多少待办事项被标记为“已完成”

const selectTotalCompletedTodos = state => {
const completedTodos = state.todos.filter(todo => todo.completed)
return completedTodos.length
}

因此,选择器可以从 Redux Store 状态返回值,还可以返回基于该状态的派生

让我们将待办事项数组读取到我们的 <TodoList> 组件中。首先,我们将从 react-redux 库导入 useSelector 钩子,然后使用选择器函数作为其参数调用它

src/features/todos/TodoList.js
import React from 'react'
import { useSelector } from 'react-redux'
import TodoListItem from './TodoListItem'

const selectTodos = state => state.todos

const TodoList = () => {
const todos = useSelector(selectTodos)

// since `todos` is an array, we can loop over it
const renderedListItems = todos.map(todo => {
return <TodoListItem key={todo.id} todo={todo} />
})

return <ul className="todo-list">{renderedListItems}</ul>
}

export default TodoList

<TodoList> 组件第一次渲染时,useSelector 钩子会调用 selectTodos 并传入整个 Redux 状态对象。选择器返回的值将由钩子返回给你的组件。因此,我们组件中的 const todos 最终将保存与 Redux 存储状态中的 state.todos 数组相同的数组。

但是,如果我们分发了像 {type: 'todos/todoAdded'} 这样的动作会发生什么?Redux 状态将被 reducer 更新,但我们的组件需要知道发生了变化,以便它可以重新渲染新的待办事项列表。

我们知道我们可以调用 store.subscribe() 来监听存储的变化,所以我们可以尝试在每个组件中编写订阅存储的代码。但是,这很快就会变得非常重复且难以处理。

幸运的是,useSelector 会自动为我们订阅 Redux 存储!这样,每次分发动作时,它都会立即再次调用其选择器函数。如果选择器返回的值与上次运行时不同,useSelector 将强制我们的组件使用新数据重新渲染。我们只需要在组件中调用一次 useSelector(),它就会完成剩下的工作。

但是,这里有一件非常重要的事情要记住

注意

useSelector 使用严格的 === 引用比较来比较其结果,因此只要选择器结果是新的引用,组件就会重新渲染!这意味着,如果你在选择器中创建了一个新的引用并返回它,你的组件可能会在每次分发动作时重新渲染,即使数据实际上并没有改变。

例如,将此选择器传递给 useSelector 将导致组件始终重新渲染,因为 array.map() 始终返回一个新的数组引用

// Bad: always returning a new reference
const selectTodoDescriptions = state => {
// This creates a new array reference!
return state.todos.map(todo => todo.text)
}
提示

我们将在本节的后面讨论解决此问题的一种方法。我们还将讨论如何使用“记忆”选择器函数在第 7 部分:标准 Redux 模式中提高性能并避免不必要的重新渲染。

还值得注意的是,我们不必将选择器函数写成一个单独的变量。你可以在调用 useSelector 时直接编写选择器函数,如下所示

const todos = useSelector(state => state.todos)

使用 useDispatch 派发操作

我们现在知道如何从 Redux 存储中读取数据到我们的组件中。但是,我们如何在组件中向存储派发操作呢?我们知道在 React 之外,我们可以调用 store.dispatch(action)。由于我们在组件文件中无法访问存储,因此我们需要某种方法来访问 dispatch 函数本身,以便在我们的组件中使用。

React-Redux useDispatch 钩子 将存储的 dispatch 方法作为其结果返回。(实际上,钩子的实现确实是 return store.dispatch。)

因此,我们可以在任何需要派发操作的组件中调用 const dispatch = useDispatch(),然后根据需要调用 dispatch(someAction)

让我们在 <Header> 组件中尝试一下。我们知道我们需要让用户输入一些新待办事项的文本,然后派发一个包含该文本的 {type: 'todos/todoAdded'} 操作。

我们将编写一个典型的 React 表单组件,该组件使用“受控输入” 让用户在表单中输入文本。然后,当用户专门按下 Enter 键时,我们将派发该操作。

src/features/header/Header.js
import React, { useState } from 'react'
import { useDispatch } from 'react-redux'

const Header = () => {
const [text, setText] = useState('')
const dispatch = useDispatch()

const handleChange = e => setText(e.target.value)

const handleKeyDown = e => {
const trimmedText = e.target.value.trim()
// If the user pressed the Enter key:
if (e.key === 'Enter' && trimmedText) {
// Dispatch the "todo added" action with this text
dispatch({ type: 'todos/todoAdded', payload: trimmedText })
// And clear out the text input
setText('')
}
}

return (
<input
type="text"
placeholder="What needs to be done?"
autoFocus={true}
value={text}
onChange={handleChange}
onKeyDown={handleKeyDown}
/>
)
}

export default Header

使用 Provider 传递存储

我们的组件现在可以从存储中读取状态,并向存储派发操作。但是,我们仍然缺少一些东西。React-Redux 钩子在哪里以及如何找到正确的 Redux 存储?钩子是一个 JS 函数,因此它无法自动从 store.js 中导入存储。

相反,我们必须明确地告诉 React-Redux 我们想要在组件中使用哪个存储。我们通过在整个 <App> 周围渲染 <Provider> 组件,并将 Redux 存储作为道具传递给 <Provider> 来实现这一点。完成此操作后,应用程序中的每个组件都将能够访问 Redux 存储(如果需要)。

让我们将其添加到我们的主 index.js 文件中

src/index.js
import React from 'react'
import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'

import App from './App'
import store from './store'

ReactDOM.render(
// Render a `<Provider>` around the entire `<App>`,
// and pass the Redux store to it as a prop
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>,
document.getElementById('root')
)

这涵盖了使用 React-Redux 与 React 的关键部分

  • 调用 useSelector 钩子来读取 React 组件中的数据
  • 调用 useDispatch 钩子来在 React 组件中分发操作
  • <Provider store={store}> 放在整个 <App> 组件周围,以便其他组件可以与存储进行通信

现在我们应该能够真正与应用程序交互!这是到目前为止的工作 UI

现在,让我们看看在我们的待办事项应用程序中将它们一起使用的几种方法。

React-Redux 模式

全局状态、组件状态和表单

到目前为止,您可能想知道,“我是否总是必须将所有应用程序的状态放入 Redux 存储中?”

答案是 **不。跨应用程序所需的全局状态应放在 Redux 存储中。仅在一个地方需要的状态应保留在组件状态中。**

一个很好的例子是之前编写的 <Header> 组件。我们可以通过在输入的 onChange 处理程序中分发操作并将它保存在我们的 reducer 中,将当前文本输入字符串保存在 Redux 存储中。但是,这并没有给我们带来任何好处。唯一使用该文本字符串的地方就在这里,在 <Header> 组件中。

因此,将该值保存在 <Header> 组件中的 useState 钩子中是有意义的。

类似地,如果我们有一个名为 isDropdownOpen 的布尔标志,应用程序中的其他组件将不会关心它——它应该真正保留在这个组件的本地。

提示

在 React + Redux 应用程序中,您的全局状态应放在 Redux 存储中,而您的本地状态应保留在 React 组件中。

如果您不确定将某项内容放在哪里,以下是一些确定应将哪种数据放入 Redux 的常见经验法则

  • 应用程序的其他部分是否关心此数据?
  • 您是否需要能够基于此原始数据创建更多派生数据?
  • 多个组件是否使用相同的数据?
  • 能够将此状态恢复到某个时间点(即,时间旅行调试)对您有价值吗?
  • 您是否希望缓存数据(即,如果数据已存在于状态中,则使用它,而不是重新请求它)?
  • 您是否希望在热重载 UI 组件时保持数据一致(这些组件在交换时可能会丢失其内部状态)?

这也是一个很好的例子,说明了如何在 Redux 中思考表单。大多数表单状态可能不应该保存在 Redux 中。 相反,在编辑时将数据保存在表单组件中,然后在用户完成操作时调度 Redux 操作来更新存储。

在组件中使用多个选择器

现在只有我们的 <TodoList> 组件从存储中读取数据。让我们看看 <Footer> 组件开始读取一些数据可能是什么样子。

<Footer> 需要知道三个不同的信息

  • 已完成的待办事项数量
  • 当前的“状态”过滤器值
  • 当前选定的“颜色”类别过滤器列表

我们如何将这些值读入组件?

我们可以在一个组件中多次调用 useSelector 事实上,这实际上是一个好主意 - 每次调用 useSelector 都应该返回尽可能少的州。

我们已经看到了如何编写一个计算已完成待办事项数量的选择器。对于过滤器值,状态过滤器值和颜色过滤器值都位于 state.filters 切片中。由于该组件需要这两个值,我们可以选择整个 state.filters 对象。

正如我们之前提到的,我们可以将所有输入处理直接放入 <Footer> 中,或者我们可以将其拆分为单独的组件,例如 <StatusFilter>。为了使这个解释更简短,我们将跳过编写输入处理的具体细节,并假设我们已经有了更小的单独组件,这些组件被赋予了一些数据和更改处理程序回调作为道具。

鉴于这个假设,组件的 React-Redux 部分可能看起来像这样

src/features/footer/Footer.js
import React from 'react'
import { useSelector } from 'react-redux'

import { availableColors, capitalize } from '../filters/colors'
import { StatusFilters } from '../filters/filtersSlice'

// Omit other footer components

const Footer = () => {
const todosRemaining = useSelector(state => {
const uncompletedTodos = state.todos.filter(todo => !todo.completed)
return uncompletedTodos.length
})

const { status, colors } = useSelector(state => state.filters)

// omit placeholder change handlers

return (
<footer className="footer">
<div className="actions">
<h5>Actions</h5>
<button className="button">Mark All Completed</button>
<button className="button">Clear Completed</button>
</div>

<RemainingTodos count={todosRemaining} />
<StatusFilter value={status} onChange={onStatusChange} />
<ColorFilters value={colors} onChange={onColorChange} />
</footer>
)
}

export default Footer

通过 ID 在列表项中选择数据

目前,我们的 <TodoList> 正在读取整个 state.todos 数组,并将实际的待办事项对象作为道具传递给每个 <TodoListItem> 组件。

这有效,但存在潜在的性能问题。

  • 更改一个待办事项对象意味着创建待办事项和 state.todos 数组的副本,并且每个副本都是内存中的一个新引用
  • useSelector 看到一个新的引用作为其结果时,它会强制其组件重新渲染。
  • 因此,每当一个待办事项对象被更新(例如单击它以切换其已完成状态)时,整个 <TodoList> 父组件都会重新渲染。
  • 然后,因为 React 默认情况下会递归地重新渲染所有子组件,这也意味着所有 <TodoListItem> 组件都会重新渲染,即使其中大多数实际上并没有发生变化!

重新渲染组件并不坏 - 这是 React 知道它是否需要更新 DOM 的方式。但是,如果列表太大,在没有任何实际变化的情况下重新渲染大量组件可能会变得太慢。

我们可以尝试通过几种方法来解决这个问题。一种选择是 将所有 <TodoListItem> 组件包装在 React.memo(),这样它们只有在它们的 props 实际发生变化时才会重新渲染。这通常是提高性能的不错选择,但它要求子组件始终接收相同的 props,直到真正发生变化。由于每个 <TodoListItem> 组件都接收一个待办事项项作为 prop,因此它们中只有一个应该真正获得更改的 prop 并需要重新渲染。

另一种选择是让 <TodoList> 组件只从 store 中读取一个待办事项 ID 数组,并将这些 ID 作为 props 传递给子 <TodoListItem> 组件。然后,每个 <TodoListItem> 可以使用该 ID 来查找它需要的正确待办事项对象。

让我们试一试。

src/features/todos/TodoList.js
import React from 'react'
import { useSelector } from 'react-redux'
import TodoListItem from './TodoListItem'

const selectTodoIds = state => state.todos.map(todo => todo.id)

const TodoList = () => {
const todoIds = useSelector(selectTodoIds)

const renderedListItems = todoIds.map(todoId => {
return <TodoListItem key={todoId} id={todoId} />
})

return <ul className="todo-list">{renderedListItems}</ul>
}

这次,我们只在 <TodoList> 中从 store 中选择一个待办事项 ID 数组,并将每个 todoId 作为 id prop 传递给子 <TodoListItem>

然后,在 <TodoListItem> 中,我们可以使用该 ID 值来读取我们的待办事项项。我们还可以更新 <TodoListItem> 以根据待办事项的 ID 派发“已切换”操作。

src/features/todos/TodoListItem.js
import React from 'react'
import { useSelector, useDispatch } from 'react-redux'

import { availableColors, capitalize } from '../filters/colors'

const selectTodoById = (state, todoId) => {
return state.todos.find(todo => todo.id === todoId)
}

// Destructure `props.id`, since we only need the ID value
const TodoListItem = ({ id }) => {
// Call our `selectTodoById` with the state _and_ the ID value
const todo = useSelector(state => selectTodoById(state, id))
const { text, completed, color } = todo

const dispatch = useDispatch()

const handleCompletedChanged = () => {
dispatch({ type: 'todos/todoToggled', payload: todo.id })
}

// omit other change handlers
// omit other list item rendering logic and contents

return (
<li>
<div className="view">{/* omit other rendering output */}</div>
</li>
)
}

export default TodoListItem

不过,这里有一个问题。我们之前说过,**在选择器中返回新的数组引用会导致组件每次都重新渲染**,而现在我们在 `<TodoList>` 中返回了一个新的 IDs 数组。在这种情况下,如果我们只是切换了一个待办事项,IDs 数组的 *内容* 应该保持一致,因为我们仍然显示着相同的待办事项 - 我们没有添加或删除任何待办事项。但是,*包含* 这些 IDs 的数组是一个新的引用,所以 `<TodoList>` 会重新渲染,而实际上它并不需要重新渲染。

解决这个问题的一个方法是改变 `useSelector` 如何比较它的值以查看它们是否发生了变化。`useSelector` 可以接受一个比较函数作为它的第二个参数。比较函数会用旧值和新值调用,如果它们被认为是相同的,则返回 `true`。如果它们相同,`useSelector` 不会让组件重新渲染。

React-Redux 有一个 `shallowEqual` 比较函数,我们可以用它来检查数组 *内部* 的项目是否仍然相同。让我们试试看。

src/features/todos/TodoList.js
import React from 'react'
import { useSelector, shallowEqual } from 'react-redux'
import TodoListItem from './TodoListItem'

const selectTodoIds = state => state.todos.map(todo => todo.id)

const TodoList = () => {
const todoIds = useSelector(selectTodoIds, shallowEqual)

const renderedListItems = todoIds.map(todoId => {
return <TodoListItem key={todoId} id={todoId} />
})

return <ul className="todo-list">{renderedListItems}</ul>
}

现在,如果我们切换一个待办事项,IDs 列表将被认为是相同的,`<TodoList>` 不需要重新渲染。只有一个 `<TodoListItem>` 会收到一个更新的待办事项对象并重新渲染,但所有其他 `<TodoListItem>` 仍然拥有现有的待办事项对象,并且根本不需要重新渲染。

如前所述,你也可以使用一种叫做 “记忆选择器” 的特殊选择器函数来帮助改进组件渲染,我们将在另一节中介绍如何使用它们。

你学到了什么

我们现在有一个可用的待办事项应用程序!我们的应用程序创建了一个存储,使用 `<Provider>` 将存储传递给 React UI 层,然后调用 `useSelector` 和 `useDispatch` 来在我们的 React 组件中与存储进行通信。

info

尝试自己实现其余的缺失 UI 功能!以下是你需要添加的内容列表

  • 在 `<TodoListItem>` 组件中,使用 `useDispatch` 钩子来分发更改颜色类别和删除待办事项的操作。
  • 在 `<Footer>` 中,使用 `useDispatch` 钩子来分发标记所有待办事项为已完成、清除已完成的待办事项和更改筛选器值的操作。

我们将在 第 7 部分:标准 Redux 模式 中介绍如何实现筛选器。

让我们看看应用程序现在的样子,包括我们跳过的组件和部分,以保持简洁。

摘要
  • Redux 存储可以与任何 UI 层一起使用
    • UI 代码始终订阅存储,获取最新状态,并重新绘制自身
  • React-Redux 是 React 的官方 Redux UI 绑定库
    • React-Redux 作为单独的 react-redux 包安装
  • useSelector 钩子允许 React 组件从存储中读取数据
    • 选择器函数将整个存储 state 作为参数,并根据该状态返回一个值
    • useSelector 调用其选择器函数并返回选择器的结果
    • useSelector 订阅存储,并在每次调度操作时重新运行选择器。
    • 每当选择器结果发生变化时,useSelector 都会强制组件使用新数据重新渲染
  • useDispatch 钩子允许 React 组件向存储调度操作
    • useDispatch 返回实际的 store.dispatch 函数
    • 您可以在组件内部根据需要调用 dispatch(action)
  • <Provider> 组件使存储可用于其他 React 组件
    • 在整个 <App> 周围渲染 <Provider store={store}>

下一步?

现在我们的 UI 已经可以正常工作了,是时候看看如何让我们的 Redux 应用程序与服务器通信了。在 第 6 部分:异步逻辑 中,我们将讨论超时和 AJAX 调用等异步逻辑如何融入 Redux 数据流。