React Hook之 useReducer

一、useReducer 简单介绍
const [state, dispatch] = useReducer(reducer, initialArg, init);

useState 的替代方案。它接收一个形如 (state, action) => newStatereducer,并返回当前的 state 以及与其配套的 dispatch 方法。(如果你熟悉 Redux 的话,就已经知道它如何工作了。)

在某些场景下,useReducer 会比 useState 更适用,例如 state 逻辑较复杂且包含多个子值,或者下一个 state 依赖于之前的 state 等。并且,使用 useReducer 还能给那些会触发深更新的组件做性能优化,因为你可以向子组件传递 dispatch 而不是回调函数 。

以下是用 reducer 重写 useState 一节的计数器示例:

const initialState = {count: 0};

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {count: state.count + 1};
    case 'decrement':
      return {count: state.count - 1};
    default:
      throw new Error();
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({type: 'decrement'})}>-</button>
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
    </>
  );
}

注意
React 会确保 dispatch 函数的标识是稳定的,并且不会在组件重新渲染时改变。这就是为什么可以安全地从 useEffectuseCallback 的依赖列表中省略 dispatch

1.1 指定初始 state

有两种不同初始化 useReducer state 的方式,你可以根据使用场景选择其中的一种。将初始 state 作为第二个参数传入 useReducer 是最简单的方法:

const [state, dispatch] = useReducer(
   reducer,
   {count: initialCount}
 );

注意
React 不使用 state = initialState 这一由 Redux 推广开来的参数约定。有时候初始值依赖于 props,因此需要在调用 Hook 时指定。如果你特别喜欢上述的参数约定,可以通过调用 useReducer(reducer, undefined, reducer) 来模拟 Redux 的行为,但我们不鼓励你这么做。

1.2 惰性初始化

你可以选择惰性地创建初始 state。为此,需要将 init 函数作为 useReducer 的第三个参数传入,这样初始 state 将被设置为 init(initialArg)

这么做可以将用于计算 state 的逻辑提取到 reducer 外部,这也为将来对重置 stateaction 做处理提供了便利:

function init(initialCount) {
  return {count: initialCount};
}

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {count: state.count + 1};
    case 'decrement':
      return {count: state.count - 1};
    case 'reset':
      return init(action.payload);
    default:
      throw new Error();
  }
}

function Counter({initialCount}) {
  const [state, dispatch] = useReducer(reducer, initialCount, init);
  return (
    <>
      Count: {state.count}
      <button
        onClick={() => dispatch({type: 'reset', payload: initialCount})}>
        Reset
      </button>
      <button onClick={() => dispatch({type: 'decrement'})}>-</button>
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
    </>
  );
}

1.3 跳过 dispatch

如果 Reducer Hook 的返回值与当前 state 相同,React 将跳过子组件的渲染及副作用的执行。(React使用 Object.is 比较算法 来比较 state。)

需要注意的是,React 可能仍需要在跳过渲染前再次渲染该组件。不过由于 React 不会对组件树的“深层”节点进行不必要的渲染,所以大可不必担心。如果你在渲染期间执行了高开销的计算,则可以使用 useMemo 来进行优化。

二、useReducer的优势

举一个例子:

function Counter() {
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(1);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + step); // 依赖其他state来更新
    }, 1000);
    return () => clearInterval(id);
    // 为了保证setCount中的step是最新的,
    // 我们还需要在deps数组中指定step
  }, [step]);

  return (
    <>
      <h1>{count}</h1>
      <input value={step} onChange={e => setStep(Number(e.target.value))} />
    </>
  );
}

这段代码能够正常工作,但是随着相互依赖的状态变多,setState中的逻辑会变得很复杂,useEffectdeps数组也会变得更复杂,降低可读性的同时,useEffect重新执行时机变得更加难以预料。

使用useReducer替代useState以后:

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const { count, step } = state;

  useEffect(() => {
    const id = setInterval(() => {
      dispatch({ type: 'tick' });
      }, 1000);
    return () => clearInterval(id);
  }, []); // deps数组不需要包含step

  return (
    <>
      <h1>{count}</h1>
      <input value={step} onChange={e => setStep(Number(e.target.value))} />
    </>
  )
}

现在组件只需要发出action,而无需知道如何更新状态。也就是将What to doHow to do解耦。彻底解耦的标志就是:useReducer总是返回相同的dispatch函数(发出action的渠道),不管reducer(状态更新的逻辑)如何变化。

另一方面step的更新不会造成useEffect的失效、重执行。因为现在useEffect依赖于dispatch,而不依赖于状态值(得益于上面的解耦模式)。这是一个重要的模式,能用来避免useEffect、useMemo、useCallback需要频繁重执行的问题。

以下是state的定义,其中reducer封装了“如何更新状态”的逻辑:

const initialState = {
  count: 0,
  step: 1,
};

function reducer(state, action) {
  const { count, step } = state;
  if (action.type === 'tick') {
    return { count: count + step, step };
  } else if (action.type === 'step') {
    return { count, step: action.step };
  } else {
    throw new Error();
  }
}

总结

  1. 当状态更新逻辑比较复杂的时候,就应该考虑使用useReducer。因为:
    (1)reducersetState更加擅长描述“如何更新状态”。比如,reducer能够读取相关的状态、同时更新多个状态;
    (2)【组件负责发出actionreducer负责更新状态】的解耦模式,使得代码逻辑变得更加清晰,代码行为更加可预测(比如useEffect的更新时机更加稳定)。
    (3)简单来记,就是每当编写setState(prevState => newState)的时候,就应该考虑是否值得将它换成useReducer
  2. 通过传递useReducerdispatch,可以减少状态值的传递。
    (1) useReducer总是返回相同的dispatch函数,这是彻底解耦的标志:状态更新逻辑可以任意变化,而发起actions的渠道始终不变;
    (2) 得益于前面的解耦模式,useEffect函数体、callback function只需要使用dispatch来发出action,而无需直接依赖状态值。因此在useEffectuseCallbackuseMemodeps数组中无需包含状态值,也减少了它们更新的需要。不但能提高可读性,而且能提升性能(useCallbackuseMemo的更新往往会造成子组件的刷新)。
三、高级用法:内联reducer

你可以将reducer声明在组件内部,从而能够通过闭包访问props、以及前面的hooks结果:

const initialState = {
  count: 0
};

function Counter() {
  const [step, setStep] = useState(1);

  console.log("before useReducer");
  const [state, dispatch] = useReducer(reducer, initialState);
  console.log("after useReducer", state);

  function reducer(prevState, action) {
    // reducer will read the value from the latest render
    console.log("reducer", step);
    const { count: prevCount } = prevState;
    if (action.type === "add") {
      return { count: prevCount + step };
    } else if (action.type === "add-current-step") {
      return { count: prevCount + action.step };
    } else {
      throw new Error();
    }
  }

  return (
    <div>
      <h1>{state.count}</h1>
      <div>
        <button
          onClick={() => {
            // this two state updates will be batched
            console.log("before dispatch");
            dispatch({
              type: "add"
            });
            console.log("after dispatch");
            setStep((v) => v + 1);
          }}
        >
          add latest step
        </button>

        <button
          onClick={() => {
            // this two state updates will be batched
            console.log("before dispatch");
            dispatch({
              type: "add-current-step",
              step
            });
            console.log("after dispatch");
            setStep((v) => v + 1);
          }}
        >
          add current step
        </button>
      </div>
    </div>
  );
}

这个能力可能会出乎很多人的意料。因为大部分人对reducer的触发时机的理解是错误的(包括以前的我)。我以前理解的触发时机是这样:

  1. 某个button被用户点击,它的onClick被调用,其中执行了dispatch({type:'add'})React框架安排一次更新
  2. React框架处理刚才安排的更新,调用reducer(prevState, {type:'add'}),得到新的状态 (注意此时还没有发生重新渲染)
  3. React框架用新的状态来重新渲染组件树,执行到Counter组件的useReducer`时,返回上一步得到的新状态即可

但是实际上,React会在下次渲染的时候,会同步地调用reducer来处理队列中的action

  1. 某个button被用户点击,它的onClick被调用,其中执行了dispatch({type:'add'})React框架安排一次更新
  2. React框架处理刚才安排的更新,开始重渲染组件树 (注意此时还不知道最新的reducer状态)
  3. React框架重新渲染组件树,执行到Counter组件的useReducer时,调用reducer(prevState, {type:'add'}),得到新的状态

重要的区别在于reducer是在重新渲染的时候被调用的,它的闭包捕获到了下次渲染的闭包(包括props以及前面的hooks结果)

如果按照上面的错误理解,reducer是在重新渲染之前被调用的,它的闭包捕获到上次渲染的props,那么点击“add latest step”按钮的结果应该是数字变成1。

事实上,上面的例子使用了console.log来打印执行顺序,会发现reducer会在新渲染执行useReducer的时候被同步执行的:

console.log("before useReducer");
const [state, dispatch] = useReducer(reducer, initialState);
console.log("after useReducer", state);

调用点击按钮以后的输出包括:

before useReducer
reducer 2
after useReducer {count: 2}

证明reducer确实被useReducer同步地调用来获取新的state
并且,如果按照上面所说的错误理解,在reducer中打印的step值应该是1。但实际打印的是2(本次渲染的step值),而不是1(上一次渲染的step值),说明它拿到了最新的闭包值。

四、参考文章:
  1. https://react.docschina.org/docs/hooks-reference.html#usereducer
  2. https://segmentfault.com/a/1190000023039945
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值