IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    useState与useReducer性能居然有区别?

    卡颂发表于 2023-04-10 10:17:50
    love 0

    大家好,我卡颂。

    稍微深入了解过useState的同学都知道 —— useState其实是预置了reducer的useReducer。具体来讲,他预置的reducer实现如下:

    function basicStateReducer(state, action) {
      // $FlowFixMe: Flow doesn't like mixed types
      return typeof action === 'function' ? action(state) : action;
    }

    那按理来说,useState与useReducer性能应该完全一致才对。但实际上,他们的性能并不一样。本文就来聊聊他们的细微差别。

    欢迎加入人类高质量前端交流群,带飞

    一个严重的bug

    在v18之前,特定场景下,useReducer存在一个严重的bug。假设我们要挂载如下App组件:

    bug复现地址
    function App() {
      const [disabled, setDisabled] = React.useState(false);
      return (
        <>
          <button onClick={() => setDisabled((prev) => !prev)}>Disable</button>
          <div>{`Disabled? ${disabled}`}</div>
          <CounterReducer disabled={disabled} />
        </>
      );
    }

    通过点击按钮,可以切换disabled状态,并将disabled作为props传递给CounterReducer组件。

    CounterReducer组件的实现如下:

    function CounterReducer({ disabled }) {
      const [count, dispatch] = useReducer((state) => {
        if (disabled) {
          return state;
        }
        return state + 1;
      }, 0);
      return (
        <>
          <button onClick={dispatch}>reducer + 1</button>
          <div>{`Count ${count}`}</div>
        </>
      );
    }

    count状态初始为0,当disabled props为true时,点击reducer + 1按钮后count不会变化。

    disabled为true时,多次点击后count仍显示0

    当disabled props为false时,点击reducer + 1按钮后count会加1。

    disabled为false时,点击后count加1

    现在问题来了,当disabled props为true时(此时count为0),我们点击reducer + 1按钮5次,然后再点击Disable按钮(disabled props会变为false),此时count为多少呢?

    按照代码逻辑,改变disabled对count不会造成影响,所以他应该保持原始状态不变(即为0)。

    但在v18之前,他会变成5。

    但是,如果我们用useState实现同样逻辑的useReducer:

    function CounterState({ disabled }) {
      const [count, dispatch] = useState(0);
    
      function dispatchAction() {
        dispatch((state) => {
          if (disabled) {
            return state;
          }
          return state + 1;
        });
      }
    
      return (
        <>
          <button onClick={dispatchAction}>state + 1</button>
          <div>{`Count ${count}`}</div>
        </>
      );
    }

    就能取得符合预期的效果。

    所以说,useReducer的实现在特殊场景下是有bug的(v18之前)。

    bug是如何产生的

    产生这个bug的原因在于React内部的一种被称为eager state的性能优化策略。

    简单的说,对于类似如下这样的,即使多次触发更新,但状态的最终结果不变的情况(在如下例子中count始终为0):

    function App() {
      const [count, dispatch] = useState(0);
      return <button onClick={() => dispatch(0)}>点击</button>;
    }

    App组件是没有必要render的。这就省去了render的性能开销。

    要命中eager state,有个严格的前提 —— 状态更新前后不变。

    我们知道,React中有两种更新状态的方式:

    1. 传递新的状态
    // 定义状态
    const [count, dispatch] = useState(0);
    
    // 更新状态
    dispatch(100)
    1. 传递更新状态的函数
    // 定义状态
    const [count, dispatch] = useState(0);
    
    // 更新状态
    dispatch(oldState => oldState + 100)

    那么,对于方式1,要保证状态不变很简单,只需要全等比较变化前后的状态,如果他们一致就能进入eager state策略。

    对于方式2,就略微复杂点,需要同时满足2个条件:

    1. 状态更新函数本身不变
    2. 通过状态更新函数计算出的新状态也不变

    比如,下述代码就同时满足2个条件,但如果将change放到App内就不满足条件1(App组件每次render时都会创建新的change函数):

    // 状态更新函数本身不变
    function change(oldState) {
      // 新状态也不变
      return oldState;
    }
    
    function App() {
      const [count, dispatch] = useState(0);
      
      // 状态更新函数每次render都会变化
      // function change(oldState) {
         // 新状态不变
         // return oldState;
      // }
      
      return <button onClick={() => dispatch(change)}>点击</button>;
    }

    类似的情况,在useState的实现中,虽然他是预置了reducer的useReducer,但他预置的reducer的引用是不变的,所以用他实现的文章开篇的例子可以命中优化策略。

    useReducer在特定场景下的bug就与此相关。并不是说bug产生的原因是useReducer一定没命中优化策略,而是说相比于useState,他命中优化策略很不稳定。

    v18之后的改变

    既然bug来源于不稳定的性能优化策略,在没有完美的解决方案之前,React是如何在v18中修复这个bug的呢?

    答案是 —— 移除useReducer的eager state策略。也就是说,在任何情况下,useReducer都不再有useState存在的这个性能优化策略了。

    这就导致在特定场景下,useReducer的性能弱于useState。

    比如在这个v18在线示例中,同样的逻辑用useState实现,不会有冗余的render,而useReducer会有。

    总结

    在考虑性能优化时,如果useState与useReducer都能满足需要,或许useState是更好的选择。



沪ICP备19023445号-2号
友情链接