把State逻辑提取到一个Reducer

538 阅读8分钟

许多state更新逻辑分布在多个事件处理函数中,会使组件变的很大和难以维护。针对这个问题,把所有的state逻辑合并到一个reducer函数中,且这个reducer函数在组件外。

用一个reducer函数合并state逻辑

随着组件复杂性的增长,要想一目了然地看到组件所有的不同的state更新逻辑可能会变得越来越困难。例如,下面的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>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},
];

4.png

它的每个事件处理函数都调用setTasks来更新state。随着这个组件的功能增加,散布在其中的state更新逻辑的数量也在增长。为了减少这种复杂性,把所有state更新逻辑保存在一个易于管理的地方。把组件的所有的state更新逻辑提取到组件外的单个函数中,这个函数称为“reducer”。

reducer是处理state的另一种方式。通过三个步骤从useState迁移到useReducer:

  1. 把设置state函数替换为调度action。
  2. 写一个reducer函数。
  3. 使用组件中的reducer。

第一步:把设置state函数替换为调度action

事件处理函数设置state指定React要做什么:

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的逻辑后,剩下的是三个事件处理函数:

  • 当用户按下“添加”按钮时调用handleadtask (text)
  • 当用户编辑任务按下“保存”按钮时调用handleChangeTask(task)
  • 当用户按下“删除”按钮时调用handleDeleteTask(taskId)

用reducer管理state与直接设置state有一些不同。不是通过设置state来告诉React“做什么”,而是通过从事件处理程序调度“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”:

dispatch(
  // "action" object:
  {
    type: 'deleted',
    id: taskId,
  }
);

它是一个普通的JavaScript对象。你决定在里面放什么,但一般来说,它应该是发生的事情的最小信息。

第二步:写一个reducer函数

一个reducer函数是存放state逻辑的地方。它接受两个参数,当前state和动作对象,并返回下一个state:

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

React将state的值设置为从reducer返回的state值。

把state逻辑提取到一个reducer函数,reducer函数需要:

  1. 声明当前的state为第一个参数。
  2. 声明action为第二个参数。
  3. 返回一个state,React把state的值设置为这个返回的state值。

下面是把所有的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作为参数,所以可以在组件外部声明reducer函数。这减少了缩进级别,可以使代码更容易阅读。

第三步:使用组件中的reducer

最后,需要将tasksReducer连接到组件。从React中导入useReducer钩子:

import { useReducer } from 'react';

然后替换useState:

const [tasks, setTasks] = useState(initialTasks);

使用useReducer:

const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

useReducer Hook类似于usestate——传递给它一个初始状态,它返回一个有状态的值和设置状态的方法(有点不同的是在useReduce中返回的是调度函数)。

useReducer钩子接受两个参数:

  1. 一个reducer函数
  2. 一个初始state值

返回:

  1. 一个有状态的值
  2. 一个调度函数,调度reducer的actions

现在它已经完全连接好了!这里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移到另一个文件:

App.js

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

taskReducer.js

export default 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);
    }
  }
}

像这样分离关注点时,组件逻辑可以更容易阅读。现在,事件处理程序只通过分派动作来指定发生了什么,而reducer函数决定状态如何更新以响应它们。

比较useState和useReducer

reducer并非没有缺点!这里有一些方法比较它们:

  • 代码量的大小。 一般情况下useState比useReducer的代码量小。使用useReducer,你需要额外写reducer函数和调度的actions。但是,如果许多事件处理程序以相似的方式修改state,useReducer可以帮助减少代码量。

  • 可读性。 当状态更新很简单时,使用useState代码非常容易阅读。当状态更新变得更复杂时,它们会使组件的代码膨胀,使其难以阅读。在这种情况下,useReducer可以清晰地将更新逻辑与事件处理分开。

  • 调试。 当useState出现错误时,很难判断在哪个地方设置状态出现了错误,以及什么行为导致了错误。使用useReducer,可以在reducer中添加一个控制台日志,能够看到每个state的更新以及是什么行为触发的(根据是哪个action)。如果行为和触发action一致,那么就会知道错误出在reducer逻辑本身。但是,与使用useState相比,useReducer调试时需要遍历更多的代码。

  • 测试。 reducer是一个不依赖于组件的纯函数。可以单独导出和测试它。虽然通常最好在更现实的环境中测试组件,但对于复杂的状态更新逻辑,根据初始state值和action,断言reducer返回的state,可能会很有用。

  • 个人喜好。 有些人喜欢reducer,有些人不喜欢。没关系。这是个人喜好的问题。总是可以在useState和useReducer之间来回转换:它们是等价的!

写好reducer

在编写reducer时,请记住以下两点:

  • reducers是纯函数。 与更新state函数类似,reducer在渲染过程中运行!(action排队等待直到下一次渲染。)这也意味着reducers必须是纯函数。在reducers函数里面不能发送网络请求,应用timeouts,或是执行其他的任何副作用(影响组件之外的操作)。把objects和array看作不改变数据,不能直接修改。

  • 每个action描述了用户的一个交互,即使可能改变会多个数据。 例如一个表单中有5个字段,“reset”按钮用于重新设置这5个字段的值。分派一个reset_form action比分派五个单独的set_field action更有意义。在reducer定义了每一个动作,就很清晰能看到有什么交互,对每一个交互做了什么响应以及交互以什么顺序进行的。

使用Immer简化逻辑

就像更新对象和数组一样,Immer库使reducer更简洁。使用useImmerReducer允许你使用pusharr[i] = assignment来改变state:

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;
    }
    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);
    }
    default: {
      throw Error('Unknown action: ' + action.type);
    }
  }
}

export default function TaskApp() {
  const [tasks, dispatch] = useImmerReducer(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},
];

reducers必须是纯函数,不能直接改变state。但是Immer提供了一个特殊的草稿对象,可以安全地对草稿对象修改。在底层,Immer依据草稿对象的改变创建一个state副本。这就是为什么由useImmerReducer管理的reducer可以改变它们的第一个参数,而不需要返回state。

参考文献: Extracting State Logic into a Reducer