包含许多状态更新、并分散在多个事件处理函数中的组件,可能会变得令人不知所措。对于这类情况,你可以把所有状态更新逻辑集中到组件外部的一个单独函数中,这个函数称为 reducer。
You will learn
- 什么是 reducer 函数
- 如何将
useState重构为useReducer - 何时使用 reducer
- 如何把它写好
使用 reducer 整合状态逻辑
随着组件变得越来越复杂,一眼看清组件状态有哪些不同更新方式会变得更难。例如,下面的 TaskApp 组件在 state 中保存了一个 tasks 数组,并使用三个不同的事件处理函数来添加、删除和编辑任务:
import { useState } from 'react'; import AddTask from './AddTask.js'; import TaskList from './TaskList.js'; export default function TaskApp() { const [tasks, setTasks] = useState(initialTasks); 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)); } return ( <> <h1>布拉格行程</h1> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} onChangeTask={handleChangeTask} onDeleteTask={handleDeleteTask} /> </> ); } let nextId = 3; const initialTasks = [ {id: 0, text: '参观 Kafka 博物馆', done: true}, {id: 1, text: '观看木偶戏', done: false}, {id: 2, text: '列侬墙照片', done: false}, ];
它的每个事件处理函数都会调用 setTasks 来更新状态。随着组件不断增长,分散在其中的状态逻辑也会越来越多。为了降低这种复杂性,并把所有逻辑放到一个容易访问的地方,你可以把这些状态逻辑移到组件外部的一个单独函数中,这个函数称为“reducer”。
Reducer 是处理 state 的另一种方式。你可以通过三个步骤从 useState 迁移到 useReducer:
- 从设置 state 改为派发 actions。
- 编写 reducer 函数。
- 在组件中使用 reducer。
第 1 步:从设置 state 改为派发 actions
你当前的事件处理函数通过设置 state 来指定 要做什么:
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));
}移除所有设置 state 的逻辑后,剩下的就是三个事件处理函数:
handleAddTask(text)在用户按下“添加”时被调用。handleChangeTask(task)在用户切换任务状态或按下“保存”时被调用。handleDeleteTask(taskId)在用户按下“删除”时被调用。
使用 reducer 管理 state 和直接设置 state 略有不同。你不是通过事件处理函数告诉 React “要做什么”并设置 state,而是通过事件处理函数派发 “actions” 来说明“用户刚刚做了什么”。(状态更新逻辑会放在别处!)因此,与其通过事件处理函数“设置 tasks”,不如派发一个“添加/修改/删除任务”的 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”:
function handleDeleteTask(taskId) {
dispatch(
// “action” 对象:
{
type: 'deleted',
id: taskId,
}
);
}它就是一个普通的 JavaScript 对象。你可以决定往里面放什么,但通常它应该包含关于 发生了什么 的最少信息。(你会在后面的步骤中添加 dispatch 函数本身。)
第 2 步:编写 reducer 函数
reducer 函数就是你放置 state 逻辑的地方。它接收两个参数:当前 state 和 action 对象,然后返回下一个 state:
function yourReducer(state, action) {
// 返回 React 要设置的下一个 state
}React 会把 state 设置为 reducer 返回的值。
要把这个例子中的状态设置逻辑从事件处理函数迁移到 reducer 函数中,你需要:
- 将当前 state(
tasks)声明为第一个参数。 - 将
action对象声明为第二个参数。 - 从 reducer 返回 下一个 state(React 会把它设置为 state)。
下面是所有状态设置逻辑迁移到 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('Unknown action: ' + action.type);
}
}因为 reducer 函数将 state(tasks)作为参数,所以你可以把它声明在组件外部。 这样可以减少缩进层级,并让代码更易读。
Deep Dive
虽然 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 也是同样的思路:它接收 当前 state 和 action,并返回 下一个 state。这样一来,它们会随着时间把 action 累积为 state。
你甚至可以使用带有 initialState 和 actions 数组的 reduce() 方法,把 reducer 函数传给它来计算最终 state:
import tasksReducer from './tasksReducer.js'; let initialState = []; let actions = [ {type: 'added', id: 1, text: '参观 Kafka 博物馆'}, {type: 'added', id: 2, text: '观看木偶戏'}, {type: 'deleted', id: 1}, {type: 'added', id: 3, text: '列侬墙照片'}, ]; let finalState = actions.reduce(tasksReducer, initialState); const output = document.getElementById('output'); output.textContent = JSON.stringify(finalState, null, 2);
你大概不需要自己这样做,但这和 React 所做的事情很相似!
第 3 步:在组件中使用 reducer
最后,你需要将 tasksReducer 接到你的组件上。从 React 导入 useReducer Hook:
import { useReducer } from 'react';然后你就可以用 useReducer 替换 useState:
const [tasks, setTasks] = useState(initialTasks);像这样改成 useReducer:
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);useReducer Hook 和 useState 类似——你必须传入一个初始 state,它会返回一个有状态的值和一种设置 state 的方式(在这里就是 dispatch 函数)。但它又有一点不同。
useReducer Hook 接收两个参数:
- 一个 reducer 函数
- 一个初始 state
并返回:
- 一个有状态的值
- 一个 dispatch 函数(用于把用户 action “派发”给 reducer)
现在它已经完全连接好了!下面示例中,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('Unknown action: ' + action.type); } } } let nextId = 3; const initialTasks = [ {id: 0, text: '参观 Kafka 博物馆', done: true}, {id: 1, text: '观看木偶戏', done: false}, {id: 2, text: '列侬墙照片', done: false}, ];
如果你愿意,甚至可以把 reducer 移到另一个文件中:
import { useReducer } from 'react'; import AddTask from './AddTask.js'; import TaskList from './TaskList.js'; import tasksReducer from './tasksReducer.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} /> </> ); } let nextId = 3; const initialTasks = [ {id: 0, text: '参观 Kafka 博物馆', done: true}, {id: 1, text: '观看木偶戏', done: false}, {id: 2, text: '列侬墙照片', done: false}, ];
把关注点这样分离后,组件逻辑会更容易阅读。现在,事件处理函数只通过派发 actions 来说明 发生了什么,而 reducer 函数则决定如何根据这些 action 更新 state。
比较 useState 和 useReducer
reducers 也不是没有缺点!下面是一些可以比较的方面:
- 代码量: 通常来说,使用
useState时你一开始需要写的代码更少。使用useReducer时,你需要同时编写 reducer 函数和分发 action。不过,如果很多事件处理器以相似的方式修改状态,useReducer可以帮助减少代码量。 - 可读性: 当状态更新很简单时,
useState非常容易阅读。当它们变得更复杂时,它们可能会让组件代码膨胀,并且难以浏览。在这种情况下,useReducer可以让你清晰地把更新逻辑的 如何做 与事件处理器中的 发生了什么 分开。 - 调试: 当你使用
useState遇到 bug 时,可能很难判断状态是 在哪里 被错误地设置的,以及 为什么 会这样。使用useReducer时,你可以在 reducer 中添加 console log,查看每一次状态更新,以及它 为什么 发生(是由于哪个action)。如果每个action都是正确的,那么你就知道错误出在 reducer 逻辑本身。不过,与useState相比,你需要跟踪更多代码。 - 测试: reducer 是一个不依赖组件的纯函数。这意味着你可以把它导出,并在隔离环境中单独测试。虽然通常最好是在更真实的环境中测试组件,但对于复杂的状态更新逻辑,断言 reducer 在某个初始状态和 action 下返回特定状态会很有用。
- 个人偏好: 有些人喜欢 reducer,有些人不喜欢。没关系,这只是偏好问题。你总是可以在
useState和useReducer之间来回转换:它们是等价的!
如果你经常在某个组件中遇到由于状态更新不正确而导致的 bug,并且希望给代码引入更多结构,我们建议使用 reducer。你不必把 reducer 用在所有地方:可以自由混用!你甚至可以在同一个组件中同时使用 useState 和 useReducer。
编写良好的 reducer
在编写 reducer 时,请记住这两个提示:
- reducer 必须是纯函数。 与 state updater 函数 类似,reducer 会在渲染期间运行!(action 会被排队,直到下一次渲染。)这意味着 reducer 必须是纯函数——相同的输入总是得到相同的输出。它们不应该发送请求、设置超时,或执行任何副作用(即影响组件外部内容的操作)。它们应该在不变异的情况下更新 对象 和 数组。
- 每个 action 描述一次单独的用户交互,即使这会导致数据发生多处变化。 例如,如果用户在一个由 reducer 管理的、包含五个字段的表单中点击“重置”,分发一个
reset_formaction 比分发五个独立的set_fieldaction 更合理。如果你在 reducer 中记录每个 action,这些日志应该足够清晰,让你能够重建交互或响应按什么顺序发生。这有助于调试!
使用 Immer 编写更简洁的 reducer
就像在普通状态中更新对象和更新数组一样,你也可以使用 Immer 库让 reducer 更简洁。这里,useImmerReducer 让你可以通过 push 或 arr[i] = 赋值来变异状态:
{ "dependencies": { "immer": "1.7.3", "react": "latest", "react-dom": "latest", "react-scripts": "latest", "use-immer": "0.5.1" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test --env=jsdom", "eject": "react-scripts eject" }, "devDependencies": {} }
reducer 必须是纯函数,所以它们不应该变异状态。但 Immer 提供了一个特殊的 draft 对象,可以安全地对其进行变异。在底层,Immer 会根据你对 draft 所做的更改创建一份状态副本。这就是为什么由 useImmerReducer 管理的 reducer 可以变异第一个参数,并且不需要返回 state。
Recap
- 要从
useState转换到useReducer:- 在事件处理器中分发 action。
- 编写一个 reducer 函数,它根据给定的 state 和 action 返回下一个 state。
- 用
useReducer替换useState。
- reducer 需要你多写一点代码,但它们有助于调试和测试。
- reducer 必须是纯函数。
- 每个 action 描述一次单独的用户交互。
- 如果你想用变异式风格编写 reducer,就使用 Immer。
Challenge 1 of 4: 从事件处理器中分发 action
目前,ContactList.js 和 Chat.js 中的事件处理器都有 // TODO 注释。这就是为什么在输入框中输入不起作用,以及点击按钮不会改变所选收件人。
用用于 dispatch 对应 action 的代码替换这两个 // TODO。要查看 action 的预期形状和类型,请检查 messengerReducer.js 中的 reducer。reducer 已经写好了,所以你不需要修改它。你只需要在 ContactList.js 和 Chat.js 中分发这些 action。
import { useReducer } from 'react'; import Chat from './Chat.js'; import ContactList from './ContactList.js'; import { initialState, messengerReducer } from './messengerReducer'; export default function Messenger() { const [state, dispatch] = useReducer(messengerReducer, initialState); const message = state.message; const contact = contacts.find((c) => c.id === state.selectedId); return ( <div> <ContactList contacts={contacts} selectedId={state.selectedId} dispatch={dispatch} /> <Chat key={contact.id} message={message} contact={contact} dispatch={dispatch} /> </div> ); } const contacts = [ {id: 0, name: 'Taylor', email: 'taylor@mail.com'}, {id: 1, name: 'Alice', email: 'alice@mail.com'}, {id: 2, name: 'Bob', email: 'bob@mail.com'}, ];