【React Hooks系列】useReducer

78 阅读10分钟

简介

我们知道,useState 是 React 的一个基础 Hook,允许我们在函数组件中存储状态。但随着应用逐渐复杂,我们发现 useState 在管理复杂的状态逻辑时显得有些力不从心。

这时,React 为我们提供的另一个更为强大的 Hook ——useReducer—— 可以帮助我们优雅地处理复杂状态。useReducer 允许我们使用 action 和 reducer 的方式来组织复杂的状态逻辑,使其变得更加清晰和模块化,弥补了 useState 的局限性。

useReducer 和 useState 非常相似,但是它可以让你把状态更新逻辑从事件处理函数中移动到组件外部。详情可以参阅 对比 useState 和 useReducer

使用参考

const [state, dispatch] = useReducer(reducer, initialArg, init?)
import { useReducer } from 'react';

function reducer(state, action) {
  // ...
}

function MyComponent() {
  const [state, dispatch] = useReducer(reducer, { age: 42 });
  // ...

useReducer接收三个参数

  1. reducer:用于更新 state 的纯函数。参数为 state 和 action,返回值是更新后的 state。state 与 action 可以是任意合法值。
  2. initialArg:用于初始化 state 的任意值。(如果有第三个参数即 init,那么初始化的 state 的值是 init 执行之后的结果,否则就是该值)。
  3. 可选参数 init:用于计算初始值的函数。如果存在,使用 init(initialArg) 的执行结果作为初始值,否则使用 initialArg

useReducer返回两个参数

useReducer 返回一个由两个值组成的数组:

  1. 当前的 state:初次渲染时,它是 init(initialArg) 或 initialArg (如果没有 init 函数)。
  2. dispatch 函数:用于更新 state 并触发组件的重新渲染。

注意事项

  • useReducer 是一个 Hook,所以只能在 组件的顶层作用域 或自定义 Hook 中调用,而不能在循环或条件语句中调用。如果你有这种需求,可以创建一个新的组件,并把 state 移入其中。

dispatch 函数

useReducer 返回的 dispatch 函数允许你更新 state 并触发组件的重新渲染。它需要传入一个 action 作为参数:

const [state, dispatch] = useReducer(reducer, { age: 42 });

function handleClick() {
  dispatch({ type: 'incremented_age' });
  // ...

React 会调用 reducer 函数以更新 state,reducer 函数的参数为当前的 state 与传递的 action。

参数

  • action:用户执行的操作。可以是任意类型的值。通常来说 action 是一个对象,其中 type 属性标识类型,其它属性携带额外信息。

返回值

dispatch 函数没有返回值。

注意事项

  1. dispatch 函数 是为下一次渲染而更新 state。因此在调用 dispatch 函数后读取 state 并不会拿到更新后的值,也就是说只能获取到调用前的值。(翻译一下:dispatch调用后,状态更新是异步的,因此立刻读取状态可能仍是旧的。)
  2. 如果你提供的新值与当前的 state 相同(使用 Object.is 比较),React 会 跳过组件和子组件的重新渲染,这是一种优化手段。虽然在跳过重新渲染前 React 可能会调用你的组件,但是这不应该影响你的代码。
  3. React 会批量更新 state。state 会在 所有事件函数执行完毕 并且已经调用过它的 set 函数后进行更新,这可以防止在一个事件中多次进行重新渲染。如果在访问 DOM 等极少数情况下需要强制 React 提前更新,可以使用 flushSync

举个例子

注意事项1:调用 dispatch 函数 不会改变当前渲染的 state:

function handleClick() {
  console.log(state.age);  // 42

  dispatch({ type: 'incremented_age' }); // 用 43 进行重新渲染
  console.log(state.age);  // 还是 42!

  setTimeout(() => {
    console.log(state.age); // 一样是 42!
  }, 5000);
}

这是因为 state 的行为和快照一样。更新 state 会使用新的值来对组件进行重新渲染,但是不会改变当前执行的事件处理函数里面 state 的值。

如果你需要获取更新后的 state,可以手动调用 reducer 来得到结果:

const action = { type: 'incremented_age' };
dispatch(action);

const nextState = reducer(state, action);
console.log(state);     // { age: 42 }
console.log(nextState); // { age: 43 }

基础用法

向组件添加 reducer

在组件的顶层作用域调用 useReducer 来创建一个用于管理状态(state)的 reducer。

import { useReducer } from 'react';

function reducer(state, action) {
  if (action.type === 'incremented_age') {
    return {
      age: state.age + 1
    };
  }
  throw Error('Unknown action.');
}

export default function Counter() {
  const [state, dispatch] = useReducer(reducer, { age: 42 });

  return (
    <>
      <button onClick={() => {
        dispatch({ type: 'incremented_age' })
      }}>
        Increment age
      </button>
      <p>Hello! You are {state.age}.</p>
    </>
  );
}

React 会把当前的 state 和这个 action 一起作为参数传给 reducer 函数,然后 reducer 计算并返回新的 state,最后 React 保存新的 state,并使用它渲染组件和更新 UI。

其中action 可以是任意类型,不过通常至少是一个存在 type 属性的对象。也就是说它需要携带计算新的 state 值所必须的数据。

action 的 type 依赖于组件的实际情况。即使它会导致数据的多次更新,每个 action 都只描述一次交互。state 的类型也是任意的,不过一般会使用对象或数组。

注意注意注意!!!

state 是只读的。即使是对象或数组也不要尝试修改它:

function reducer(state, action) {
  switch (action.type) {
    case 'incremented_age': {
      // 🚩 不要像下面这样修改一个对象类型的 state:
      state.age = state.age + 1;
      return state;
    }

正确的做法是返回新的对象:

function reducer(state, action) {
  switch (action.type) {
    case 'incremented_age': {
      // ✅ 正确的做法是返回新的对象
      return {
        ...state,
        age: state.age + 1
      };
    }

阅读 更新对象类型的 state更新数组类型的 state 来了解更多内容。

基础示例

避免重新创建初始值

React 会保存 state 的初始值并在下一次渲染时忽略它。

function createInitialState(username) {
  // ...
}

function TodoList({ username }) {
  const [state, dispatch] = useReducer(reducer, createInitialState(username));
  // ...

虽然 createInitialState(username) 的返回值只用于初次渲染,但是在每一次渲染的时候都会被调用。如果它创建了比较大的数组或者执行了昂贵的计算就会浪费性能。

你可以通过给 useReducer 的第三个参数传入 初始化函数 来解决这个问题,createInitialState 函数只会在初次渲染的时候进行调用,有益于性能优化:

function createInitialState(username) {
  // ...
}

function TodoList({ username }) {
  const [state, dispatch] = useReducer(reducer, username, createInitialState);
  // ...

在上面这个例子中,createInitialState 有一个 username 参数。如果初始化函数不需要参数就可以计算出初始值,可以把 useReducer 的第二个参数改为 null

疑难解答

我已经 dispatch 了一个 action,但是屏幕并没有更新

React 使用 Object.is 比较更新前后的 state,如果 它们相等就会跳过这次更新。这通常是因为你直接修改了对象或数组:

function reducer(state, action) {
  switch (action.type) {
    case 'incremented_age': {
      // 🚩 错误行为:直接修改对象
      state.age++;
      return state;
    }
    case 'changed_name': {
      // 🚩 错误行为:直接修改对象
      state.name = action.nextName;
      return state;
    }
    // ...
  }
}

你直接修改并返回了一个 state 对象,所以 React 会跳过这次更新。为了修复这个错误,你应该确保总是 使用正确的方式更新对象使用正确的方式更新数组

function reducer(state, action) {
  switch (action.type) {
    case 'incremented_age': {
      // ✅ 修复:创建一个新的对象
      return {
        ...state,
        age: state.age + 1
      };
    }
    case 'changed_name': {
      // ✅ 修复:创建一个新的对象
      return {
        ...state,
        name: action.nextName
      };
    }
    // ...
  }
}

在 dispatch 后 state 的某些属性变为了 undefined

请确保每个 case 语句中所返回的新的 state 都复制了当前的属性

function reducer(state, action) {
  switch (action.type) {
    case 'incremented_age': {
      return {
        ...state, // 不要忘记复制之前的属性!
        age: state.age + 1
      };
    }
    // ...

如果上面的代码没有 ...state ,返回的新的 state 就只有 age 属性。

在 dispatch 后整个 state 都变为了 undefined

如果你的 state 错误地变成了 undefined,可能是因为你忘记在某个分支返回 state,或者是你遗漏了某些 case 分支。可以通过在 switch 语句之后抛出一个错误来查找原因:

function reducer(state, action) {
  switch (action.type) {
    case 'incremented_age': {
      // ...
    }
    case 'edited_name': {
      // ...
    }
  }
  throw Error('Unknown action: ' + action.type);
}

也可以通过使用 TypeScript 等静态检查工具来发现这类错误。

我收到了一个报错:“Too many re-renders”

你可能会收到这样一条报错信息:Too many re-renders. React limits the number of renders to prevent an infinite loop.。这通常是在 渲染期间 dispatch 了 action 而导致组件进入了无限循环:dispatch(会导致一次重新渲染)、渲染、dispatch(再次导致重新渲染),然后无限循环。大多数这样的错误是由于事件处理函数中存在错误的逻辑:

// 🚩 错误:渲染期间调用了处理函数
return <button onClick={handleClick()}>Click me</button>

// ✅ 修复:传递一个处理函数,而不是调用
return <button onClick={handleClick}>Click me</button>

// ✅ 修复:传递一个内联的箭头函数
return <button onClick={(e) => handleClick(e)}>Click me</button>

我的 reducer 和初始化函数运行了两次

严格模式 下 React 会调用两次 reducer 和初始化函数,但是这不应该会破坏你的代码逻辑。

这个 仅限于开发模式 的行为可以帮助你 保持组件纯粹:React 会使用其中一次调用结果并忽略另一个结果。如果你的组件、初始化函数以及 reducer 函数都是纯函数,这并不会影响你的逻辑。不过一旦它们存在副作用,这个额外的行为就可以帮助你发现它。

比如下面这个 reducer 函数直接修改了数组类型的 state:

function reducer(state, action) {
  switch (action.type) {
    case 'added_todo': {
      // 🚩 错误:直接修改 state
      state.todos.push({ id: nextId++, text: action.text });
      return state;
    }
    // ...
  }
}

因为 React 会调用 reducer 函数两次,导致你看到添加了两条代办事项,于是你就发现了这个错误行为。在这个示例中,你可以通过 返回新的数组而不是修改数组 来修复它:

function reducer(state, action) {
  switch (action.type) {
    case 'added_todo': {
      // ✅ 修复:返回一个新的 state 数组
      return {
        ...state,
        todos: [
          ...state.todos,
          { id: nextId++, text: action.text }
        ]
      };
    }
    // ...
  }
}

现在这个 reducer 是纯函数了,调用两次也不会有不一致的行为。这就是 React 如何通过调用两次函数来帮助你发现错误。只有组件、初始化函数和 reducer 函数需要是纯函数。事件处理函数不需要实现为纯函数,并且 React 永远不会调用事件函数两次。

与 useContext 一起使用

结合 useContext 和 useReducer 可以创建简单的全局状态管理系统。

我们就以此来尝试创建一个完整的主题切换系统:

首先,定义状态、reducer 和 context:

const ThemeContext = React.createContext();

const initialState = { theme: 'light' };

function themeReducer(state, action) {
    switch (action.type) {
        case 'TOGGLE_THEME':
            return { theme: state.theme === 'light' ? 'dark' : 'light' };
        default:
            return state;
    }
}

接下来,创建一个 Provider 组件:

function ThemeProvider({ children }) {
    const [state, dispatch] = useReducer(themeReducer, initialState);

    return (
        <ThemeContext.Provider value={{ theme: state.theme, toggleTheme: () => dispatch({ type: 'TOGGLE_THEME' }) }}>
            {children}
        </ThemeContext.Provider>
    );
}

在子组件中,你可以轻松切换和读取主题:

function ThemedButton() {
    const { theme, toggleTheme } = useContext(ThemeContext);

    return (
        <button style={{ backgroundColor: theme === 'light' ? '#fff' : '#000' }} onClick={toggleTheme}>
            Toggle Theme
        </button>
    );
}

useReducer与 Redux 的差异

虽然useReducer和 Redux 都采用了 action 和 reducer 的模式来处理状态,但它们在实现和使用上有几个主要的区别:

  1. 范围:useReducer通常在组件或小型应用中使用,而Redux被设计为大型应用的全局状态管理工具。
  2. 中间件和扩展:Redux支持中间件,这允许开发者插入自定义逻辑,例如日志、异步操作等。而useReducer本身不直接支持,但我们可以模拟中间件的效果。
  3. 复杂性:对于简单的状态管理,useReducer通常更简单和直接。但当涉及到复杂的状态逻辑和中间件时,Redux可能更具优势。

结语

useReducer作为 React 的一部分,它比useState强大,又比 Redux 轻量,尤其适合中小型应用或组件级状态管理。