在 React 中进行声明式思考
关于在 React 中使用声明式和状态驱动方法的指南,其中包含实际示例,如待办事项列表,以说明构建动态、可维护的 UI
React 的核心原则之一是其声明性。React 允许你定义 UI 应该根据当前状态呈现的样子,而不是手动逐步更新 DOM(命令式编程),它会为你处理更新。这种方法使 UI 开发更具可预测性、可扩展性,并且更容易推理。
声明式 UI 与命令式 UI
在命令式编程中,你给出关于事情应该如何发生的明确指令。DOM API 本质上是命令式的。当操作 DOM 时,这通常意味着选择元素并手动修改它们。
const button = document.createElement('button');button.textContent = 'Click me';button.style.backgroundColor = 'blue';document.body.appendChild(button);button.addEventListener('click', () => {button.style.backgroundColor = 'red';alert('Button clicked!');});
请注意,每个操作都被明确定义:创建按钮、设置样式、将其附加到 DOM 以及响应事件更改属性。
另一方面,声明式编程侧重于描述期望的结果,而不是详细说明如何实现它。React 组件允许我们声明 UI 应该是什么样子,当状态改变时,React 会负责更新它。
function App() {const [color, setColor] = React.useState('blue');return (<button style={{ backgroundColor: color }} onClick={() => setColor('red')}>Click me</button>);}
在这个例子中,我们根据 color
状态定义了按钮的 UI。当状态改变时,React 会自动更新 DOM,而无需我们手动管理它。
类比:命令式与声明式
想想制作一杯咖啡:
- 命令式:“拿一个马克杯,倒入热水,加入咖啡,搅拌,然后享用”
- 声明式:“我想要一杯咖啡”
在声明式方法中,执行的细节被抽象掉了。你描述最终结果,然后一个系统(例如咖啡机、咖啡师或 React)确保它正确发生。
声明式编程的好处在上面的按钮示例中可能不太明显,因为它很小。
让我们使用一个稍微复杂一点的待办事项列表示例,该列表允许添加、删除和完成任务。UI 应该显示任务列表和任务总数以及已完成的任务。
使用命令式方法(例如,使用原始 JavaScript 和 DOM API),每次交互都需要手动查找、更新和重新渲染元素。
以下用户操作将需要这些 DOM 操作:
- 添加任务:
- 将新任务追加到现有列表中
- 清空输入内容
- 为新添加的任务添加任务完成事件监听器
- 增加总任务数
- 完成任务:
- 更新任务以显示已完成状态
- 修改已完成任务的数量
- 删除任务:
- 从列表中删除任务
- 减少总任务数
- 如果任务已完成,则减少已完成任务的数量
使用命令式方法,要使 UI 与状态保持同步要困难得多,因为您必须记住要修改的相关区域。当引入新功能时,用命令式编写的逻辑会变得更难阅读和跟踪。这对可维护性不利!
使用声明式方法(如 React),您可以简单地描述应根据更新后的任务状态显示的 UI,React 将计算出必要的命令式 DOM 操作,以将当前 UI 演变为预期的 UI。
可能存在无数个可能的当前 UI – React 如何计算出要进行的正确命令式 DOM 操作? 这就是 React 的虚拟 DOM 和协调过程发挥作用的地方。 React 比较/区分当前 UI 表示和新的 UI 表示,并生成必要的 DOM 操作列表。
声明式 UI 是几乎所有现代 UI 框架都采用的方法,因为它相对于命令式方法具有压倒性的优势。
在 react.dev 上进一步阅读:声明式 UI 与命令式 UI 的比较
如何以声明方式思考 UI
以声明方式思考需要将您的重点从如何更新 UI 转移到 UI 在任何给定时刻应该是什么。
让我们使用与上面相同的待办事项列表示例,并演示声明式编程如何更好。 我们不手动操作 DOM(命令式编程),而是根据状态定义 UI 应该如何显示,并让 React 负责渲染。
为了使事情更复杂一些,待办事项列表支持过滤(全部、已完成、未完成)。
1. 识别组件中的视觉状态
待办事项列表有几种可能的 UI 状态:
- 输入字段,可以接受用户的文本输入
- 任务列表,可以为空或非空
- 每个任务可以完成或未完成
- 任务筛选器的选择器和所选选项
- 任务列表可以被过滤(全部、活动、已完成)
2. 确定触发状态更改的操作
接下来,我们定义影响状态的操作:
- 添加任务
- 完成任务
- 删除任务
- 过滤任务(显示全部、活动或已完成)
这些操作将修改状态,React 将相应地自动重新渲染 UI。
3. 设计一个最小结构来表示状态
接下来,我们需要设计一个结构来捕获 UI 中的状态数据。
这是一个可能的方案:
const [tasks, setTasks] = useState([{ id: 1, title: '学习 React' },{ id: 2, title: '构建一个项目' },]);const [completedTasks, setCompletedTasks] = useState([{ id: 1, title: '学习 React' },]);const [incompleteTasks, setIncompleteTasks] = useState([{ id: 2, title: '构建一个项目' },]);const [filter, setFilter] = useState('all'); // all | active | completed
然而,在这种设计中,存在一些数据重复。任务是 tasks
和 completedTasks
或 incompleteTasks
的一部分,添加/删除任务将涉及修改多个任务数组。这种状态设计不太好。
一些改进方法:
incompleteTasks
状态是多余的,因为如果一个任务不在completedTasks
中,那么它就是未完成的。但是,当线性扫描completedTasks
数组以检查每个任务是否已完成时,效率会很低- 我们可以对
completedTasks
使用一个Set
,它只存储任务 ID。这样,任务标题就不必重复,并且查找任务的完成状态也很有效。但是,当已完成的任务被删除时,我们仍然需要记住修改两个地方。
我们可以跟踪任务本身的完成状态,而不是存储需要同步的多个独立状态片段。这是一个最小且结构化的状态表示:
const [tasks, setTasks] = useState([{ id: 1, title: 'Learn React', completed: false },{ id: 2, title: 'Build a project', completed: true },]);const [filter, setFilter] = useState('all'); // all | active | completed
使用此结构:
tasks
是一个数组,其中每个任务都有一个id
、title
和completed
状态filter
确定要显示的任务
为了避免状态冗余,我们可以从 tasks
派生已完成的任务,而不是保留一个单独的 completedTasks
数组。
这里需要考虑权衡。虽然状态现在已合并,但切换任务完成状态现在将需要克隆整个 tasks
数组,而如果我们对 completedTasks
使用 Set
,则不需要这样做。
4. 在事件处理程序中调用操作
操作是响应两种事件而触发的:
- 用户事件:用户直接执行的操作,例如单击按钮、在输入字段中键入或从下拉列表中选择一个选项
- 后台事件:在没有直接用户交互的情况下触发的操作,例如 API 响应、计时器和间隔、WebSocket 实时更新
对于手头的待办事项列表,我们只需要关心用户事件。
但是,建议为每个操作编写一个函数,并在每个操作函数中调用状态设置器。这是因为相同的操作可以从 UI 上的许多地方甚至在后台触发。一个例子是视频播放器,用户可以按“暂停”按钮或按空格键来暂停视频。
在这些操作函数中集中状态更新逻辑将有助于保持代码的可维护性。
完整示例
现在,让我们实现上面学到的内容。
import { useState } from 'react';function TodoApp() {const [tasks, setTasks] = useState([]);const [filter, setFilter] = useState('all');const [taskInput, setTaskInput] = useState('');// Add a taskfunction addTask() {if (taskInput.trim() === '') {return;}const newTask = { id: Date.now(), text: taskInput, completed: false };setTasks([...tasks, newTask]);setTaskInput('');}// Toggle task completionfunction toggleTask(id) {setTasks(tasks.map((task) =>task.id === id ? { ...task, completed: !task.completed } : task,),);}// Delete a taskfunction deleteTask(id) {setTasks(tasks.filter((task) => task.id !== id));}// Get tasks based on filterconst filteredTasks = tasks.filter((task) => {if (filter === 'active') {return !task.completed;}if (filter === 'completed') {return task.completed;}return true; // "all" case});return (<div><h2>Todo List</h2><inputvalue={taskInput}onChange={(e) => setTaskInput(e.target.value)}placeholder="Add a new task..."/><button onClick={addTask}>Add</button><div><button onClick={() => setFilter('all')}>All</button><button onClick={() => setFilter('active')}>Active</button><button onClick={() => setFilter('completed')}>Completed</button></div><ul>{filteredTasks.map((task) => (<likey={task.id}style={{textDecoration: task.completed ? 'line-through' : 'none',}}>{task.text}<button onClick={() => toggleTask(task.id)}>{task.completed ? 'Undo' : 'Complete'}</button><button onClick={() => deleteTask(task.id)}>❌</button></li>))}</ul></div>);}export default TodoApp;
这个待办事项列表是声明式的,因为:
- UI 自动更新:当
tasks
或filter
更改时,React 会相应地重新渲染 UI - 没有手动 DOM 操作:无需选择元素、手动删除任务或通过
document.querySelector
更新样式。 - 最小且结构化的状态:UI 源自单一事实来源 (
tasks
和filter
) - 事件处理程序更新状态,而不是 DOM:函数
addTask
、toggleTask
和deleteTask
仅更新状态,React 处理重新渲染。
此示例展示了声明式思维如何简化 React 中的 UI 开发:
- 识别 UI 状态
- 确定 状态更改操作/操作
- 设计一个 最小状态结构
- 使用 事件处理程序 更新状态
我们让 React 响应 状态变化,而不是微观管理 DOM 更新,这使得我们的代码更简洁、更具可扩展性,并且更易于维护。
在 React 中以声明式方式思考意味着从一步一步的、命令驱动的思维模式转变为状态驱动的方法。 通过专注于根据状态定义 UI 应该是什么,您可以创建更具可读性、可预测性和可维护性的组件。
拥抱这种范式可以更容易地管理复杂的 UI,并让 React 承担起确定如何有效地更新 DOM 的繁重工作。
面试需要了解的内容
- 以声明方式思考并设计组件:给定一个 UI 和需求,您应该能够以声明式方式思考,以定义各种必要的组件、道具、状态、更改状态的操作,并将它们全部连接在一起。
练习题
编码: