关于useEffect和useCallback使用上的细节

429 阅读5分钟

今天在编码的过程中遇到了这样的情况

image.png

原因是因为在useEffect中调用了函数isId()但是又没有将这个函数添加到useEffect依赖项内,而这个函数内又有state的状态更新并且没有定义在useEffect内部,所以eslint给出了这个警告。

解决方案:

isId函数使用useCallback定义,并将它添加到useEffect的依赖项内。也就是最终的代码如下

    const isId = useCallback(()=>{},[]);
    useEffect(()=>{
        isId()
    },[isId])

eslint不再给出警告。这个警告引起了我的好奇心,所以查阅一些资料,总结了一下关于useEffect的使用细节和useCallback的使用场景。

useEffect:

首先解释一下为什么需要把isId放入到依赖项内才能解除警告。 因为react在每次渲染时都会创建一个新的函数引用,如果不将isId加入到依赖项内,那么useEffect使用的始终都是第一次创建时,旧的那个函数引用。所以需要把isId加入到依赖项内保证每次调用的isId都是最新状态的。这样就消除了eslint的警告。但是这样就又有了新的问题,前面说过,每次重新渲染时都会创建新的函数引用,如果把isId加入到useEffect依赖项内,那么每次发生函数引用的改变的话不就会执行回调了吗?这显然是不符合逻辑的。这个时候我们就需要用到另一个react的hooks,useCallback

useCallback

首先介绍一下useCallbackuseCallback是一个用于记忆函数的引用的hook。它的主要目的是优化性能,特别是在组件重新渲染时,避免不必要的函数重新创建。但从字面上看可能很难理解。结合一下我们的使用场景。
假设我们有一个计数器的组件。父组件传递props,子组件接受。当父组件内的更新状态时,相应的子组件也会重新渲染,有时候我们并不希望发生这样的情况。因为有些发生变化的props与子组件并不相关,如果此时因为父组件更新状态而子组件也重新渲染的话,这样不仅会造成不必要的性能浪费,对于用户体验来说也很差。这个时候我们就可以使用useCallback了。

    //父组件
    import React, { useState, useCallback } from 'react';
    import ChildComponent from './ChildComponent';

    const ParentComponent = () => {
        const [count, setCount] = useState(0);

        const incrementCount = useCallback(() => {
            setCount((prevCount) => prevCount + 1);
        }, []);

        return (
            <div>
                <h1>Count: {count}</h1>
                <ChildComponent onButtonClick={incrementCount} />
            </div>
        );
    };

    export default ParentComponent;
    //子组件
    import React from 'react'; 
    const ChildComponent = ({ onButtonClick }) => { 
        console.log("ChildComponent rendered"); 
        return ( 
            <button onClick={onButtonClick}>Increment Count</button> 
        ); 
    }; 
    export default ChildComponent;

我们可以结合这段代码去理解,父组件传递了incrementCount函数为props,子组件button组件的点击事件会调用这个propsincrementCount函数内进行setState的操作更新state状态。此时,如果incrementCount是一个普通的函数,那么因为state的状态更新就会造成不仅父组件会重新渲染子组件也会随之重新渲染。所以我们将incrementCount定义为useCallback函数,这样因为useCallback的依赖项为空,所以incrementCount的函数引用将不会被更新,也就不会造成子组件的重新渲染。
更加直观的例子可以查看大佬:React Hooks - useCallback讲解与使用场景useCallback 接收一个函数和一个依赖项数组 - 掘金 (juejin.cn)的文章。

useCallback依赖项

但是当我把上述isId定义为useCallback函数时,此时又引发了新的警告

image.png
原因是因为我的函数代码内更新了addressListcustomerChannelformValueuserObj这几个state的状态,eslint提示我需要将函数内使用到的state添加到useCallback依赖项内。
添加之后报错解决但却引发了更严重的bug,此时当刷新页面后,发现代码在不停的调用isId函数,浏览器直接奔溃。后得知是因为isId被调用并导致更新 formValue 或 userObj,这又会触发组件重新渲染,导致 isId 再次被调用,进入无限循环。所以解决办法就是,使用函数式更新状态,确保当前更新时是最新的状态。
关于useCallback的依赖项,最开始提到说是当依赖项发生变化时重新更新函数引用。可以结合以下代码来理解这句话

    import React, { useState, useCallback } from 'react';

    const ParentComponent = () => {
        const [count, setCount] = useState(0);
        const [multiplier, setMultiplier] = useState(1);

        // 使用 useCallback,确保 increment 函数在 multiplier 变化时更新
        const increment = useCallback(() => {
            setCount((prevCount) => prevCount + multiplier);
        }, [multiplier]); // 依赖于 multiplier,确保逻辑更新

        return (
            <div>
                <h1>Count: {count}</h1>
                <h2>Multiplier: {multiplier}</h2>
                <button onClick={increment}>Increment</button>
                <button onClick={() => setMultiplier(multiplier + 1)}>Increase Multiplier</button>
            </div>
        );
    };

    export default ParentComponent;

我们可以看到第一个按钮点击时更新count的值为当前值加上multiplier的值,第二个按钮点击时更新multiplier的值。increment是一个useCallback定义的函数,如果没有将multiplier添加为依赖项的话,那么每次increment函数调用时multiplier都为初始值也就是1,所以我们需要将multiplier作为依赖项添加到useCallback内,确保每次拿到的都是最新的值。

最后

理解了useCallback后就可以解释,为什么useEffect的依赖项如果是一个函数时,必须是一个useCallback定义的函数了,因为useEffect的依赖项需要保证具有足够的稳定性所以需要useCallback确保稳定性。
个人理解,可能有误,欢迎大佬指正。