useReducer

Reducer 可能是使用 React 的时候最常见到的一个词了。Reducer 通常用在处理复杂的数据逻辑并提供一个 State 的场景中。与useState可以直接提供一个改变 State 的功能不同的是,useReducer可以更加灵活的改变 State。

首先看一下useReducer在组件中的使用格式:

const [state, dispatch] = useReducer(reducer, initialArg, init?);

useReducer的使用格式中,state就是useReducer经过运算处理最终返回的 State,dispatch则是用来改变 State 的关键函数,对于调用了dispatch以后的具体处理逻辑,则是在reducer参数中定义的。结合后面 Flux、Redux 等章节的内容实际上就可以发现,useReducer就是完成了一个单向数据流 State 的处理过程。

Info

useReducer的参数initialArg表示用于初始化state的值,而init参数则是利用initialArg参数计算state初始值的函数。但是通常在使用中,只设定initialArg就已经足够使用了。

reducer函数的定义

useReducer Hook 中的第一个参数reducer是一个函数,它需要接受两个参数:stateactionreducer函数的返回值则是更新以后的state

React 没有对useReducerreducer函数的参数做任何限制,它们可以是任意合法的内容。例如以下利用switch实现一个根据action中携带的指令完成state内容更新的reducer函数。

function reducer(state, action) {
  switch (action.type) {
    case "increase":
      return {
        conter: state.counter + action.amount,
      };
    case "decrease":
      return {
        conter: state.counter - action.amount,
      };
  }
  throw new Error("Unknown action: " + action.type);
}

在这个示例中可以看到,action可以通过携带各种所需的内容,使reducer函数可以对state做出任何的改变。但是需要注意的是,reducer函数所返回的state,始终都应该是一个完整的state对象结构,而不是只是发生了改变的state不分结构,useReducer Hook 不会对新旧state自动进行拼合的。

Info

虽然useReducer不会对state进行贼床上拼合,但是可以结合其他的工具库来完成state对象的拼合操作,例如Ramda库提供的merge系列函数,亦或者Javascript中提供的Object.assign()方法。使用{ ...state }的格式来复制已有state中的属性也不失为一种好办法。

dispatch函数

dispatch函数是调用useReducer以后返回的内容之一,也是在项目代码中调用reducer函数更改state值的唯一途径。dispatch函数的唯一作用就是更新state值并触发一次组件的重新渲染,它所接受的参数就是reducer函数的action参数内容。

从以上的描述实际上可以看出来,dispatch函数就是把提供给它的action参数转发给了reducer函数,然后再触发一次组件的重新渲染。

例如结合前一节的示例,就可以这样实现一个组件。

function Amount() {
  const [state, dispatch] = useReducer(reducer, { counter: 1 });
  const handleIncrease = useCallback(() => dispatch({ type: "increase" }), []);
  const handleDecrease = useCallback(() => dispatch({ type: "decrease" }), []);

  return (
    <div>
      <div>
        <span>Current Amount: {state.amount}</span>
        <button onClick={handleDecrease}>Decrease</button>
        <button onClick={handleIncrease}>Increase</button>
      </div>
    </div>
  );
}

Warning

在使用dispatch函数的时候,有以下几点是需要注意的:

  1. dispatch函数在调用以后不会立即更新state,所以在调用dispatch函数以后立刻读取state的值,依旧还是更新之前的值,新的state值只能在组件重新渲染之后才可以获得。
  2. 如果新的state值与旧的state值相同,也就是使用Object.is方法进行比较获得的结果是true,那么React就会跳过组件的重新渲染,这是一种非常重要的优化手段。
  3. 组件中的state并不是即时更新的,而是批量更新的。React会在所有的视见函数调用完毕以后对state进行更新,这也是React为了减少组件的渲染次数做出的优化。

init函数

init函数在usereducer的定义中不是必备的,在不提供init函数的时候,useReducer会默认使用其第二个参数initialArg的值作为state的初始值,但是如果state的初始值是通过一定的逻辑处理生成的,那么就需要使用init参数了。init函数接受一个参数,就是useReducer的第二个参数initialArg,其返回值是state的初始值。

可能有的读者可能觉得既然是通过initialArg来生成state的初始值,那么可以直接使用init(initialArg)作为useReducer的第二个参数,也就是如同下面示例中的样子。

function produceInitValue(initialArg) {
  // 计算产生state初始值的逻辑
}

function RepeatInit() {
  const [state, dispatch] = useReducer(reducer, produceInitValue(initialArg));

  // 组件渲染逻辑
}

这个示例在实际运行的时候酒就会发现,produceInitValue(initialArg)返回的值虽然仅在初次渲染的时候被使用了,但是在组件每一次渲染的时候都会重复执行一次,如果这个初始化过程比较费时,那么就会严重影响项目的性能。所以在这种情况下就体现出了useReducer第三个参数的用途了。

useReducer的第三个参数接受的是一个函数的名称,而不是函数的调用,它可以保证被指定的初始化函数仅在useReducer首次使用的时候被调用一次,之后就不再运行。所以上面的示例可以优化成以下样子。

function produceInitValue(initialArg) {
  // 计算产生state初始值的逻辑
}

function RepeatInit() {
  // 注意这里只是使用了产生初始值的函数名,而不是调用。
  const [state, dispatch] = useReducer(reducer, initialArg, produceInitValue);

  // 组件渲染逻辑
}

一些常见的问题

reducerinit函数都执行了两次

在严格模式下,React 默认会调用两次reducerinit函数,这个特性主要是为了帮助开发者保持组件的纯粹。如果组件、reducer函数和init函数都是纯函数,那么 React 的这个特性并不会影响项目的逻辑。

例如以下代码中的reducer函数就不是纯函数,在严格模式下就会向state.items中增加两条数据。

function unpureReducer(state, action) {
  switch (action.type) {
    case "add_item":
      state.items.push({
        id: max(pluck("id", state.items)) + 1,
        text: action.text,
      });
      return state;
    // 以下其他分支代码省略
  }
}

这种在reducer函数中操作数组的方法正确的应该是每次都返回新的数组实例。例如可以修改成以下形式。

function unpureReducer(state, action) {
  switch (action.type) {
    case "add_item":
      return {
        ...state,
        items: [
          ...state.items,
          { id: max(pluck("id", state.items)) + 1, text: action.text },
        ],
      };
    // 以下其他分支代码省略
  }
}

Tip

一个比较好而且省事的推荐是使用一些函数式编程支持库,利用函数式编程中纯函数的特性来完成state的修改操作,可以大大简化state的控制。

调用dispatch以后出现了Too many re-renders的错误

出现Toomany re-renders的错误通常意味着组件渲染进入了死循环,也就是在徐建重新渲染过程中又发生了组件state的改变,触发了新的重新渲染请求。在使用useReducer的过程中出现这个错误,一般是在渲染期间再一次dispatch了一个能够触发重新渲染的action。大多数这样的错误都是存在于事件处理函数中。

例如以下组件中因为错误的书写了事件处理函数,就会造成dispatch调用的死循环。

function FaultyReducer() {
  const [state, dispatch] = useReducer(reducer, { count: 1 });
  const handleClick = useCallback(() => dispatch({ type: "increase" }), []);

  return (
    <div>
      <div>Current count: {state.count}</div>
      <button onClick={handleClick()}>Increase</button>
    </div>
  );
}