React 状态管理
约 9398 字大约 31 分钟
React
2025-08-12
声明式与命令式 UI
用 State 响应输入:声明式 UI 与命令式 UI
- 声明式 UI(React 采用的方式)
- 只需声明组件可能的 状态 以及不同状态下的 UI 表现。
- 根据用户输入切换状态,UI 会自动与状态保持同步。
- 类似设计师的思路——先想“UI 有哪些状态”,再决定“状态之间如何切换”。
- 命令式 UI
- 需要一步步 直接指挥 UI 元素的变化,例如“显示加载动画 → 禁用按钮 → …”。
- 程序员必须写出明确的操作步骤(命令),告诉计算机 怎么做。
- 像给司机实时下达导航指令,如果指令错了,结果也会错。
核心区别:
- 声明式:描述“UI 在某个状态下应该长什么样”,重点是结果。
- 命令式:描述“为了达到某个状态,需要执行哪些步骤”,重点是过程。
声明式 UI
为什么 React 选择声明式 UI
- 命令式 UI 的问题
- 在简单、独立的系统中,命令式控制 UI 还算可行。
- 但在复杂系统中,每次新增 UI 元素或交互,都要 仔细检查和修改现有代码,避免忘记更新相关元素(显示、隐藏、启用、禁用等),否则容易引入 bug。
- 管理难度会 随复杂度呈指数级上升。
- React 的解决思路
- 你 不直接操作 UI,而是声明“我想看到的内容”。
- React 会 自动计算并更新 UI,确保界面和状态保持一致。
- 类比:你告诉出租车司机目的地(目标 UI),而不是亲自指挥每一个转弯(具体 DOM 操作)。
核心优势:降低复杂 UI 的维护成本,减少出错几率,让开发者专注于 描述结果 而非 编写过程。
声明式 UI 的核心
- 事先定义好所有可能的 UI 状态
- 每种状态对应的 UI 都用代码(组件渲染逻辑)写出来。
- 例如:
loading状态显示加载动画,success状态显示成功提示,error状态显示错误信息。
- 运行时只负责切换状态
- 当条件变化(用户输入、网络响应等),只需更新 state 为对应状态值。
- React 会自动渲染出匹配这个状态的 UI,而不是你去手动 show/hide、enable/disable 每个元素。
简单例子:
function App() {
const [status, setStatus] = useState("idle"); // idle | loading | success | error
return (
<div>
{status === "idle" && <button onClick={() => setStatus("loading")}>提交</button>}
{status === "loading" && <p>加载中...</p>}
{status === "success" && <p>提交成功!</p>}
{status === "error" && <p>提交失败,请重试。</p>}
</div>
);
}所有状态的 UI 都写在 JSX 里,切换 UI 只需 setStatus(...),React 会自动帮你把界面更新到对应状态。
声明式 UI = “写好所有状态 → 切状态就行”,而不是“遇到事件后去一一修改 DOM”。
声明式 UI 的自动计算机制
- 当你切换状态时,React 会 重新渲染组件的虚拟 DOM,然后通过 diff 算法(比较新旧虚拟 DOM)找出真正需要改的地方。
- 它不会像命令式那样,可能一不小心就多次重复修改同一个 DOM 属性,也不会去动那些 没变的部分。
- 结果就是:
- 减少不必要的 DOM 操作(性能更好)
- 保证 UI 和状态的一致性(不容易出现忘记改 UI 的 bug)
总结
- 性能优化只是副作用之一,声明式的主要目的 还是:
- 降低复杂度(你只写状态和 UI 对应关系)
- 提高可维护性(不用记住每个状态变化要动哪些 UI 元素)
- 性能优化是通过 虚拟 DOM diff 或 编译优化 自动实现的,不需要你手动管理。
声明式 UI 在状态切换时,会自动计算 UI 的差异,只更新必要的部分,从而减少多余的 DOM 操作,并确保界面和状态保持一致。
构建 state 的最佳实践
- 合并关联的 state
- 如果你有多个总是一起变化的变量,比如
firstName和lastName,就合成一个user对象会更方便管理。 - 这样可以减少更新时的同步负担。
- 如果你有多个总是一起变化的变量,比如
- 避免互相矛盾的 state
- 比如
isEmpty和items.length同时存在,如果isEmpty是true,items.length却是3,这就冲突了。 - 更好的做法是直接通过
items.length推导isEmpty。
- 比如
- 避免冗余的 state
- 如果能算出来,就不要单独存。
- 例如购物车的
totalPrice可以用items.reduce()计算,不需要单独存一个totalPrice。
- 避免重复的 state
- 同一份数据不要在多个地方保存,否则可能会出现“一个改了另一个忘记改”的情况。
- Vue、React 都是用 props 或全局 store 来解决数据共享,而不是在多个组件各存一份。
- 避免深度嵌套的 state
- 嵌套太深更新很麻烦,比如
docs.user.info.address.city。 - 扁平化存储会更方便,类似 Redux 推荐的结构。
- 嵌套太深更新很麻烦,比如
核心目标 就是: 让 state 结构足够简单,不容易出错,并且在更新时尽量少影响无关部分,这样渲染性能和可维护性都会好很多。
相关例子:
const [x, setX] = useState(0);
const [y, setY] = useState(0);
↓
const [position, setPosition] = useState({ x: 0, y: 0 });不要在 state 中镜像 props
一个常见的冗余写法是:
function Message({ messageColor }) {
const [color, setColor] = useState(messageColor);
}这样做的问题是:color 只会在第一次渲染时初始化为 messageColor 的值。 如果父组件之后将 messageColor 从 'blue' 改成 'red',color 并不会自动更新,导致 UI 和 props 脱节。
正确做法:直接使用 props,而不是复制到 state。
function Message({ messageColor }) {
const color = messageColor; // 可重命名,但保持直接引用
}这样,color 始终与父组件传入的 messageColor 保持同步。
例外情况 只有当你 明确不希望 跟随 props 更新时,才将它复制到 state,并用 initial 或 default 前缀命名:
function Message({ initialColor }) {
const [color, setColor] = useState(initialColor); // 后续更新将被忽略
}唯一数据源原则
在 React 中,每个状态应有且仅有一个 唯一的数据源(Single Source of Truth)。
- 不同状态分布在不同层级的组件中,活跃在它们需要的地方。
- 状态应保存在最合适的组件中,通过 提升状态 或 向下传递 来共享,而不是在多个组件中复制。
- 确定状态的“活跃位置”是优化组件结构和数据流的重要步骤。
React 组件 State 保留规则
React 中组件的 state 与其在渲染树中的位置绑定,而不是与组件本身绑定。即使两个组件使用相同的 JSX 标签(如 <Counter />),只要它们出现在渲染树的不同位置,就会拥有完全独立的状态。
- 状态保留:React 在重渲染时会根据组件在 UI 树中的位置决定是否保留状态。位置相同且类型相同,状态会被保留。
- 状态重置:如果组件位置变化、类型变化,或你显式指定(如使用不同
key),React 会重置该组件的状态。 - 状态独立性:并排渲染的多个相同组件,状态互不影响。
- 开发意义:可以通过控制组件位置、类型和
key来决定状态的保留与重置,从而精确控制 UI 行为。
- 相同位置 + 相同组件 → 保留 state
- 当一个组件在 UI 树中的位置和类型保持不变,React 会保留它的 state。
- 组件被移除 → state 销毁
- 组件一旦从渲染树中移除,其 state 会被完全丢弃。
- 重新渲染时,该组件会重新初始化 state。
- 相同位置 + 不同组件 → state 销毁
- 如果在相同位置渲染了不同的组件类型,React 会认为它是全新的组件,并丢掉原组件的 state。
- 验证示例
- 计数器组件(Counter)在被“隐藏”后再次显示,计数值会重置为 0。
- 原因:React 在隐藏时卸载了组件,state 被销毁。
只要一个组件还被渲染在 UI 树的相同位置,React 就会保留它的 state。 如果它被移除,或者一个不同的组件被渲染在相同的位置,那么 React 就会丢掉它的 state。
React 中 相同位置 + 相同组件 的 State 保留机制
核心原则
React 只关心渲染树的位置和组件类型,不关心 JSX 中的具体写法。
如果在 相同位置 渲染了 相同类型 的组件,React 会保留其 state。
如果组件被替换成不同类型,或者完全移除,则其 state 会被销毁并重新初始化。
示例一:同位置同组件 → state 保留
import { useState } from 'react';
export default function App() {
const [isFancy, setIsFancy] = useState(false);
return (
<div>
{/* isFancy 变化时,位置和组件类型不变 */}
<Counter isFancy={isFancy} />
<label>
<input
type="checkbox"
checked={isFancy}
onChange={e => setIsFancy(e.target.checked)}
/>
使用好看的样式
</label>
</div>
);
}
function Counter({ isFancy }) {
const [score, setScore] = useState(0);
return (
<div className={`counter ${isFancy ? 'fancy' : ''}`}>
<h1>{score}</h1>
<button onClick={() => setScore(score + 1)}>加一</button>
</div>
);
}切换 isFancy 样式时,计数不会重置,因为 <Counter /> 在树中的位置和类型没变。
示例二:条件渲染但位置相同 → state 依然保留(常见误区)
import { useState } from 'react';
export default function App() {
const [isFancy, setIsFancy] = useState(false);
if (isFancy) {
return (
<div>
<Counter isFancy={true} />
<Checkbox isFancy={isFancy} setIsFancy={setIsFancy} />
</div>
);
}
return (
<div>
<Counter isFancy={false} />
<Checkbox isFancy={isFancy} setIsFancy={setIsFancy} />
</div>
);
}
function Counter({ isFancy }) {
const [score, setScore] = useState(0);
return (
<div className={`counter ${isFancy ? 'fancy' : ''}`}>
<h1>{score}</h1>
<button onClick={() => setScore(score + 1)}>加一</button>
</div>
);
}
function Checkbox({ isFancy, setIsFancy }) {
return (
<label>
<input
type="checkbox"
checked={isFancy}
onChange={e => setIsFancy(e.target.checked)}
/>
使用好看的样式
</label>
);
}虽然看起来是两段 if/else 分支返回了 两个不同的 <Counter /> 标签, 但 React 在比对渲染树时发现:两次渲染中 <Counter /> 都是根 <div> 的第一个子组件 → 位置相同,因此 state 不会重置。
记忆要点
- 匹配规则:相同的 位置路径 + 相同的 组件类型 → state 保留 例如
"根组件 → 第一个子组件 → 第一个子组件"(从 App(根)走到它的第一个子组件,再进入那个子组件的第一个子组件。) - 条件渲染不会影响位置匹配,React 只看最终渲染树的位置,不看你 JSX 的写法。
- 更换组件类型 或 移除组件 → state 丢失并重新初始化。
组件类型
这里的 组件类型 指的就是 React 中的 函数组件(Function Component)或 类组件(Class Component)。
更具体地说,React 会根据 组件类型 + 在父组件 JSX 树中的位置 来决定是否复用 state:
- 类型相同:同一个位置上渲染的组件是同一个函数组件或同一个类组件,React 会保留它的 state。
- 类型不同:即使位置相同,但组件类型换了(比如原来是
<Counter />,后来换成<AnotherCounter />),React 也会认为这是一个全新的组件,销毁旧的 state 并初始化新的 state。
换句话说,React 用 (组件类型, 树中位置) 这个组合来标识一个组件实例的“身份”。
元素包裹
当你在相同位置渲染不同的组件时,该组件的整个子树都会被重置
{isFancy ? (
<div>
<Counter isFancy={true} />
</div>
) : (
<section>
<Counter isFancy={false} />
</section>
)}组件定义位置的注意事项
- 问题现象:如果在一个组件函数内部定义另一个组件(如在
MyComponent内部定义MyTextField),则每次外部组件重新渲染时,内部组件都会被视为“新组件”,其 state 会被重置。 - 原因:React 的组件匹配规则依赖于 位置路径 + 组件类型。当组件函数在渲染过程中被重新创建时,类型引用发生变化,React 认为这是一个不同的组件,从而丢弃旧的 state。
- 影响:
- 状态丢失:输入框、选中状态等 UI 状态会在父组件更新时被清空。
- 性能问题:重复创建组件定义会造成额外的内存与计算开销。
- 最佳实践:
- 永远在 组件文件的最顶层 定义组件,而不是在函数组件内部定义。
- 保持组件函数在每次渲染中引用相同的函数对象,以便 React 正确复用组件实例和 state。
总结:不要在组件内部嵌套定义新的组件,否则会造成 state 重置和性能下降。
相同位置重置 state
当组件在渲染树中的位置相同时,React 会复用该组件及其 state,即使传入的 props 发生变化。
例如,切换玩家时两个 Counter 组件在同一位置渲染,只是 person 不同,但 state 保留:
示例
export default function Scoreboard() {
const [isPlayerA, setIsPlayerA] = useState(true);
return (
<div>
{isPlayerA ? (
<Counter person="Taylor" />
) : (
<Counter person="Sarah" />
)}
<button onClick={() => setIsPlayerA(!isPlayerA)}>下一位玩家!</button>
</div>
);
}这时 score 状态不会重置。
在某些场景下,不同的“组件实例”逻辑上应该是独立的,切换时应重置它们的状态。比如:
- 不同玩家的分数计数器
- 不同聊天窗口的输入框内容
方法一:渲染到不同位置
将两个 Counter 组件渲染到不同的位置,利用 React 对渲染树位置的匹配规则来区分:
export default function Scoreboard() {
const [isPlayerA, setIsPlayerA] = useState(true);
return (
<div>
{isPlayerA && <Counter person="Taylor" />}
{!isPlayerA && <Counter person="Sarah" />}
<button onClick={() => setIsPlayerA(!isPlayerA)}>下一位玩家!</button>
</div>
);
}效果:当切换时,一个 Counter 被卸载,state 被销毁,另一个被新建,state 从零开始。
方法二:使用 key 强制 React 识别组件身份
React 默认用组件位置区分实例,key 可以让你指定唯一标识,区分即使位置相同的组件:
export default function Scoreboard() {
const [isPlayerA, setIsPlayerA] = useState(true);
return (
<div>
{isPlayerA ? (
<Counter key="Taylor" person="Taylor" />
) : (
<Counter key="Sarah" person="Sarah" />
)}
<button onClick={() => setIsPlayerA(!isPlayerA)}>下一位玩家!</button>
</div>
);
}效果:切换 key 会导致 React 认为是不同组件,从而卸载旧组件,创建新组件,重置 state。
key 的作用与注意事项
key不是全局唯一,只在同一层的兄弟组件间有效。- 用于帮助 React 识别哪些元素发生了变化,进行高效重用或重建。
- 对于需要重置状态的组件,合理赋予不同
key是最佳实践。
应用场景示例:聊天应用输入框状态重置
function Messenger() {
const [to, setTo] = useState(contacts[0]);
return (
<div>
<ContactList contacts={contacts} selectedContact={to} onSelect={setTo} />
{/* 加 key 来保证切换联系人时 Chat 组件状态重置 */}
<Chat key={to.id} contact={to} />
</div>
);
}这样切换联系人时,Chat 组件的输入框状态会重置,避免输入内容错误地“携带”到其他联系人。
保留状态
为被移除的组件保留 state 的方法与思路
在实际应用中,尤其是聊天类应用,用户切换不同会话时,有时希望保留输入框中的内容,即使当前会话组件从 UI 树中被隐藏或卸载,也能“活着”的状态。以下是几种常见的解决方案:
- 渲染所有组件,使用 CSS 隐藏不活跃的部分
- 思路:不卸载组件,仅通过 CSS (如
display: none)隐藏非当前会话对应的组件。 - 效果:所有聊天组件依然存在于 React 渲染树中,因此它们的内部 state 完全保留。
- 优缺点:
- 优点:简单,state 保留自然,不用额外逻辑。
- 缺点:如果隐藏组件树很大、包含大量 DOM 节点,性能可能受影响。
- 状态提升 (Lifting State Up)
- 思路:将聊天内容(草稿)状态提升至父组件中集中管理。
- 实现:
- 父组件维护一个对象,key 是联系人 ID,value 是对应的草稿文本。
- 子组件渲染时,通过 props 获取草稿内容及更新函数。
- 优点:
- 子组件可以安全地卸载,不影响草稿保存。
- 状态集中,便于管理和持久化。
- 使用持久化存储(如 localStorage)
- 思路:将草稿信息存储在浏览器的
localStorage,组件初始化时从中读取,退出时保存。 - 优点:
- 即使用户关闭或刷新页面,草稿依然保存。
- 状态不依赖于组件生命周期。
- 缺点:
- 需要额外的同步逻辑。
- 可能带来存储过期和同步问题。
为不同聊天会话指定不同的 key
- 说明:不同的会话组件从概念上是不同的 React 组件树,应给它们不同的
key,即使它们渲染在相同位置。 - 效果:
- React 会为每个
key创建独立的状态树。 - 结合上述方案,确保不同会话的状态相互隔离。
- React 会为每个
Reducer
当一个组件拥有 许多状态更新逻辑 时,如果把这些逻辑分散在不同的事件处理函数里,代码很快就会变得难以维护。你需要在多个地方记住不同的更新方式,稍有不慎就可能引入 bug。
为了解决这种问题,可以将 所有的状态更新逻辑集中在一个函数中,这个函数被称为 reducer。
将 useState 迁移到 useReducer 通过三个步骤即可:
- 将设置状态的逻辑 修改 成 dispatch 的一个 action;
- 编写 一个 reducer 函数;
- 在你的组件中 使用 reducer。
第 1 步: 将设置状态的逻辑修改成 dispatch 的一个 action
事件处理程序目前是通过设置状态来 实现逻辑的:
代码
function handleAddTask(text) {
setTasks([
...tasks,
{
id: nextId++,
text: 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));
}移除所有的状态设置逻辑。只留下三个事件处理函数:
handleAddTask(text)在用户点击 “添加” 时被调用。handleChangeTask(task)在用户切换任务或点击 “保存” 时被调用。handleDeleteTask(taskId)在用户点击 “删除” 时被调用。
使用 reducer 管理状态与直接调用 setState 有所不同。 这里,你不是直接告诉 React “状态应该变成什么”,而是通过事件处理函数 dispatch 一个 action,来表达“用户刚刚做了什么”。
状态的更新逻辑则集中在 reducer 中进行处理。 因此,在事件处理器里我们不再写“直接设置 task”,而是发送一个类似“添加任务 / 修改任务 / 删除任务”的 action。
这种方式更贴近用户的思维过程:用户不会去想“我要把任务数组变成什么样”,而是会说“我刚刚添加了一个任务”。
代码
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,
});
}你传递给 dispatch 的对象叫做 “action”:
// "action" 对象:
{
type: 'deleted',
id: taskId,
}它是一个普通的 JavaScript 对象。它的结构是由你决定的,但通常来说,它应该至少包含可以表明 发生了什么事情 的信息。
提醒
action 对象的结构并没有强制要求,但按照惯例,我们通常会添加一个字符串类型的 type 字段 来描述“发生了什么”,并通过其他字段传递额外的信息。
type 的值是 组件内部约定的,比如在这个例子里,"added" 或 "added_task" 都可以。关键是选择一个能清楚表达事件含义的名字!
dispatch({
type: 'what_happened', // 描述事件类型
// 这里可以放额外的数据
});第 2 步: 编写一个 reducer 函数
reducer 函数就是你放置状态逻辑的地方。它接受两个参数,分别为当前 state 和 action 对象,并且返回的是更新后的 state:
function yourReducer(state, action) {
// 给 React 返回更新后的状态
}React 会将状态设置为你从 reducer 返回的状态。
在这个例子中,要将状态设置逻辑从事件处理程序移到 reducer 函数中,你需要:
- 声明当前状态(
tasks)作为第一个参数; - 声明
action对象作为第二个参数; - 从
reducer返回 下一个 状态(React 会将旧的状态设置为这个最新的状态);
下面是所有迁移到 reducer 函数的状态设置逻辑:
function tasksReducer(tasks, action) {
if (action.type === 'added') {
return [
...tasks,
{
id: action.id,
text: action.text,
done: false,
},
];
} else if (action.type === 'changed') {
return tasks.map((t) => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
} else if (action.type === 'deleted') {
return tasks.filter((t) => t.id !== action.id);
} else {
throw Error('未知 action: ' + action.type);
}
}由于 reducer 函数接受 state(tasks)作为参数,因此你可以 在组件之外声明它。这减少了代码的缩进级别,提升了代码的可读性。
上面的代码使用了 if/else 语句,但是在 reducer 中使用 switch 语句 是一种惯例。两种方式结果是相同的,但 switch 语句读起来一目了然。
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) => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
}
case 'deleted': {
return tasks.filter((t) => t.id !== action.id);
}
default: {
throw Error('未知 action: ' + action.type);
}
}
}建议将每个 case 块包装到 { 和 } 花括号中,这样在不同 case 中声明的变量就不会互相冲突。此外,case 通常应该以 return 结尾。如果你忘了 return,代码就会 进入 到下一个 case,这就会导致错误!
如果你还不熟悉 switch 语句,使用 if/else 也是可以的。
第 3 步: 在组件中使用 reducer
最后,你需要将 tasksReducer 导入到组件中。记得先从 React 中导入 useReducer Hook:
import { useReducer } from 'react';接下来,你就可以替换掉之前的 useState:
const [tasks, setTasks] = useState(initialTasks);只需要像下面这样使用 useReducer:
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);useReducer 和 useState 很相似——你必须给它传递一个初始状态,它会返回一个有状态的值和一个设置该状态的函数(在这个例子中就是 dispatch 函数)。但是,它们两个之间还是有点差异的。
useReducer 钩子接受 2 个参数:
- 一个 reducer 函数
- 一个初始的 state
它返回如下内容:
- 一个有状态的值
- 一个 dispatch 函数(用来 “派发” 用户操作给 reducer)
完整代码
详情
import { useReducer } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
export default function TaskApp() {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
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,
});
}
return (
<>
<h1>布拉格的行程安排</h1>
<AddTask onAddTask={handleAddTask} />
<TaskList
tasks={tasks}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
/>
</>
);
}
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) => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
}
case 'deleted': {
return tasks.filter((t) => t.id !== action.id);
}
default: {
throw Error('未知 action: ' + action.type);
}
}
}
let nextId = 3;
const initialTasks = [
{id: 0, text: '参观卡夫卡博物馆', done: true},
{id: 1, text: '看木偶戏', done: false},
{id: 2, text: '打卡列侬墙', done: false}
];你当然可以把 reducer 移到一个单独的文件中。
为什么叫做 reducer?
它的名字来源于 数组方法 reduce(),reduce() 可以把数组中的多个值 “累积” 成一个最终值:
const arr = [1, 2, 3, 4, 5];
const sum = arr.reduce(
(result, number) => result + number
); // 1 + 2 + 3 + 4 + 5在这里,你传递给 reduce() 的那个函数就叫 reducer:
- 它接受 当前的累积结果 和 当前元素,
- 返回 下一个累积结果。
React 的 reducer 也是一样的:
- 它接受 当前状态 和 action,
- 返回 下一个状态。
随着一连串 action 的不断传入,状态就像数组求和一样 逐步累积 出最终结果。
如何编写一个优秀的 reducer
在编写 reducer 时,有两个核心原则必须牢记:
保持纯净
reducer 必须是一个 纯函数,这一点和 React 的状态更新函数类似。
- 为什么? reducer 会在渲染阶段执行,而 actions 则会在下一次渲染前被依次处理。如果 reducer 内部存在副作用(比如发起异步请求、启动定时器、操作 DOM 或修改外部变量),就会导致结果不可预测。
- 纯函数的定义:
- 相同的输入(state + action) → 必须产生相同的输出(newState)。
- 不依赖外部的可变数据。
- 不产生任何副作用。
- 实现方式: 使用 不可变更新 来修改对象或数组。例如,不直接修改原始数组,而是使用
map、filter、concat等方法返回新数组;更新对象时用展开运算符创建副本。
好习惯:在 reducer 中只关心 “输入 → 输出” 的纯逻辑,把异步或副作用交给其他地方(如 useEffect)。
一个 action 描述一次用户交互
每个 action 应该对应 一次完整的用户意图,即使它会引发多个字段或状态的变化。
- 示例:
- 用户点击 “重置表单” 按钮(表单有五个字段)。
- 正确做法:
dispatch({ type: 'reset_form' }),在 reducer 内一次性重置所有字段。 - 不推荐做法:依次触发五个
dispatch({ type: 'set_field', ... })。
这样做有几个好处:
- 语义清晰 —— action 日志能直接反映用户操作。
- 可调试性强 —— reducer 的日志就像一份“交互时间线”,你可以顺着 action 流轻松复现整个应用的状态演变。
- 便于扩展 —— 当交互逻辑变复杂时,一个语义明确的 action 更容易维护。
总结:
- 把 reducer 想象成 状态的计算机,输入
state+action,输出newState,仅此而已。 - 把 action 想象成 用户的操作日志,它描述了“发生了什么”,而不是“怎么改”。
使用 Immer 简化 Reducer
在编写 reducer 时,一个关键原则是 不可变性:我们不能直接修改原始的 state,而必须返回一个全新的副本。 然而,手动维护不可变更新有时会很繁琐,尤其是当 state 包含嵌套对象或复杂数组时,代码会显得冗长。
这时就可以引入 Immer。
为什么选择 Immer?
- 自动处理不可变性:你可以“看似”直接修改
state,实际上修改的是一个特殊的 draft(草稿)对象。 - 保持 reducer 的纯净:虽然代码写起来像是“可变”的,但 Immer 会在底层帮你生成一个全新的不可变
state。 - 简化复杂逻辑:比如修改数组中的某一项或对象的某个属性,避免手动写大量
...spread操作。
示例
import { useImmerReducer } from 'use-immer';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
function tasksReducer(draft, action) {
switch (action.type) {
case 'added': {
draft.push({
id: action.id,
text: action.text,
done: false,
});
break; // 不需要返回,Immer 会自动生成新的 state
}
case 'changed': {
const index = draft.findIndex((t) => t.id === action.task.id);
draft[index] = action.task;
break;
}
case 'deleted': {
return draft.filter((t) => t.id !== action.id);
// 这里返回新数组也没问题,Immer 会兼容
}
default: {
throw Error('未知 action:' + action.type);
}
}
}
export default function TaskApp() {
const [tasks, dispatch] = useImmerReducer(tasksReducer, initialTasks);
function handleAddTask(text) {
dispatch({ type: 'added', id: nextId++, text });
}
function handleChangeTask(task) {
dispatch({ type: 'changed', task });
}
function handleDeleteTask(taskId) {
dispatch({ type: 'deleted', id: taskId });
}
return (
<>
<h1>布拉格的行程安排</h1>
<AddTask onAddTask={handleAddTask} />
<TaskList
tasks={tasks}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
/>
</>
);
}
let nextId = 3;
const initialTasks = [
{ id: 0, text: '参观卡夫卡博物馆', done: true },
{ id: 1, text: '看木偶戏', done: false },
{ id: 2, text: '打卡列侬墙', done: false },
];优点:
更直观:像 draft.push()、draft[index] = ... 这样的写法接近普通的“可变”写法,更容易理解。
减少样板代码:不用再频繁写 map、filter、展开运算符来拷贝对象或数组。
保持 reducer 的纯净:虽然写起来像是“修改”,但 reducer 依然是纯函数,因为 Immer 在底层保证了不可变性。
使用 Context 深层传递参数
通常,我们通过 props 将信息从父组件传递到子组件。但当信息需要经过多个中间组件层层传递,或者应用中许多组件都需要相同的数据时,props 的使用会显得冗长且不便。Context 则允许父组件直接向任意深度的子组件提供数据,无需显式通过 props 一层层传递。
Context 允许父组件,甚至很远的祖先组件,为其内部的整个组件树提供数据,极大简化了跨层级的数据传递。
创建 Context 使用 React.createContext 创建一个 context,将其从一个文件中导出,例如:
// LevelContext.js
import { createContext } from 'react';
export const LevelContext = createContext(defaultValue);这里 LevelContext 表示标题的级别,你也可以根据需要命名。
一般来说,createContext 会放在一个 独立的文件 中,这样方便在整个项目中被引用和共享。例如:
// src/context/ThemeContext.js
import React from 'react';
const ThemeContext = React.createContext('light'); // 默认值为 'light'
export default ThemeContext;使用时,只需在组件里导入即可:
import ThemeContext from '../context/ThemeContext';在需要数据的组件中使用 Context
useContext 是一个 Hook,和 useState 或 useReducer 类似。你只能在 React 组件的顶层调用它,不能放在循环、条件或嵌套函数中。
它的作用是告诉 React,Heading 组件想要读取某个 Context(这里是 LevelContext)中的数据,从而让组件能够访问最近的父组件或祖先组件提供的值,而无需通过 props 一层层传递。
在子组件中,通过 useContext(函数组件)或者 Context.Consumer(类组件)来访问 context 中的数据。例如在 Heading 组件中:
import { useContext } from 'react';
import { LevelContext } from './LevelContext.js';
const level = useContext(LevelContext);
return <h{level}>标题</h{level}>;在父组件中提供 Context 使用 Context.Provider 将数据传递给下层组件树。例如在 Section 组件中:
import { LevelContext } from './LevelContext.js'
<LevelContext value={2}>
<Heading />
<SubSection />
</LevelContext>这样,Provider 内的整个组件树都可以访问 LevelContext 提供的数据,无需通过 props 层层传递。
提醒
创建 context 的目的就是 声明“有这样一个共享的数据”,它本身并不关心提供的值是什么,也不要求和 Provider 放在同一个文件或组件里。
Provider 才是真正 提供具体值 的地方,而 Consumer(或 useContext)才会去读取这个值。
所以,创建 context ≈ 声明数据类型和用途,提供 context ≈ 给它赋实际的值,两者可以分开管理。
Context 可以穿过中间层级的组件。你可以在提供 context 的组件和使用它的组件之间插入任意数量的组件,这包括原生元素(如 <div>)或自定义组件。
无论中间隔了多少层组件,使用 useContext 的组件都会从最近的提供该 Context 的父组件中读取数据;如果没有找到 Provider,则会使用创建 Context 时指定的默认值。
Context 让你可以创建“适应周围环境”的组件,根据它所在的位置(或者说所属的 context)来呈现不同的样式或行为。
它的工作方式有点像 CSS 的属性继承。比如,你可以给一个 <div> 设置 color: blue,那么其内部的所有 DOM 节点,无论嵌套多深,都会继承这个颜色,除非中间某个节点用 color: green 覆盖它。类似地,在 React 中,要覆盖上层 context 的值,唯一的方法是将子组件包裹在一个提供不同值的 Context.Provider 中。
另外,在 CSS 中,不同属性(如 color 和 background-color)不会互相覆盖;同样,不同的 React context 也彼此独立。每个通过 createContext() 创建的 context 都是独立的,只有使用或提供该 context 的组件才会关联它。这样,一个组件就可以同时使用或提供多个不同的 context,互不干扰。
使用 Context 的注意事项:
使用 Context 看起来非常诱人,但也正因为如此,它很容易被过度使用。如果只是想把一些 props 传递到多个层级中,这并不意味着一定要把这些信息放到 context 里。
在使用 Context 之前,可以考虑以下几种替代方案:
先从传递 props 开始 如果你的组件层级不算复杂,通过几层组件传递 props 并不罕见。虽然看起来有些繁琐,但这样可以让数据流向非常清晰,维护代码的人也能轻松理解哪些组件使用了哪些数据。
抽象组件并通过 children 传递 JSX 如果你需要通过很多中间组件传递数据,而这些中间组件本身并不使用这些数据,这通常意味着可以进行组件抽象。例如,你可能想把 posts 数据传给一个不会直接使用它的组件:
<Layout posts={posts} />更好的做法是将 children 作为参数,让 Layout 渲染它:
<Layout>
<Posts posts={posts} />
</Layout>这样可以减少数据源组件和数据使用组件之间的层级。
在以上方法都不合适时,再考虑使用 Context
Context 最适合解决跨越多个层级、在许多组件中都需要访问的数据。只有在确实存在这种场景时,才值得使用。
Context 的使用场景
- 主题(Theme) 如果你的应用允许用户切换外观(例如暗夜模式),可以在应用顶层放一个 context provider,然后在需要调整样式的组件中使用该 context。
- 当前账户(Current User) 许多组件可能需要访问当前登录用户的信息。将其放入 context,可以方便地在组件树中的任何位置读取。如果应用支持多账户操作(例如以不同用户身份发表评论),将某部分 UI 包裹在提供不同账户数据的 provider 中会非常方便。
- 路由(Routing) 大多数路由解决方案在内部使用 context 保存当前路由信息,这就是每个链接“知道”自己是否处于活动状态的方式。如果你自己实现路由库,也可以采用相同的模式。
- 状态管理(State Management) 随着应用变大,很多 state 会集中在靠近应用顶部的位置,而远层组件可能需要读取或修改它们。通常可以将 reducer 与 context 搭配使用,将复杂状态传递给深层组件,从而避免繁琐的 props 传递。
Context 不仅限于静态值。如果在下一次渲染时传递不同的值,React 会更新读取它的所有下层组件!这也是 context 经常与 state 一起使用的原因。
一般来说,当组件树中不同部分的远距离组件需要共享信息时,Context 会非常有用。
Context 与 State 的关系
- 相似点:
- Context 和 state 都会触发组件重新渲染。
- 当 context 提供的数据发生变化时,消费该 context 的下层组件会自动更新,就像 state 改变一样。
- 区别:
- 作用范围:state 是局部的,只影响定义它的组件及其子组件;context 则可以跨越组件树,供任意深度的子组件使用。
- 更新方式:通常通过更新 context 所包裹的 state 来修改 context 的值。
总结:Context 可以看作是跨越组件树的“共享 state”,当其值改变时,所有使用该 context 的组件都会响应更新。
结合使用 reducer 和 context
useReducer:用于集中管理复杂的状态逻辑。它接收一个 reducer 函数和初始状态,返回当前状态和一个dispatch函数。所有状态的更新都通过dispatch一个动作(action)来触发,使得状态变更逻辑更加可预测和统一。useContext:允许您在组件树中创建一个“数据隧道”,使得任何被 Provider 包裹的子组件,无论嵌套多深,都能直接访问 Provider 提供的共享数据,而无需通过 props 逐层传递。
通过将 useReducer 产生的状态和 dispatch 函数通过 useContext 机制向下传递,我们可以将状态逻辑与 UI 组件完全解耦,大大提升应用的可扩展性和可维护性。
一个整理后的实践示例:
import { createContext, useContext, useReducer } from 'react';
// 创建 Context
const TasksContext = createContext(null);
const TasksDispatchContext = createContext(null);
// Provider 组件
export function TasksProvider({ children }) {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
return (
<TasksContext.Provider value={tasks}>
<TasksDispatchContext.Provider value={dispatch}>
{children}
</TasksDispatchContext.Provider>
</TasksContext.Provider>
);
}
// 自定义 Hook:读取任务列表
export function useTasks() {
return useContext(TasksContext);
}
// 自定义 Hook:分发任务更新
export function useTasksDispatch() {
return useContext(TasksDispatchContext);
}
// Reducer 函数
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('Unknown action: ' + action.type);
}
}
}
// 初始任务列表
const initialTasks = [
{ id: 0, text: 'Philosopher’s Path', done: true },
{ id: 1, text: 'Visit the temple', done: false },
{ id: 2, text: 'Drink matcha', done: false }
];使用 TasksProvider 包裹应用
// App.js
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
import { TasksProvider } from './TasksContext.js';
export default function TaskApp() {
return (
<TasksProvider>
<h1>Day off in Kyoto</h1>
<AddTask />
<TaskList />
</TasksProvider>
);
}在组件中使用 context
import { useTasks, useTasksDispatch } from './TasksContext.js';
function SomeComponent() {
const tasks = useTasks(); // 获取任务列表
const dispatch = useTasksDispatch(); // 更新任务
// 示例:添加任务
const addTask = () => {
dispatch({ type: 'added', id: 3, text: 'New Task' });
};
}集中管理:reducer、context 和 provider 都在一个文件中,逻辑清晰。
干净组件:组件只负责显示内容,不关心数据来源。
自定义 Hook:useTasks 和 useTasksDispatch 简化了 context 的使用。
可拓展性强:随着应用增长,可以轻松添加更多 context + reducer 的组合。
灵活:任何组件都可以从 context 获取数据或分发更新,无需层层传递 props。
小提示:像 useTasks 和 useTasksDispatch 这样的函数叫 自定义 Hook,函数名以 use 开头,可以在函数内部使用其他 Hook(如 useContext)。