将状态逻辑提取到 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”

Reducers 是一种处理状态的不同方式。您可以通过三个步骤从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 函数是您放置状态逻辑的地方。它接受两个参数:当前状态和 action 对象,并返回下一个状态。

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 同样遵循这个理念:它们接收目前的 stateaction,并返回下一个 state。通过这种方式,它们随着时间的推移将 action 累积到 state 中。

您甚至可以使用 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},
];

将组件逻辑分离成不同的关注点,可以更容易阅读。现在,事件处理器只通过分发action来指定发生了什么,而reducer函数则确定如何响应这些action来更新状态

比较 useStateuseReducer

Reducer并非没有缺点!以下是一些比较它们的方法。

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

如果由于某个组件中状态更新不正确而经常遇到bug,并且想要为其代码引入更多结构,我们建议使用reducer。你不需要在所有情况下都使用reducer:可以随意混合和匹配!你甚至可以在同一个组件中使用useStateuseReducer

编写良好的reducer

编写reducer时,请记住以下两点提示。

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

使用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. 从事件处理器分发action。
    2. 编写一个reducer函数,该函数根据给定的状态和action返回下一个状态。
    3. useState替换为useReducer
  • Reducer 需要你编写更多代码,但它有助于调试和测试。
  • Reducer 必须是纯函数。
  • 每个 action 都描述单个用户交互。
  • 如果你想以可变的方式编写 reducer,可以使用 Immer。

挑战 1 4:
从事件处理程序分派 action

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

将这两个// TODO替换为dispatch相应 action 的代码。要查看 action 的预期形状和类型,请检查messengerReducer.js中的 reducer。reducer 已经编写好了,所以你不需要更改它。你只需要在ContactList.jsChat.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]'},
];