文章目录
- [一、使用 State 响应输入](#一、使用 State 响应输入)
-
- [1. 命令式 vs 声明式](#1. 命令式 vs 声明式)
- [2. 声明式地设计 UI 的 5 个步骤](#2. 声明式地设计 UI 的 5 个步骤)
-
- 第一步:定位组件中不同的视图状态
- 第二步:确定触发状态改变的因素
- [第三步:用 useState 表示内存中的 State](#第三步:用 useState 表示内存中的 State)
- [第四步:删除不必要的 State(精简逻辑)](#第四步:删除不必要的 State(精简逻辑))
- 第五步:连接事件处理函数
- 二、选择状态结构
-
- [1. 合并关联的 state](#1. 合并关联的 state)
- [2. 避免矛盾的 state](#2. 避免矛盾的 state)
- [3. 避免冗余的 state](#3. 避免冗余的 state)
- [4. 避免重复的 state](#4. 避免重复的 state)
- [5:避免深度嵌套的 State](#5:避免深度嵌套的 State)
- 三、在组件间共享状态
-
- [1. 什么是状态提升?](#1. 什么是状态提升?)
- [2. 实现状态提升的三个步骤](#2. 实现状态提升的三个步骤)
- [3. 受控组件 vs. 非受控组件](#3. 受控组件 vs. 非受控组件)
- [4. 单一数据源原则 (Single Source of Truth)](#4. 单一数据源原则 (Single Source of Truth))
- [5. 状态提升后的组件通信模型](#5. 状态提升后的组件通信模型)
- [💡 总结语](#💡 总结语)
- [四、对 state 进行保留和重置](#四、对 state 进行保留和重置)
-
- [1. 核心原则:状态的"居住地"](#1. 核心原则:状态的“居住地”)
- [2. 状态何时会被【保留】?](#2. 状态何时会被【保留】?)
- [3. 状态何时会被【重置】?](#3. 状态何时会被【重置】?)
- [4. 如何【强制】重置状态?](#4. 如何【强制】重置状态?)
- [5. 开发中的重要禁忌](#5. 开发中的重要禁忌)
-
- [❌ 严禁嵌套定义组件](#❌ 严禁嵌套定义组件)
- 总结摘要表
- [五、迁移状态逻辑至 Reducer 中](#五、迁移状态逻辑至 Reducer 中)
-
- [1. 什么是 Reducer?](#1. 什么是 Reducer?)
- [2. 核心语法对比](#2. 核心语法对比)
-
- [useState 模式(分散)](#useState 模式(分散))
- [useReducer 模式(解耦)](#useReducer 模式(解耦))
- [3. useState vs useReducer 深度对比](#3. useState vs useReducer 深度对比)
- [4. 编写 Reducer 的两条"金律"](#4. 编写 Reducer 的两条“金律”)
-
- [核心:保持 Reducer 的纯净 (Pure)](#核心:保持 Reducer 的纯净 (Pure))
- [逻辑:Action 描述"交互"而非"数据更新"](#逻辑:Action 描述“交互”而非“数据更新”)
- [3. 快速决策指南](#3. 快速决策指南)
- [六、使用 Context 深层传递参数](#六、使用 Context 深层传递参数)
- [七、React 进阶:Reducer + Context 模式总结](#七、React 进阶:Reducer + Context 模式总结)
-
- [1. 核心价值:为什么要结合使用?](#1. 核心价值:为什么要结合使用?)
- [2. 实现的三个关键步骤](#2. 实现的三个关键步骤)
-
- [第一步:创建 Context](#第一步:创建 Context)
- [第二步:提供 Context (Provider)](#第二步:提供 Context (Provider))
- [第三步:使用 Context (Consumer)](#第三步:使用 Context (Consumer))
- [3. 最佳实践:模块化封装](#3. 最佳实践:模块化封装)
- [4. 代码模式对比](#4. 代码模式对比)
一、使用 State 响应输入
1. 命令式 vs 声明式
-
命令式(Imperative):像给司机下达具体指令:"过两个路口左转,看到红房子停车"。你必须手动操作每一个 DOM 元素(显示、隐藏、禁用)。
-
声明式(Declarative):像告诉出租车司机:"去机场"。你只需要描述组件在不同状态下看起来是什么样,React 会负责更新 DOM。
2. 声明式地设计 UI 的 5 个步骤
当你准备开发一个交互组件时,请遵循以下流程:
第一步:定位组件中不同的视图状态
像设计师一样,列出用户可能看到的所有视觉状态:
-
Empty: 初始状态,按钮禁用。
-
Typing: 正在输入,按钮启用。
-
Submitting: 提交中,表单禁用,显示加载动画。
-
Success: 提交成功,显示反馈信息。
-
Error: 提交失败,显示错误提示,允许重试。
第二步:确定触发状态改变的因素
-
人为输入:点击按钮、切换输入框、点击链接。
-
计算机输入:网络请求成功/失败、定时器结束。
第三步:用 useState 表示内存中的 State
先写下所有可能需要的变量(即使它们看起来有重复):
javascript
const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// ... 等等
第四步:删除不必要的 State(精简逻辑)
审查你的 State,问自己三个问题:
-
是否矛盾?(例如 isTyping 和 isSubmitting 同时为 true 是不可能的,应合并为 status 变量)。
-
是否重复?(例如 isEmpty 可以通过 answer.length === 0 计算得出)。
-
是否可以推导?(例如 isError 可以通过 error !== null 得到)。
精简后的结果示例:
javascript
const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
const [status, setStatus] = useState('typing'); // 'typing', 'submitting', 'success'
第五步:连接事件处理函数
最后,通过事件(如 onChange, onSubmit)调用 set 函数来切换状态。
💡 核心心法总结
- UI 是状态的反映: U I = f ( s t a t e ) UI = f(state) UI=f(state)。
- 状态机思维:把组件想象成一个状态机,明确每个状态下的视觉表现以及状态间的迁移路径。
- 减少"不可能的状态":通过合并相关的变量(如使用字符串状态机而非多个布尔值),避免 UI 出现既在加载又在报错的尴尬情况。
二、选择状态结构
构建良好的 State 结构就像是为建筑搭建稳固的骨架。设计得当的 State 能显著减少 Bug,并使组件更易于修改和调试。
| 优化目标 | 解决方案 | 核心益处 |
|---|---|---|
| 同步更新 | 合并关联变量为对象 | 防止更新遗漏 |
| 消除冲突 | 使用状态枚举 (Status) | 杜绝"既在加载又在成功"的矛盾 |
| 保持简洁 | 删除冗余/可推导的变量 | 减少内存占用,逻辑更清晰 |
| 数据同步 | 存储 ID 而非完整对象 | 确保关联数据永远是最新的 |
| 易于维护 | 扁平化嵌套结构 | 更新逻辑从 O(N) 降为 O(1) |
1. 合并关联的 state
如果某两个 state 变量总是一起变化,则将它们统一成一个 state 变量可能更好。
- ❌ 差 :
const [x, setX] = useState(0); const [y, setY] = useState(0); - ✅ 好 :
const [position, setPosition] = useState({ x: 0, y: 0 });提示 :更新对象 State 时,必须使用setPosition({ ...position, x: 100 })展开运算符来显式复制其他属性。
2. 避免矛盾的 state
| 维度 | ❌ 多个布尔值 (容易出错) | ✅ 状态枚举 (推荐) |
|---|---|---|
| State 声明 | const [isSending, setIsSending] = useState(false); const [isSent, setIsSent] = useState(false); |
const [status, setStatus] = useState('typing'); |
| 逻辑维护 | 手动同步 :开发者必须确保在一个变为 true 时,另一个手动变为 false。 |
自动互斥:只需要切换一个字符串值,天然避免了状态重叠。 |
| 可读性 | 代码中充斥着复杂的布尔组合判断,难以直观一眼看出当前阶段。 | 通过语义化的状态名(如 'sending')直观判断当前 UI 阶段。 |
| 健壮性 | 可能出现 isSending 和 isSent 同时为 true 的逻辑 Bug。 |
状态机模型确保在任何时刻只能处于一种确定的状态。 |
3. 避免冗余的 state
-
准则:能算出来的,就别存。
-
坏处:如果你把 fullName 存入 state,你必须在 setFirstName 和 setLastName 的地方手动去更新它,一旦漏掉一个,数据就不同步了。
javascript
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState(''); // ❌ 冗余!
- 正解: 在渲染期间实时计算 const fullName = firstName + ' ' + lastName;。
不要在 state 中镜像 props:
javascript
function Message({ colorProp }) {
const [color, setColor] = useState(colorProp); // 🔴 危险!
}
-
原因:如果父组件的 colorProp 变了,子组件的 color State 不会随之更新(因为它只在挂载时初始化一次)。
-
例外:仅当你明确希望忽略后续更新时使用,并建议改名为 initialColor。
4. 避免重复的 state
-
情景:从一个列表中选择一项。
-
错误:把选中的整个对象存入 selectedItem。
-
正确:只存选中项的 id。
javascript
const [selectedId, setSelectedId] = useState(0);
const selectedItem = items.find(item => item.id === selectedId);
5:避免深度嵌套的 State
-
问题:更新一个深层嵌套的地点(如:地球 -> 亚洲 -> 中国 -> 北京)需要复制每一层父级,代码极其复杂。
-
优化:扁平化(归一化)。像数据库一样,用 ID 索引来存储数据。
三、在组件间共享状态
1. 什么是状态提升?
状态提升是指将原本在子组件中各自维护的 state 移动到它们最近的公共父组件上。
- 目的:让多个子组件能够共享同一个数据源,实现状态的同步更改。
- 实现方式 :父组件保存
state,并通过props将数据和修改数据的函数(回调函数)传递给子组件。
2. 实现状态提升的三个步骤
当你发现两个组件需要同步变化时,可以按以下节奏重构:
- 从子组件中移除 state :删除子组件内部的
useState,改为从props接收数据。 - 从父组件传递硬编码数据 :先在父组件中通过
props给子组件传一个固定的值,确保子组件能根据props正确渲染。 - 在父组件添加真正的 state :
- 在父组件定义
useState。 - 将
state变量作为 props 传给子组件。 - 将改变 state 的函数(如
setActiveIndex)封装成事件处理程序(如onShow)传给子组件。
- 在父组件定义
3. 受控组件 vs. 非受控组件
这是一个重要的架构概念:
| 类型 | 驱动源 | 特点 | 灵活性 |
|---|---|---|---|
| 非受控组件 | 内部 state |
组件自己管理状态,父组件难以干预。 | 较低(难以与其他组件同步) |
| 受控组件 | 外部 props |
组件的行为完全由父组件驱动。 | 较高(容易组合和协调) |
提示:在实际开发中,组件往往是混合的。有些 UI 交互(如 CSS 悬停效果)适合非受控,而核心业务数据(如表单值、当前激活项)通常推荐受控。
4. 单一数据源原则 (Single Source of Truth)
- 定义 :对于应用中的每一个状态,都应该由唯一一个组件来负责"掌控"它。
- 原则 :不要在多个组件中复制相同的状态,而是通过
props向下流动,或者通过状态提升向上移动。 - 动态性:状态的位置不是固定的。随着功能增加,状态可能会在组件树中上下移动,这是 React 开发中非常自然的重构过程。
5. 状态提升后的组件通信模型
- 向下通信(数据驱动) :父组件通过
props把state传给子组件,子组件被动渲染。 - 向上通信(行为触发) :父组件通过
props传给子组件一个回调函数 。当子组件发生交互(如点击按钮)时,调用该函数,通知父组件修改state。
💡 总结语
状态提升是解决 React 组件间"步调不一致"的万金油。当你犹豫状态该放哪时,问自己一句:"谁需要知道这个状态?" 如果有两个兄弟组件都需要,那就把它提到它们的公共父级那里去。
四、对 state 进行保留和重置
这份教程深入浅出地解释了 React 中 State 的生命周期与 UI 树位置之间的核心关系。
1. 核心原则:状态的"居住地"
- 状态不在 JSX 里 :虽然你在组件内部编写
useState,但 State 实际上是由 React 内部保存的。 - 关联依据(位置) :React 根据组件在 UI 渲染树中的位置,将保存的状态与对应的组件关联起来。
- 位置即"地址":你可以把 UI 树中的层级结构看作组件的"地址"。只要地址没变,即使父组件更新了,状态通常也会保留。
2. 状态何时会被【保留】?
只要满足以下 "双同"条件,状态就会被保留:
- 相同的位置:在父组件的子节点顺序/结构中处于同一层级。
- 相同的组件类型 :标签名(如
<Counter />)没有发生改变。
⚠️ 陷阱提醒 :React 关心的是渲染树中的最终结果,而不是你代码里的
if/else逻辑。即使你在不同的return分支里写了两个<Counter />,只要它们最终渲染在树的同一位置,React 就会认为它们是同一个实例。
3. 状态何时会被【重置】?
当以下任一情况发生时,React 会销毁旧组件及其所有子树的状态:
- 组件被移除 :组件从渲染树中消失(例如条件渲染为
false)。 - 位置发生了不同类型的组件切换 :比如同一位置从
<Counter />变成了<p>。 - 父级结构改变 :即使组件本身没变,但它的父标签变了(例如从被
<div>包裹变成被<section>包裹)。
4. 如何【强制】重置状态?
有时即使组件位置没变,我们也希望清除状态(例如切换聊天对象时清空输入框)。
- 方法一:改变组件位置(不推荐)
- 通过逻辑让两个组件渲染在不同的层级位置,但这会增加 JSX 的复杂度。
- 方法二:使用
key(推荐)- 身份标识 :
key不仅仅用于列表。给组件一个唯一的key(如key={userId}),可以告诉 React:"即使我在同一个位置,但我是一个全新的组件"。 - 效果 :当
key改变时,React 会销毁旧的组件实例(及其状态)并创建一个全新的组件。
- 身份标识 :
5. 开发中的重要禁忌
❌ 严禁嵌套定义组件
错误做法: 不要在函数组件 A 的内部定义函数组件 B。
- 后果:每次 A 重新渲染时,都会创建一个全新的 B 函数(类型引用变了)。
- 现象:这会导致 B 在每次父组件更新时都会彻底重置状态,引发输入框失去焦点、性能下降等严重 Bug。
总结摘要表
| 场景 | React 的行为 | 结果 |
|---|---|---|
| 位置相同 + 类型相同 | 保留状态 | 状态延续 |
| 位置不同 | 销毁旧状态,初始化新状态 | 状态重置 |
| 位置相同 + 类型改变 | 销毁旧状态,初始化新状态 | 状态重置 |
| 位置相同 + 类型相同 + Key 改变 | 视为不同组件 | 状态重置 |
五、迁移状态逻辑至 Reducer 中
当组件的状态更新逻辑变得复杂且分散时,使用 useReducer 可以将逻辑整合到组件外部的一个统一函数中(即 Reducer),从而提高代码的可读性和可维护性。
1. 什么是 Reducer?
Reducer 是处理状态的一种新方式。它将组件中"发生了什么"(Action)与"状态如何更新"(State Logic)分离开来。
迁移的三大步骤:
- Dispatch Action:在事件处理程序中,不再直接设置状态,而是派发(dispatch)一个描述用户操作的 action 对象。
- 编写 Reducer 函数:编写一个外部函数,根据不同的 Action 类型返回新的状态。
- 在组件中使用 :使用
useReducerHook 替换useState。
2. 核心语法对比
useState 模式(分散)
javascript
function handleAddTask(text) {
setTasks([...tasks, { id: nextId++, text, done: false }]);
}
function handleChangeTask(task) {
setTasks(
tasks.map((t) => {
if (t.id === task.id) {
return task;
} else {
return t;
}
})
);
}
function handleDeleteTask(taskId) {
setTasks(tasks.filter((t) => t.id !== taskId));
}
useReducer 模式(解耦)
第一步:派发 Action
javascript
function handleAddTask(text) {
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}
function handleChangeTask(task) {
dispatch({
type: 'changed',
task: task,
});
}
function handleDeleteTask(taskId) {
dispatch({
type: 'deleted',
id: taskId,
});
}
第二步:编写 Reducer (通常放在组件外部)
javascript
function tasksReducer(tasks, action) {
switch (action.type) {
case 'added': {
return [...tasks, { id: action.id, text: action.text, done: false }];
}
case 'changed': {
return tasks.map(t => (t.id === action.task.id ? action.task : t));
}
case 'deleted': {
return tasks.filter(t => t.id !== action.id);
}
default: {
throw Error('未知 action: ' + action.type);
}
}
}
第三步:在组件中使用
javascript
import { useReducer } from 'react';
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
3. useState vs useReducer 深度对比
| 维度 | useState | useReducer |
|---|---|---|
| 代码体积 | 初始代码少。非常适合管理简单的布尔值、数字或字符串。 | 初始代码较多。需要提前定义 Reducer 函数和各种 Action 类型。 |
| 可读性 | 当更新逻辑变得复杂时,处理程序会变得臃肿,可读性下降。 | 将逻辑从组件中抽离,实现关注点分离,逻辑层级更清晰。 |
| 调试体验 | 出现问题时,很难追踪是哪个函数在何时修改了状态。 | 极其强大。通过打印 Action 日志,可以清晰看到每一个用户交互序列。 |
| 测试便利性 | 逻辑耦合在组件内,通常需要配合组件挂载进行集成测试。 | 极易测试。Reducer 是纯函数,不依赖 React 环境,可直接进行单元测试。 |
4. 编写 Reducer 的两条"金律"
为了确保应用的可预测性和易维护性,在编写 Reducer 时必须遵循以下原则:
核心:保持 Reducer 的纯净 (Pure)
- 无副作用:不要在 Reducer 内部发送 API 请求、执行定时器或进行任何影响外部的操作。
- 不可变更新 :永远不要直接修改传入的
state。必须通过展开运算符...或数组方法返回一个全新的对象/数组副本。 - 确定性:同样的输入(State + Action)必须永远得到同样的输出。
逻辑:Action 描述"交互"而非"数据更新"
- 单一交互原则 :一个 Action 应该对应一个具体的用户行为。
- 示例 :如果用户点击"重置表单",应该派发一个
type: 'reset_form'。- ❌ 错误做法 :连续派发 5 个
set_field的 Action。 - ✅ 正确做法 :派发 1 个
reset_formAction,由 Reducer 一次性将 5 个字段全部重置。 - 好处:调试日志会非常清晰,一眼就能看出用户点击了重置按钮。
- ❌ 错误做法 :连续派发 5 个
3. 快速决策指南
- 使用
useState:状态是独立的(如isOpen)、逻辑简单、只有 1-2 个地方会修改它。 - 使用
useReducer:状态逻辑复杂(多个状态相互关联)、多个事件处理程序以相似方式修改状态、或者需要维护大型对象/数组。
六、使用 Context 深层传递参数
Context 提供了一种在组件树中传递数据的方法,无需在每个层级手动传递 props。
1. 核心机制:解决"Prop 逐级透传"
在 React 中,数据流通常是自上而下的(单向数据流)。
- 痛点(Prop Drilling):当深层子组件需要祖先组件的数据时,必须通过中间组件一层层手动传递 Props,导致代码冗长且难以维护。
- 对策(Context):Context 建立了一个"广播系统",允许数据绕过中间组件,直接从祖先组件"直达"任何深层的后代组件。
2. 使用 Context 的三个标准步骤
实现 Context 就像配置一个无线广播电台:
| 步骤 | 动作 | 关键语法 |
|---|---|---|
| Step 1: 建立电台 | 创建并导出 Context 对象 | export const MyContext = createContext(defaultValue); |
| Step 2: 接收信号 | 在子组件内通过 Hook 读取 | const value = useContext(MyContext); |
| Step 3: 发射信号 | 在父组件中提供数据 | <MyContext value={yourValue}> {children} </MyContext> |
💡 版本注意 :在 React 最新版本中,可以直接使用
<MyContext value={...}>;旧版本则需要使用<MyContext.Provider value={...}>。
3. Context 的关键特性
- 就近原则 :子组件总是从 UI 树中离它最近的那个 Provider 获取值。
- 动态响应 :Context 的值通常与
state绑定。当 Provider 的value改变时,所有使用了useContext的子组件都会自动重新渲染。 - 强穿透力 :Context 可以穿过任何中间组件(即使中间组件是静态的或使用了
React.memo),不会被中断。 - 高度独立:不同的 Context 互不干扰。一个组件可以轻松消费多个不同的 Context(如同时读取"主题"和"用户信息")。
4. 决策指南:何时使用?
Context 虽然强大,但会使组件产生耦合(降低复用性),应谨慎使用。
优先考虑的替代方案
- 继续传递 Props:如果层级较浅,Props 是最清晰、最显式的数据流方式。
- 组件组合 (Children) :通过
children属性将子组件传入,利用"内容分发"减少中间传递层级。
最佳实践场景
- 全局偏好:如深色/浅色模式(Theme)、语言国际化(i18n)。
- 登录状态:当前登录的用户信息、权限验证。
- 路由/状态库:许多库(如 React Router, Redux)底层都基于 Context 实现。
- 复杂组件通信:如折叠面板(Accordion)或选项卡(Tabs),父容器与深层子项共享交互状态。
💡 深度思考:与 CSS 属性继承的类比
Context 的工作方式非常类似于 CSS 的 属性继承 (如 color 或 font-family):
- 你在根节点设置了
color: blue,所有子元素默认都变蓝。 - 如果你在中间某个容器设置了
color: green,该容器内部的所有子元素都会"覆盖"掉蓝色,变为绿色。
这正是 Context 的精髓:让组件能够"适应周围环境",并根据所处的 Context 渲染出不同的形态。
七、React 进阶:Reducer + Context 模式总结
将 useReducer 的状态管理逻辑与 Context API 的跨层级传递能力相结合,是 React 中管理中大型应用状态的黄金搭档。
1. 核心价值:为什么要结合使用?
- 解决 Props 钻取(Props Drilling) :避免将
state和dispatch像接力棒一样穿过数十个中间组件,只为了传给最深层的子组件。 - 清晰的职责分离 :
- Reducer:定义"如何更新状态"(逻辑中心)。
- Context:定义"数据传给谁"(广播中心)。
- 简化组件维护:中间组件不再需要关心不属于它们的数据,代码更整洁。
2. 实现的三个关键步骤
第一步:创建 Context
通常建议创建 两个独立的 Context,以优化性能:
TasksContext:传递当前的状态数据(State)。TasksDispatchContext:传递更新状态的函数(Dispatch)。
注意:分开存放可以确保那些只发送 Action 而不读取数据的组件,在数据变化时不会被强制重新渲染。
第二步:提供 Context (Provider)
在顶层组件中:
- 调用
useReducer获取tasks和dispatch。 - 使用嵌套的
Provider将这两个值注入到组件树中。
第三步:使用 Context (Consumer)
深层组件通过 Hook 获取所需资源:
useContext(TasksContext)获取数据。useContext(TasksDispatchContext)获取派发函数。
3. 最佳实践:模块化封装
为了提高可重用性和安全性,通常将逻辑封装在独立文件中(如 TasksContext.js):
- 封装 Provider 组件 :创建一个
TasksProvider,内部管理useReducer,外部包裹{children}。 - 自定义 Hook :
- 导出
useTasks()和useTasksDispatch()。 - 优势:组件代码更简洁,且可以在 Hook 中加入错误处理(例如:检查 Hook 是否在 Provider 外部被非法调用)。
- 导出
4. 代码模式对比
| 特性 | 仅使用 Reducer | Reducer + Context 模式 |
|---|---|---|
| 状态存储 | 存在于顶层组件 | 存在于独立的 Provider 内部 |
| 数据传递 | 依靠 Props 逐层传递 | 通过 Context 跨层级"瞬移" |
| 组件耦合 | 子组件强依赖父组件传入的 Props | 子组件直接消费全局/局部 Context |
| 适用场景 | 简单、层级浅的组件结构 | 复杂、层级深、状态共享频繁的应用 |
!TIP
设计思想
这种模式是 Redux 等主流状态管理库的核心灵魂。掌握了它,你就理解了单向数据流和集中式状态管理的精髓,未来迁移到 Redux 或 Zustand 将会非常轻松。