React 状态管理和脱围机制
约 6924 字大约 23 分钟
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 在底层保证了不可变性。
贡献者
更新日志
85b18
-doc update于7eec2
-Document organization于9de0b
-全局优化于b1c4a
-文档迁移于