Dark Dwarf Blog background

React Reduce

React Reduce

1. 引入

在 React 中,useState 是处理组件状态最常用的 Hook。但随着组件逻辑变得复杂,状态更新散布在多个事件处理函数中,会导致组件变得臃肿且难以维护。

想象一个管理任务列表的组件,它需要处理添加、删除和编辑任务。如果使用 useState,代码可能如下:

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

  function handleAddTask(text) {
    setTasks([...tasks, { id: nextId++, text: text, done: false }]);
  }

  function handleChangeTask(task) {
    setTasks(tasks.map(t => t.id === task.id ? task : t));
  }

  function handleDeleteTask(taskId) {
    setTasks(tasks.filter(t => t.id !== taskId));
  }
  // ...
}

当状态更新逻辑越来越多时,这些函数会让组件本身变得非常庞大。所有这些 setTasks 调用都分散在不同的事件处理器中,使得追踪状态变化变得困难。

useReducer 为我们提供了一种更强大、更结构化的方式来管理复杂的状态。

2. Reducer 模式

a.a. 基本概念

Reducer 模式旨在将**“发生了什么”(用户的操作)与“状态如何更新”**(业务逻辑)分离开来。

Reducer 是一个纯函数,它接收两个参数:当前的状态 (state) 和一个 action 对象,然后返回下一个新的状态

(currentState, action) => nextState

b.b. Action

Action 是一个普通的 JavaScript 对象,用于描述用户执行的操作。按照约定,它通常包含一个 type 属性(一个字符串,描述操作类型)和一个可选的 payload 属性(携带操作所需的数据)。

// 一个描述“添加任务”的 action
{ type: 'added_task', payload: { text: '学习 useReducer' } }

// 一个描述“删除任务”的 action
{ type: 'deleted_task', payload: { id: 123 } }

c.c. Reducer 工作流程

Reducer 的工作流程总体包含三步:Dispatch → Reducer → New State。具体如下:

  1. Dispatch (分发): 在组件中,当用户执行某个操作时(如点击按钮),会调用一个名为 dispatch 的函数,并将一个 action 对象传递给它。
  2. Reducer (处理): React 接收到 dispatchaction 后,会调用提供的 reducer 函数,并将当前的 state 和这个 action 作为参数传入。
  3. New State (新状态): reducer 函数根据 action.type 执行相应的逻辑,计算并返回一个新的状态。
  4. Re-render (重渲染): React 检测到状态已更新,会使用新的状态重新渲染组件及其子组件。

d.d. Reducer 模式的优点

Reducer 模式具有如下的优点:

  1. 集中化与可预测性: 所有状态更新逻辑都集中在 reducer 中,使得状态的流转清晰、可预测,极大地简化了调试过程。
  2. 提升可测试性: Reducer 是纯函数,不依赖 React,可以被独立地导出和测试。你可以轻松地为每种 action 编写单元测试,确保逻辑的正确性。
  3. 优化深层组件的性能: dispatch 函数的身份在组件的整个生命周期内是稳定不变的。当你通过 props 或 Context 将 dispatch 传递给深层子组件时,它不会像 useStatesetState 函数(除非用 useCallback 包裹)那样在每次重渲染时都创建新的函数,从而可以避免子组件不必要的重渲染。
  4. 代码分离: 你可以将 reducer 逻辑完全移出组件文件,使组件代码更专注于视图,更加简洁。

3. useReducer Hook

a.a. 基本概念

useReducer 是 React 提供的内置 Hook,用于在组件中使用 reducer 模式。

useReducer 接收一个 reducer 函数和一个初始状态,返回一个包含当前状态和 dispatch 函数的数组。

const [state, dispatch] = useReducer(reducer, initialState);

b.b. 使用 useReducer 重构

让我们将前面的 TaskManager 组件重构为使用 useReducer

  1. 编写 Reducer 函数:将所有状态逻辑整合到一个 reducer 函数中。通常使用 switch 语句来处理不同的 action.type
// reducers/taskReducer.js
export function taskReducer(tasks, action) {
  switch (action.type) {
    case 'added': {
      return [...tasks, { id: action.id, text: action.text, done: false }];
    }
    case 'changed': {
      return tasks.map(t => t.id === action.task.id ? action.task : t);
    }
    case 'deleted': {
      return tasks.filter(t => t.id !== action.id);
    }
    default: {
      throw new Error('未知的 action 类型: ' + action.type);
    }
  }
}
  1. 在组件中使用 useReducer:用 useReducer 替换 useState,并用 dispatch(action) 替换所有的 setTasks 调用。
// TaskManager.js
import { useReducer } from 'react';
import { taskReducer } from './reducers/taskReducer';

let nextId = 3;
const initialTasks = [/* ... */];

function TaskManager() {
  const [tasks, dispatch] = useReducer(taskReducer, 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 });
  }
  // ...
}

现在,组件本身只负责“分发指令”,而不再关心“状态如何具体更新”。

c.c. 惰性初始化

如果初始状态的计算成本很高,useReducer 允许我们传入第三个参数:一个 init 函数。这样,初始状态只会在组件初次渲染时计算一次。

function createInitialState(username) {
  // ... 执行一些昂贵的计算 ...
  return { user: username, tasks: [] };
}

// React 将会调用 createInitialState('Taylor') 来获取初始状态
const [state, dispatch] = useReducer(reducer, 'Taylor', createInitialState);

4. Action Creator 模式

随着应用变得复杂,直接在组件中编写 action 对象(如 { type: 'added', ... })会带来一些问题:

  1. 重复劳动: 如果多个地方都需要添加任务,你就需要多次编写相同的 action 对象结构。
  2. 容易出错: 手动编写 type 字符串和 payload 结构,很容易因拼写错误或结构遗漏而出错。
  3. 关注点耦合: 组件被迫了解 action 的内部结构,这违反了“逻辑与视图分离”的原则。

Action Creator 模式就是为了解决这些问题而生的。它是一个简单的函数,其唯一目的就是创建并返回一个 action 对象。

一个 Action Creator 的简单示例如下:

// actionCreators.js

// addTask 是一个 Action Creator
export function addTask(text) {
  return {
    type: 'added',
    payload: { text: text }
  };
}

// deleteTask 是另一个 Action Creator
export function deleteTask(taskId) {
  return {
    type: 'deleted',
    payload: { id: taskId }
  };
}

使用它之后,组件中的 dispatch 调用就变得非常清晰和声明式:

import { addTask, deleteTask } from './actionCreators';

// ... 在组件内部
dispatch(addTask('学习 Action Creator'));
dispatch(deleteTask(123));

Action Creator 模式带来了下面的好处:

  1. 彻底解耦: 组件现在只需调用一个函数,完全无需关心 actiontype 是什么,payload 的结构是怎样的。
  2. 简化与复用: 组件的调用代码变得极其干净和易读。Action Creator 可以在应用中的任何地方被复用。
  3. 易于维护: 如果未来需要修改某个 action 的结构(比如为“添加任务”增加一个 priority 属性),你只需要在对应的 Action Creator 函数中修改一处即可,所有使用它的组件都会自动生效。
  4. 逻辑内聚: 我们可以将一些辅助逻辑(如 ID 生成)也封装起来。例如,我们可以改进 Reducer,让它自己管理 ID,这样 Action Creator 甚至都不需要传递 ID:
// reducers/taskReducer.js (改进后)
let nextId = 3; // ID 生成逻辑内聚在 reducer 模块中
export function taskReducer(tasks, action) {
  switch (action.type) {
    case 'added': {
      // Action Creator 只需提供 text
      return [...tasks, { id: nextId++, text: action.text, done: false }];
    }
    // ... 其他 case
  }
}

5. useReducer + useContext

当需要管理跨多个组件的全局状态时,useReduceruseContext 是一对黄金搭档。结合上一章的 Action Creator 模式,我们可以构建出一套不依赖第三方库、兼具结构清晰和高性能的轻量级全局状态管理方案。

a.a. 设计目标

  1. 逻辑与视图分离: 状态更新逻辑(Reducer)与 Action 创建逻辑(Action Creators)应与 UI 组件完全解耦。
  2. 全局访问: 应用中任何层级的组件都可以轻松地读取状态或触发状态更新,而无需通过繁琐的 props 钻探。
  3. 性能优化: 一个组件应该只在其真正关心的数据发生变化时才重渲染。特别是,一个只负责触发更新(调用 Action Creators)的组件,不应该因为状态的改变而重渲染。

b.b. 具体实现

我们将通过一个任务管理器的例子,一步步构建这个模式。

  1. 定义 Reducer (业务逻辑核心):我们使用在上一章改进过的 Reducer,它自己管理 ID 的生成。
// reducers/taskReducer.js
let nextId = 3;
export function taskReducer(tasks, action) {
  switch (action.type) {
    case 'added': {
      return [...tasks, { id: nextId++, text: action.text, done: false }];
    }
    case 'changed': {
      return tasks.map(t => t.id === action.task.id ? action.task : t);
    }
    case 'deleted': {
      return tasks.filter(t => t.id !== action.id);
    }
    default: {
      throw new Error('未知的 action 类型: ' + action.type);
    }
  }
}
  1. 创建两个独立的 Context:这是性能优化的关键,我们创建下面的 Context:
  • 一个 Context 用于传递状态 (state) 本身。
  • 另一个 Context 用于传递封装后的 Action Creators
// contexts/TasksContext.js
import { createContext } from 'react';

export const TasksContext = createContext(null);       // 用于 state
export const TasksDispatchContext = createContext(null); // 用于 actions
  1. 封装 Provider 和 Action Creators:我们创建一个 Provider 组件,它内部调用 useReducer,然后创建一组与 dispatch 绑定的 Action Creator 函数。为了性能,这个函数对象通过 useMemo 进行缓存。
// contexts/TasksContext.js (续)
import { useReducer, useMemo, useContext } from 'react';
import { taskReducer } from '../reducers/taskReducer';

const initialTasks = [
    { id: 0, text: '参观卡夫卡博物馆', done: true },
    { id: 1, text: '看木偶戏', done: false },
    { id: 2, text: '列侬墙', done: false },
];

export function TasksProvider({ children }) {
  const [tasks, dispatch] = useReducer(taskReducer, initialTasks);

  // 使用 useMemo 封装 Action Creators,保证其引用稳定
  const actions = useMemo(() => ({
    addTask: (text) => {
      dispatch({ type: 'added', text: text });
    },
    changeTask: (task) => {
      dispatch({ type: 'changed', task: task });
    },
    deleteTask: (taskId) => {
      dispatch({ type: 'deleted', id: taskId });
    }
  }), [dispatch]); // dispatch 的引用是永久稳定的

  return (
    <TasksContext.Provider value={tasks}>
      <TasksDispatchContext.Provider value={actions}>
        {children}
      </TasksDispatchContext.Provider>
    </TasksContext.Provider>
  );
}
  1. 创建自定义消费 Hooks:使用自定义 Hook 让组件消费 Context 更方便,并包含健壮性检查。
// contexts/TasksContext.js (续)
export function useTasks() {
  return useContext(TasksContext);
}

export function useTaskActions() {
  return useContext(TasksDispatchContext);
}

c.c. 使用组件

现在,组件可以从 Context 中获取状态和已经封装好的 Action,代码变得非常简洁。下面是一些使用的例子:

  1. AddTask.js:只消费 useTaskActions,因此在状态变化时不会重渲染。
import { useState } from 'react';
import { useTaskActions } from './contexts/TasksContext';

export default function AddTask() {
  const [text, setText] = useState('');
  const { addTask } = useTaskActions(); // 获取封装好的 addTask 函数

  return (
    <>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <button onClick={() => {
        setText('');
        addTask(text); // 直接调用,无需关心实现细节
      }}>添加</button>
    </>
  );
}
  1. TaskList.jsTask.jsTaskList 消费 useTasks 来渲染列表,Task 组件消费 useTaskActions 来处理单个任务的变更和删除。
// TaskList.js
import { useTasks } from './contexts/TasksContext';
import Task from './Task';

export default function TaskList() {
  const tasks = useTasks();
  return (
    <ul>
      {tasks.map((task) => (
        <li key={task.id}>
          <Task task={task} />
        </li>
      ))}
    </ul>
  );
}

// Task.js
import { useState } from 'react';
import { useTaskActions } from './contexts/TasksContext';

export default function Task({ task }) {
  const [isEditing, setIsEditing] = useState(false);
  const { changeTask, deleteTask } = useTaskActions();

  return (
    <label>
      <input
        type="checkbox"
        checked={task.done}
        onChange={e => changeTask({ ...task, done: e.target.checked })}
      />
      {isEditing ? (
        <>
          <input
            value={task.text}
            onChange={e => changeTask({ ...task, text: e.target.value })}
          />
          <button onClick={() => setIsEditing(false)}>保存</button>
        </>
      ) : (
        <>
          {task.text}
          <button onClick={() => setIsEditing(true)}>编辑</button>
        </>
      )}
      <button onClick={() => deleteTask(task.id)}>删除</button>
    </label>
  );
}