Redux 基础教程,第 5 节:UI 与 React
本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →
- Redux 仓库如何与 UI 协同工作
- 如何在 React 中使用 Redux
简介
在第 4 节:仓库中,我们学习了如何创建 Redux 仓库、派发 action 及读取当前状态。我们还探讨了仓库的内部工作原理,如何通过增强器和中间件扩展仓库能力,以及如何添加 Redux DevTools 来观察应用内部动态。
本节将为待办事项应用添加用户界面。我们将了解 Redux 如何与 UI 层整体协作,并重点介绍 Redux 与 React 的配合机制。
请注意:本页及整个 "基础教程" 均使用 现代 React-Redux hooks API。传统 connect API 仍然可用,但当前我们推荐所有 Redux 用户采用 hooks API。
同时,本教程其他章节有意展示旧版 Redux 模式(相比我们推荐的现代 Redux Toolkit 方案代码量更大),旨在阐明 Redux 的核心原理与概念。
关于生产环境中 "如何正确使用 Redux" 的完整示例(含 Redux Toolkit 和 React-Redux hooks),请参阅"Redux 核心教程"。
Redux 与 UI 集成
Redux 是独立的 JS 库。如前所述,即使未搭建用户界面,也可创建并使用 Redux 仓库。这意味着您可将 Redux 与任意 UI 框架(甚至完全不用 UI 框架)结合,并同时应用于客户端和服务器端。您可以使用 React、Vue、Angular、Ember、jQuery 或原生 JavaScript 开发 Redux 应用。
但需注意,Redux 专为与 React 深度优化而设计。React 允许您将 UI 描述为状态的函数,而 Redux 负责管理状态并根据 action 进行更新。
因此,在构建待办事项应用的本教程中,我们将使用 React,并讲解 React 与 Redux 配合的基本方法。
在此之前,我们先概览 Redux 如何与 UI 层交互。
Redux 与 UI 基础集成
在任何 UI 层中使用 Redux 都需遵循以下固定步骤:
-
创建 Redux 仓库
-
订阅状态更新
-
在订阅回调中:
- 获取当前仓库状态
- 提取该 UI 所需数据
- 使用数据更新 UI
-
如有必要,使用初始状态渲染 UI
-
通过派发 Redux action 响应 UI 输入
让我们回顾第 1 节的计数器应用示例,观察其如何实践这些步骤:
// 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 的运作机制始终如一。实际实现通常更复杂以优化性能,但核心步骤保持不变。
由于 Redux 是独立库,存在不同的 "绑定库" 协助您将其接入特定 UI 框架。这些 UI 绑定库会处理订阅仓库和状态变更时高效更新 UI 的细节,您无需手动实现相关逻辑。
在 React 中使用 Redux
官方的 React-Redux UI 绑定库 独立于 Redux 核心库,您需要额外安装:
npm install react-redux
本教程将涵盖 React 与 Redux 协同使用的最关键模式和示例,并通过待办事项应用演示其实际运作方式。
请参见官方 React-Redux 文档 https://react-redux.js.org 以获取关于如何一起使用 Redux 和 React 的完整指南以及 React-Redux API 的参考文档。
设计组件树
正如我们曾基于需求设计状态结构,同样可以设计应用中的 UI 组件体系及其相互关系。
根据应用功能需求列表,我们至少需要以下组件:
<App>:根组件,渲染所有内容<Header>:包含"新建待办事项"输入框和"全选"复选框<TodoList>:基于筛选结果展示可见待办事项<TodoListItem>:单个待办项,含完成状态切换复选框和颜色分类选择器
<Footer>:显示活跃待办数量及基于完成状态/颜色的筛选控件
除基础结构外,组件划分存在多种方案。例如<Footer>可设计为单一大型组件,或拆分为<CompletedTodos>、<StatusFilter>和<ColorFilters>等小组件。划分方式无绝对标准,具体场景下选择整体式或模块化方案皆可。
为简化理解,我们暂采用上述组件结构。考虑到您已掌握 React 基础,我们将跳过组件布局代码细节,聚焦 React-Redux 库在组件中的实际应用。
添加 Redux 逻辑前,应用的初始 React UI 如下:
使用 useSelector 从仓库读取状态
我们需要展示待办事项列表。首先创建<TodoList>组件,使其能从仓库读取待办列表,遍历数据并为每个条目渲染<TodoListItem>组件。
您应熟悉React Hooks(如useState),它们使函数组件能访问 React 状态。React 还支持自定义 Hook,可提取复用逻辑扩展内置 Hook 功能。
与多数库类似,React-Redux 提供自定义 Hook,允许组件通过读取状态和派发操作与 Redux 仓库交互。
首个介绍的 React-Redux Hook 是 useSelector,它使 React 组件能从 Redux 仓库读取数据。
useSelector 接收单个selector函数作为参数。selector 是以整个 Redux 仓库状态为输入,从中提取特定值并返回结果的函数。
例如,已知待办事项应用的 Redux 状态将任务项数组存储为 state.todos。我们可以编写一个小的选择器函数来返回该任务数组:
const selectTodos = state => state.todos
或者,我们可能想了解当前有多少任务标记为"已完成":
const selectTotalCompletedTodos = state => {
const completedTodos = state.todos.filter(todo => todo.completed)
return completedTodos.length
}
因此,选择器既可以从 Redux 仓库状态返回值,也可以基于该状态返回派生值。
现在让我们将任务数组读入 <TodoList> 组件。首先,从 react-redux 库中导入 useSelector 钩子,然后以选择器函数作为参数调用它:
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 键时,分发对应动作。
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 仓库?钩子是 JavaScript 函数,无法自动从 store.js 导入仓库。
解决方案是在 <App> 组件外层包裹 <Provider> 组件,并将 Redux 仓库作为属性传递给 <Provider>。此操作只需执行一次,应用中的所有组件就能按需访问 Redux 仓库。
将其添加到主 index.js 文件:
import React from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'
import App from './App'
import store from './store'
const root = createRoot(document.getElementById('root'))
root.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>
)
以上涵盖了 React-Redux 与 React 配合使用的关键部分:
-
在 React 组件中调用
useSelector钩子读取数据 -
在 React 组件中调用
useDispatch钩子分发动作 -
用
<Provider store={store}>包裹整个<App>组件,使其他组件能与仓库通信
现在应能与应用实际交互!以下是当前可运行的 UI:
接下来我们探讨在待办事项应用中配合使用的更多方式。
React-Redux 模式
全局状态、组件状态与表单
此时您可能产生疑问:"是否必须将所有应用状态都放入 Redux 仓库?"
答案是否定的。全局共享状态应存入 Redux 仓库,而仅限单处使用的状态应保留在组件内部。
先前编写的 <Header> 组件就是典型案例。我们本可将当前输入文本存入 Redux 仓库——通过在输入框的 onChange 处理程序中分发动作,并将其保存在 reducer 中。但这毫无益处,因为该文本字符串仅在 <Header> 组件中使用。
因此更合理的做法是将该值保留在 <Header> 组件的 useState 钩子中。
同理,若存在名为 isDropdownOpen 的布尔标记,其他组件无需感知此状态——应严格保留在组件内部。
在 React + Redux 应用中,全局状态应存入 Redux 仓库,局部状态应保留在 React 组件中。
若不确定数据归属,可参考以下经验法则判断是否应存入 Redux:
- 其他应用模块是否依赖此数据?
- 是否需要基于原始数据派生出新数据?
- 相同数据是否驱动多个组件?
- 是否需支持状态回退(如时间旅行调试)?
- 是否需缓存数据(优先使用状态而非重复请求)?
- 是否需在热重载 UI 组件时保持数据一致性(热重载可能导致内部状态丢失)?
这也是思考 Redux 中表单处理的典型示例。大多数表单状态可能不应保存在 Redux 中。正确的做法是在用户编辑时将数据保留在表单组件内,待用户操作完成后再通过分发 Redux action 更新存储。
在组件中使用多个选择器
目前仅 <TodoList> 组件从仓库读取数据。现在让 <Footer> 组件也开始读取数据。
<Footer> 需要获取三部分信息:
-
已完成待办事项的数量
-
当前“状态”筛选值
-
当前选中的“颜色”分类筛选列表
如何将这些值读取到组件中?
可以在单个组件中多次调用 useSelector。实际上这是最佳实践——每次 useSelector 调用都应返回尽可能少的状态。
我们已见过如何编写统计已完成待办事项的选择器。对于筛选值,状态筛选值和颜色筛选值均位于 state.filters 切片中。由于本组件需要两者,可直接选择整个 state.filters 对象。
如前所述,可将所有输入处理逻辑直接放入 <Footer>,或拆分为 <StatusFilter> 等独立组件。为简化说明,此处省略具体输入处理实现细节,假设已有小型独立组件通过 props 接收数据和变更回调函数。
基于此假设,组件的 React-Redux 部分实现如下:
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 数组,并将实际待办事项对象作为 prop 传递给每个 <TodoListItem> 组件。
此方案可行,但存在潜在性能问题:
-
修改单个待办事项对象需同时创建该对象和
state.todos数组的副本,每个副本都是内存中的新引用 -
当
useSelector检测到结果为新引用时,会强制其组件重新渲染 -
因此,任何单条待办事项更新时(如点击切换完成状态),整个
<TodoList>父组件将重新渲染 -
由于 React 默认会递归重新渲染所有子组件,所有
<TodoListItem>组件都会重新渲染,即使其中多数实际未发生变更!
组件重新渲染并非错误——这是 React 判断是否需要更新 DOM 的机制。但当列表过大时,大量未实际变更的组件重新渲染可能导致性能下降。
解决方案有两种:其一可 用 React.memo() 包裹所有 <TodoListItem> 组件,使其仅在 props 实际变更时重新渲染(常用性能优化手段)。但要求子组件在内容不变时始终接收相同 props。由于每个 <TodoListItem> 接收待办事项作为 prop,仅实际变更的项需重新渲染。
另一种方案是让 <TodoList> 组件仅从仓库读取待办事项 ID 数组,并将这些 ID 作为 props 传递给子组件 <TodoListItem>。每个 <TodoListItem> 再用该 ID 查找对应待办事项对象。
我们尝试第二种方案。
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> 仅从仓库选择待办事项 ID 数组,并将每个 todoId 作为 id prop 传递给子组件 <TodoListItem>。
在 <TodoListItem> 中,可利用该 ID 读取对应待办事项。还可更新 <TodoListItem> 以基于待办事项 ID 分发 "toggled" 动作。
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> 组件中返回的是全新的 ID 数组。当我们切换待办事项状态时,ID 数组的内容应当保持不变(因为展示的是相同的待办事项条目,没有增删操作),但包含这些 ID 的数组却生成了新引用,导致 <TodoList> 在无需更新时仍然重新渲染。
解决方案之一是改变 useSelector 对比值变更的方式。useSelector 的第二个参数可接收比较函数,该函数接收新旧值并返回 true 表示两者相同。若值相同,useSelector 将阻止组件重新渲染。
React-Redux 提供了 shallowEqual 比较函数用于检查数组内部元素是否相同:
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>
}
现在切换待办事项状态时,ID 数组将被视为相同,<TodoList> 无需重新渲染。仅对应的 <TodoListItem> 会获取更新后的待办事项对象并重新渲染,其余列表项将保持现有对象状态,完全无需更新。
如前所述,也可使用称为"记忆化选择器"的特殊选择器函数优化组件渲染,我们将在后续章节详述其用法。
学习要点
至此我们已完成可运行的待办事项应用!应用创建了仓库,通过 <Provider> 将仓库传递给 React UI 层,并在 React 组件中使用 useSelector 和 useDispatch 与仓库交互。
请尝试自行实现剩余缺失功能:
- 在
<TodoListItem>组件中使用useDispatch分派修改颜色分类和删除待办事项的操作 - 在
<Footer>中使用useDispatch分派标记全部完成、清除已完成项及切换筛选条件的操作
筛选功能实现将在第7章:标准 Redux 模式中详述。
以下是当前应用效果展示(包含为保持简洁而跳过的组件):
- Redux 仓库可与任何 UI 层协同工作
- UI 代码始终订阅仓库、获取最新状态并重绘自身
- React-Redux 是官方的 React 集成库
- 通过独立的
react-redux包安装
- 通过独立的
useSelector钩子使 React 组件可读取仓库数据- 选择器函数接收完整仓库
state作为参数并返回派生值 useSelector调用选择器函数并返回结果useSelector订阅仓库,在每次分派动作后重新执行选择器- 选择器结果变化时,
useSelector会强制组件使用新数据重新渲染
- 选择器函数接收完整仓库
useDispatch钩子使 React 组件可向仓库分派动作useDispatch返回实际的store.dispatch方法- 可在组件内部按需调用
dispatch(action)
<Provider>组件向其他 React 组件提供仓库访问- 用
<Provider store={store}>包裹整个<App>
- 用
下一步是什么?
完成 UI 实现后,下一步将探索 Redux 应用如何与服务器通信。第6章:异步逻辑将阐述超时和 HTTP 请求等异步操作如何融入 Redux 数据流。