不要再用useEffect去仅进行state的状态同步,用useMemo实现React中真正的watch,避免无用的渲染和请求

1,440 阅读7分钟

在看这篇文章前,推荐大家先仔细看一下React官网中对于useEffect的文章react.nodejs.cn/learn/you-m…

和这⬇️篇文章 主要看评论)

zhuanlan.zhihu.com/p/450513902…

1、最佳实践?

1.1 状态同步

大伙一定遇到过这样的场景,假设有stateA和stateB,当stateA变化时,stateB也必须要变化,但反过来并不会。例如有一个日期类型(dateType)和日期值(dateKey),当日期类型切换时,你需要转换dateKey

  • dateKey受到dateType控制,把他变为一个计算属性?显然不行,因为dateKey还需要维护自己的状态
//他们都必须是state
const [dateType,setDateType] = useState('day')
const [dateKey,setDateKey] = useState(new Date().toLocaleString())
  • 引入一个新的计算属性,当有值更改时,去重新计算?
const date = useMemo(()=>{  transform(dateKey,dateType)},[dateKey,dateType])

为了解决一个小问题,引入了一个新的状态,这似乎不划算,反倒增加了负担

//这是最常见的方式,大伙一定见过这种做法,甚至写过,无论背景
useEffect(() => {
checkValue(dateType)
    setDateKey((dateKey)=>transform(dateType,dateKey))
  },[dateType]);
  • 当一个值的改变的时候,你可以知晓并做些什么,这让你想到了Vue中的watch,你在React的文档中查找,发现了useEffect可以满足你要求,并没有在意什么 “useEffect 是一个 React Hook,它允许你将组件与外部系统同步”,你隐约的知道useEffect是在渲染结束时调用,可能会导致重复渲染,但你并没有在意,因为除了这个也没有API可用了
import React, { useEffect, useState } from "react";

let onlyOne = true;
//分别执行下这两段逻辑,应该可以初见端倪
const Son = (props) => {
  const [data, setData] = useState(1);
  console.log("渲染中");
  // if (props.show && onlyOne) {
  //   setData((c) => c + 1);
  //   onlyOne = false;
  // }
  // useEffect(() => {
  //   setData((c) => c + 1);
  // }, [props.show]);

  useEffect(() => {
    console.log("渲染完毕");  //第一种方式只会打印一次,第二种会打印两次
  });
  return <div>son:{data}</div>;
};

const App = () => {
  const [show, setShow] = useState(false);
  return (
    <div
      onClick={() => {
        console.log("start");
        setShow(true);
      }}
    >
      <Son show={show}></Son>
    </div>
  );
};

export default App;

1.2  官方例子

让我们看一看官方文档给的方案吧 当属性改变时调整一些状态

image.png

官网的最佳例子再次在组件中引入了新的状态,preItem,再次为了解决小问题而引入了一个新的值,这可能反倒增加了负担,显然称不上最佳实践,但他确实没有导致不必要的渲染

  1. 正如图上底部蓝字所说,我们需要在渲染过程中计算一切
  2. preItem的目的仅仅只是为了缓存上一次的值,检测是否需要执行变更
  3. 更新应该是同步的

2、昂贵的渲染

上述三点,我们可以想到什么?useMemo! 将上例中的useEffect换成useMemo,

image.png 很好,只有一次。

2.1 渲染次数

先暂时不管上面那个东西,抛出一个问题,这三个的大小排序是什么

  • UI组件函数执行次数
  • 单个useEffect组件的执行次数
  • dom的渲染次数

前两个很好检测,最后一个的话,我们可以更新一个文本dom,利用 MutationObserver去检测

三个检测hooks

const useCheckDomRenderCount = (
  domref: React.MutableRefObject<HTMLDivElement>
) => {
  const domRenderCount = useRef(0);
  useEffect(() => {
    const observe = new MutationObserver((e) => {
      domRenderCount.current = domRenderCount.current + 1;
      console.log(e, "do再渲染次数:", domRenderCount);
    });
    observe.observe(domref.current, {
      characterData: true,
      subtree: true,
      characterDataOldValue: true,
    });
    return () => observe.disconnect();
  }, [domref]);
};
const useCheckUIFnExecCount = () => {
  const uiFnExecCount = useRef(0);
  ++uiFnExecCount.current;
  console.log("UI函数执行次数:", uiFnExecCount.current);
};

const useCheckUseEffectExecCount = () => {
  let isLatest = true;
  const useEffectExecCount = useRef(0);
  useEffect(() => {
    ++useEffectExecCount.current;
    console.log(
      "useEffect(request API)函数执行次数:",
      useEffectExecCount.current
    );
    fetch("http://localhost:8080")
      .then((res) => res.json())
      .then(() => {
        if (isLatest) {
          //do something
        }
      });
    return () => {
      isLatest = false;
    };
  });
};

我们引用ahooks中的这段代码,这在effect中仅为了setState,但稍微更(添)改(油)一(加)点(醋),尽管在实际上可能很少并且也不推荐有这么长的依赖链

image.png

const APP = () => {
  const [count, setCount] = useState("a");
  const [effectCount, setEffectCount] = useState("b");
  const [updateEffectCount, setUpdateEffectCount] = useState("c");
  const [afterUpdateEffect, setAfterUpdateEffect] = useState("d");
  const domref = useRef<any>();
  console.log(
    count,
    effectCount,
    updateEffectCount,
    afterUpdateEffect,
    "________________"
  );
  useEffect(() => {
    setEffectCount((b) => b + "b");
  }, [count]);

  useEffect(() => {
    setUpdateEffectCount((c) => c + "c");
    return () => {
      // do something
    };
  }, [effectCount]); // you can include deps array if necessary

  useEffect(() => {
    setAfterUpdateEffect((d) => d + "d");
  }, [updateEffectCount]);

  useCheckDomRenderCount(domref);
  useCheckUIFnExecCount();
  useCheckUseEffectExecCount();

  return (
    <>
      <span ref={domref}>
        {count + effectCount + updateEffectCount + afterUpdateEffect}
      </span>
      <button
        type="button"
        onClick={() => {
          setCount((a) => a + "a");
        }}
      >
        reRender
      </button>
    </>
  );
};

执行上面那段代码 image.png

将三个监听逻辑改为useMemo image.png

你应该对为什么你点一下却有好几个请求调用有一些了解了

这个结果似乎有些不及预期,useEffect导致dom的渲染次数增加了,但为什么才一次?这个与useEffect的执行时机有关,更细致的可以见站内这篇和他里面的那篇 # useEffect 一定在页面渲染后才会执行吗?

2.2 性能差距

--->点这个sandbox直接查看<---

要让他符合预期,多次加载的加载很简单,只需要让渲染变得昂贵一点,这也更符合实际和预期

比如这样⬇️

    <>
     <span ref={domref}>
       {count + effectCount + updateEffectCount + afterUpdateEffect}
     </span>
     <button
       type="button"
       onClick={() => {
         setCount((a) => a + "a");
       }}
     >
       reRender
     </button>
     {new Array(10000).fill(0).map(() => {
       const key = Math.random().toString(36).slice(-8);
       return <div key={key}>{key}</div>;
     })}
   </>

再进行一个简单的性能比较

      <button
        type="button"
        onClick={() => {
          console.time("start");  //button处开个定时
          setCount((a) => a + "a");
        }}
      >
      
    useEffect(() => {
    console.timeEnd("start");
  }, [afterUpdateEffect]); //最后一个变化完成时结束时定时

useEffect⬇️无用的中间态的渲染太多了,性能大致有3-4倍差距 image.png 将三个useEffect换成useMemo⬇️ 伟大!无须多言 image.png

3、UI组件函数执行不等于渲染

回到上面那个问题,应该可以明确

UI组件函数执行次数>单个useEffect组件的执行次数=dom的渲染次数(后两者的关系可能不是单纯的等于)

具体的机制就与React老生常谈的渲染流程机制有关了,站内的文章很多,本文就不赘述了(因为其实我也不是很了解😂),但细想也能明白,当你执行UI函数后发现还有更新项,你肯定就没有必要渲染这次中间态了

image.png

4、useWatch

用useMemo可能会由于lint规则,观念限制,没有Update等等原因限制你去更换,但很简单,我们用useMemo仅仅只是因为上面那三个条件他刚好都满足。所以一个useWatch很简单

//就这样,并不复杂
const useWatch = (callback: () => any, deps: any[]) => {
  const [pre, SetPre] = useState<any[] | undefined>(undefined);
  if (
    deps.some((now, index) => {
      if (!Array.isArray(pre)) return true;
      return now !== pre[index];
    })
  ) {
    callback();
    SetPre(deps);
  }
};

useUpdateWatch

需要初始化不执行的watch,改一下初始值就行

const useWatch = (callback: () => any, deps: any[]) => {
  const [pre, SetPre] = useState<any[] | undefined>(deps);
  if (
    deps.some((now, index) => {
      if (!Array.isArray(pre)) return true;
      return now !== pre[index];
    })
  ) {
    callback();
    SetPre(deps);
  }
};

useDeepCompareWatch useUpdateDeepCompareWatch

………………

比较方式换成深比较就行,懒得写了

5、结语

  • useEffect 是一个 React 钩子,可以让你 将组件与外部系统同步。(外部系统)
  • 推荐大伙看一下react官网上有关useEffect的说明和例子,挺有用的。并且我想说个暴论😆,如果你的函数实质上并不需要清除副作用,也不需要用到useEffect的伪生命周期机制,那么你就不应该用useEffect
  • 上面知乎那篇评论有个说的好,许多人把useEffect当成了Vue中的watch在用。但在useEffect中同步更类似于这样的场景: 你没有发现Vue中的watch这个API,但任何变化都会走到onUpdated逻辑中,所以你在onUpdated中去检查是否是你的依赖项引起的变化,如果是,那再更新一次。这就是许多React开发者有意无意都在做的事
  • 本文的作用最后应该是一种性能优化的问题和方案,把useEffect换成useMemo或者useWatch都不会解决你的数据依赖链过长并难以理解的问题,在代码开发中应该尽可能避免这种依赖链过长问题
  • 以ahooks为例,乃至许多开源框架中都频繁的利用useEffect去当watch而仅仅为了同步状态,这是一种十分不妥的行为

Watch,在哪儿?

在实际开发中,我们必然面临需要有watch的情况,参数变更,需要同步变化

所以在函数组件中,watch在哪儿?

这不就是组件函数本身吗!只不过他的依赖类似于是: watch([props,allCustomState],Componnet)

而上面的useWatch只是帮你挑出是那个值变化了而已

去尝试一下把你项目仅仅只是为了同步state,而没有利用到他的伪生命周期或者同步外部系统而写的useEffect改成useMemo吧,应该会有些提升(应该,因为你下载的npm包中可能充斥着useEffect)