将状态逻辑提取到 Reducer 中

在多个事件处理程序中分散了多个状态更新的组件可能会变得难以理解。对于这些情况,您可以将组件外部的所有状态更新逻辑整合到一个称为*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”。**

Reducer 是处理状态的另一种方式。您可以通过三个步骤从 useState 迁移到 useReducer

  1. **移动**从设置状态到分派操作。
  2. **编写** reducer 函数。
  3. **使用**组件中的 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 函数本身。)

注意

操作对象可以有任何形状。

按照惯例,通常会给它一个字符串 type 来描述发生了什么,并在其他字段中传递任何其他信息。 type 特定于组件,因此在本例中, 'added''added_task' 都可以。选择一个能够说明发生了什么的名字!

dispatch({
// specific to component
type: 'what_happened',
// other fields go here
});

步骤 2:编写 reducer 函数

reducer 函数是您放置状态逻辑的地方。它有两个参数,当前状态和操作对象,并返回下一个状态。

function yourReducer(state, action) {
// return next state for React to set
}

React 会将状态设置为您从 reducer 中返回的内容。

在这个例子中,要将状态设置逻辑从事件处理程序移至 reducer 函数,您需要:

  1. 将当前状态(tasks)声明为第一个参数。
  2. action 对象声明为第二个参数。
  3. 从 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)作为参数,您可以在组件外部声明它。这会降低缩进级别,并使您的代码更易于阅读。

注意

上面的代码使用 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('Unknown action: ' + action.type);
}
}
}

我们建议将每个 case 块包装在 {} 花括号中,以便在不同 case 中声明的变量不会相互冲突。此外,case 通常应以 return 结束。如果您忘记了 return,代码将“穿透”到下一个 case,这可能会导致错误!

如果您还不熟悉 switch 语句,使用 if/else 完全没问题。

深入探讨

为什么 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 累积到状态中。

您甚至可以使用 reduce() 方法以及 initialStateactions 数组来计算最终状态,方法是将 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 接受两个参数

  1. 一个 reducer 函数
  2. 一个初始状态

它返回

  1. 一个有状态值
  2. 一个 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},
];

当您像这样分离关注点时,组件逻辑更易于阅读。现在,事件处理程序仅通过调度操作来指定_发生了什么_,而 reducer 函数确定_状态如何更新_以响应它们。

比较 useStateuseReducer

Reducer 并非没有缺点!您可以通过以下几种方式比较它们

  • 代码大小:通常,使用 useState,您需要编写的代码更少。使用 useReducer,您必须编写 reducer 函数_和_调度操作。但是,如果许多事件处理程序以类似的方式修改状态,则 useReducer 可以帮助减少代码量。
  • 可读性:当状态更新很简单时,useState 非常易于阅读。当它们变得更加复杂时,它们会使组件的代码膨胀,并使其难以扫描。在这种情况下,useReducer 允许您清晰地将更新逻辑的_方式_与事件处理程序的_发生了什么_分开。
  • 调试:当你在使用 useState 遇到错误时,很难判断状态是在哪里被错误设置的,以及为什么。使用 useReducer,你可以在 reducer 中添加一个控制台日志,以查看每次状态更新以及它发生的原因(由于哪个 action)。如果每个 action 都是正确的,你就会知道错误出在 reducer 逻辑本身。但是,你必须比使用 useState 单步执行更多代码。
  • 测试:Reducer 是一个不依赖于组件的纯函数。这意味着你可以单独导出并测试它。虽然通常最好在更真实的环境中测试组件,但对于复杂的状态更新逻辑,断言 reducer 对于特定的初始状态和操作返回特定的状态是很有用的。
  • 个人偏好:有些人喜欢 reducer,有些人不喜欢。这没关系。这是一个偏好问题。你总是可以在 useStateuseReducer 之间来回转换:它们是等效的!

如果你经常因为某些组件中的状态更新错误而遇到错误,并且希望为其代码引入更多结构,我们建议使用 reducer。你不必对所有事情都使用 reducer:随意混合搭配!你甚至可以在同一个组件中同时使用 useStateuseReducer

编写良好的 reducer

编写 reducer 时请记住以下两点

  • Reducer 必须是纯函数。状态更新函数 类似,reducer 在渲染期间运行!(操作会排队,直到下一次渲染。)这意味着 reducer 必须是纯函数——相同的输入总是产生相同的输出。它们不应发送请求、安排超时或执行任何副作用(影响组件外部事物的操作)。它们应该更新 对象数组 而不会发生突变。
  • 每个操作描述一个用户交互,即使这会导致数据的多个更改。例如,如果用户按下由 reducer 管理的具有五个字段的表单上的“重置”,则调度一个 reset_form 操作比五个单独的 set_field 操作更有意义。如果你记录 reducer 中的每个操作,则该日志应该足够清晰,以便你重建以什么顺序发生的交互或响应。这有助于调试!

使用 Immer 编写简洁的 reducer

就像在常规状态下更新 对象数组 一样,你可以使用 Immer 库使 reducer 更简洁。在这里,useImmerReducer 允许你使用 pusharr[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
    1. 从事件处理程序中调度操作。
    2. 编写一个 reducer 函数,该函数返回给定状态和操作的下一个状态。
    3. useState 替换为 useReducer
  • Reducer 需要你编写更多代码,但它们有助于调试和测试。
  • Reducer 必须是纯函数。
  • 每个操作描述一个用户交互。
  • 如果要以可变样式编写 reducer,请使用 Immer。

挑战 1 4:
从事件处理程序中调度操作

目前,ContactList.jsChat.js 中的事件处理程序都有 // TODO 注释。这就是为什么在输入中键入内容不起作用,以及单击按钮不会更改所选收件人的原因。

用代码替换这两个 // TODO,以 dispatch 相应的操作。要查看操作的预期形状和类型,请查看 messengerReducer.js 中的 reducer。reducer 已经写好了,因此您不需要更改它。您只需要在 ContactList.jsChat.js 中分派操作。

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]'},
];