组件中许多状态更新分散在许多事件处理程序中,可能会变得难以管理。对于这些情况,您可以将所有状态更新逻辑整合到组件外部的单个函数中,称为reducer。
你将学习
- 什么是 reducer 函数
- 如何将
useState
重构为useReducer
- 何时使用 reducer
- 如何编写一个好的 reducer
使用 reducer 整合状态逻辑
随着组件复杂性的增加,快速查看组件状态更新的所有不同方式可能会变得越来越困难。例如,下面的TaskApp
组件在状态中保存一个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>Prague itinerary</h1> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} onChangeTask={handleChangeTask} onDeleteTask={handleDeleteTask} /> </> ); } let nextId = 3; const initialTasks = [ {id: 0, text: 'Visit Kafka Museum', done: true}, {id: 1, text: 'Watch a puppet show', done: false}, {id: 2, text: 'Lennon Wall pic', done: false}, ];
它的每个事件处理程序都调用setTasks
来更新状态。随着这个组件的增长,分散在其中的状态逻辑量也在增加。为了减少这种复杂性并将所有逻辑保存在一个易于访问的地方,您可以将该状态逻辑移动到组件外部的单个函数中,称为“reducer”。
Reducers 是一种处理状态的不同方式。您可以通过三个步骤从useState
迁移到useReducer
- 移动从设置状态到分发操作。
- 编写一个 reducer 函数。
- 使用组件中的 reducer。
步骤 1:从设置状态迁移到分发操作
你的事件处理程序目前通过设置状态来指定做什么。
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 管理状态与直接设置状态略有不同。你无需通过设置状态来告诉 React“做什么”,而是通过从事件处理程序分发“操作”来指定“用户刚刚做了什么”。(状态更新逻辑将在其他地方!)因此,你无需通过事件处理程序“设置tasks
”,而是分发“添加/更改/删除任务”操作。这更能描述用户的意图。
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
的对象称为“操作”。
function handleDeleteTask(taskId) {
dispatch(
// "action" object:
{
type: 'deleted',
id: taskId,
}
);
}
它是一个普通的 JavaScript 对象。你决定在其中放入什么内容,但通常它应该包含关于发生了什么的最小信息。(你将在后面的步骤中添加dispatch
函数本身。)
步骤 2:编写 reducer 函数
reducer 函数是您放置状态逻辑的地方。它接受两个参数:当前状态和 action 对象,并返回下一个状态。
function yourReducer(state, action) {
// return next state for React to set
}
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('Unknown action: ' + action.type);
}
}
因为 reducer 函数将状态 (tasks
) 作为参数,所以您可以在组件外部声明它。这减少了缩进级别,并使您的代码更易于阅读。
深入探讨
尽管 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 中。
您甚至可以使用 reduce()
方法以及 initialState
和 actions
数组,通过将您的 reducer 函数传递给它来计算最终状态。
import tasksReducer from './tasksReducer.js'; let initialState = []; let actions = [ {type: 'added', id: 1, text: 'Visit Kafka Museum'}, {type: 'added', id: 2, text: 'Watch a puppet show'}, {type: 'deleted', id: 1}, {type: 'added', id: 3, text: 'Lennon Wall pic'}, ]; 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';
然后,您可以替换 useState
const [tasks, setTasks] = useState(initialTasks);
为 useReducer
,如下所示:
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
useReducer
Hook 类似于 useState
——您必须传递初始状态,它返回一个有状态的值和一种设置状态的方法(在本例中为 dispatch 函数)。但它略有不同。
useReducer
Hook 接受两个参数:
- 一个 reducer 函数
- 一个初始状态
它返回:
- 一个有状态的值
- 一个 dispatch 函数(用于将用户操作“分派”到 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>Prague itinerary</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: 'Visit Kafka Museum', done: true}, {id: 1, text: 'Watch a puppet show', done: false}, {id: 2, text: 'Lennon Wall pic', 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>Prague itinerary</h1> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} onChangeTask={handleChangeTask} onDeleteTask={handleDeleteTask} /> </> ); } let nextId = 3; const initialTasks = [ {id: 0, text: 'Visit Kafka Museum', done: true}, {id: 1, text: 'Watch a puppet show', done: false}, {id: 2, text: 'Lennon Wall pic', done: false}, ];
将组件逻辑分离成不同的关注点,可以更容易阅读。现在,事件处理器只通过分发action来指定发生了什么,而reducer函数则确定如何响应这些action来更新状态。
比较 useState
和 useReducer
Reducer并非没有缺点!以下是一些比较它们的方法。
- 代码大小:通常,使用
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必须是纯函数。与状态更新函数类似,reducer在渲染期间运行!(action会排队,直到下一次渲染。)这意味着reducer必须是纯函数——相同的输入总是产生相同的输出。它们不应该发送请求、调度超时或执行任何副作用(影响组件外部内容的操作)。它们应该更新对象和数组,而无需进行变异。
- 每个action都描述单个用户交互,即使这会导致数据发生多处更改。例如,如果用户在一个由reducer管理的包含五个字段的表单上按下“重置”,则分发一个
reset_form
action 比分发五个单独的set_field
action 更合理。如果你在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可以改变它们的第一个参数,并且不需要返回值的原因。
总结
- 从
useState
转换为useReducer
- 从事件处理器分发action。
- 编写一个reducer函数,该函数根据给定的状态和action返回下一个状态。
- 将
useState
替换为useReducer
。
- Reducer 需要你编写更多代码,但它有助于调试和测试。
- Reducer 必须是纯函数。
- 每个 action 都描述单个用户交互。
- 如果你想以可变的方式编写 reducer,可以使用 Immer。
挑战 1的 4: 从事件处理程序分派 action
目前,ContactList.js
和Chat.js
中的事件处理程序带有// TODO
注释。这就是为什么输入内容不起作用,以及点击按钮不会更改所选收件人的原因。
将这两个// TODO
替换为dispatch
相应 action 的代码。要查看 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: '[email protected]'}, {id: 1, name: 'Alice', email: '[email protected]'}, {id: 2, name: 'Bob', email: '[email protected]'}, ];