初学React useEffect Hook

646 阅读8分钟

React Hooks 是从功能组件访问 React 的状态和生命周期方法的最佳方式。 useEffect Hook 是一个在渲染之后和每次 DOM 更新时运行的函数(效果)。在本文中,将讨论一些技巧以更好地使用 useEffect Hook。

通过项目来发现问题,加深对其理解应用到项目中。

开始之前先简单来理解一下 useEffect 设计。

useEffect 设计

React 提供了一个 useEffect 钩子函数来设置在更新后的回调:

const Title = () => {
    useEffect(() => {
        window.title = "Hello World";
        return () => {
            window.title = "NoTitle";
        };
    }, []);
};

useEffect 函数采用名为 create 的回调函数作为其第一个输入参数来定义效果。上面的代码,Effect 在安装组件时将 window.title 设置为 Hello World

create 函数可以返回一个名为 destroy 的函数来执行清理。这里有趣的是 destroy 函数由 create 函数的返回值提供。在前面的示例中,清理将 window.title 对象在卸载时设置为 NoTitle

useEffect 参数列表中的第二个参数是一个名为 deps 的依赖项数组。如果未设置 deps,则在每次更新期间每次都会调用 Effect,而当给出 deps 时,Effect 只会在 deps 数组发生更改时调用。

子组件 Effects 优先触发

useEffect Hook 视为 componentDidMountcomponentDidUpdatecomponentWillUnmount 的组合。所以 useEffect Hook 的行为类似于类生命周期方法。需要注意的一种行为是子回调在父回调之前触发。

function ParentComponent() {
    useEffect(() => {
        console.log("我是父组件");
    });
    return <ChildComponent />;
}

function ChildComponent({ fetchProduct }) {
    useEffect(() => {
        console.log("我是子组件");
    });
}

假设必须自动触发付款。这段代码写在 render 之后运行的子组件中,但是实际支付所需的详细信息(总金额、折扣等)是在父组件的 effect 中获取的。在这种情况下,由于在设置所需的详细信息之前触发了付款,因此就会出现实现逻辑不对。

因此在构建代码的时候需要考虑子组件的 useEffect 会优先执行。

依赖数组

从基础开始。 useEffect Hook 接受第二个参数,称为依赖数组,以控制回调何时触发。

对每个 DOM 更新运行效果

不传递依赖项数组将在每次 DOM 更新时运行回调。

useEffect(() => {
    console.log("每次DOM更新时,我都会被调用");
});

在初始渲染上运行效果

传入空数组仅在初始渲染后运行效果。至此,状态已更新为初始值。 DOM 中的进一步更新不会调用此效果。

useEffect(() => {
    console.log("我只在初始渲染后被调用一次");
}, []);

这类似于 componentDidMountcomponentWillUnmount(返回)生命周期方法。这是添加页面所需的所有侦听器和订阅的地方。

对特定 props 变化的运行效果

假设必须根据用户感兴趣的产品来获取数据(产品详细信息),如,所选产品有一个 productId,需要在每次 productId 更改时运行回调——而不仅仅是在初始渲染或每次 DOM 更新时。

useEffect(() => {
    getProductDetails(productId); 
}, [productId]);

这基本上复制了 componentDidUpdate 生命周期方法。还可以将多个值传递给依赖数组。

一个经典的反例可以帮助更好地理解这一点:

useEffect(() => {
  console.log(`当counter1: ${counter1}或counter2: ${counter2}发生变化时,我会被调用。`);
}, [counter1, counter2]);

在上面的示例中,counter1counter2 中的更新将触发。

在依赖数组中传递对象

现在,如果回调依赖是一个对象怎么办。如果这样做,effects 会成功运行吗?

const [productId, setProductId] = useState(0);
const [obj, setObj] = useState({ a: 1 });
useEffect(() => {
    // 对`obj`的变化做些什么
}, [obj]);

答案是否定的,因为对象是引用类型。对象属性的任何更改都不会被依赖项数组监听到,因为只检查引用而不检查内部的值。

可以遵循几种方法在对象中执行深度比较。

  • JSON.stringify 对象:
const [objStringified, setObj] = useState(JSON.stringify({ a: 1 }));
useEffect(() => {
    //
}, [objStringified]);

现在,useEffect 可以检测到对象的属性何时发生变化并按预期运行。

  • useRef 和 Lodash 进行比较:

还可以编写自定义函数以使用 useRef 进行比较。它用于在组件的当前属性中的整个生命周期中保存可变值。

function deepCompareEquals(prevVal, currentVal) {
    return _.isEqual(prevVal, currentVal);
}

function useDeepCompareWithRef(value) {
    const ref = useRef();
    if (!deepCompareEquals(value, ref.current)) {
        ref.current = value;
    }

    return ref.current;
}

function MyComponent({ obj }) {
    useEffect(() => {
        //
    }, [useDeepCompareWithRef(obj)]);
}
  • 外部packages:如果对象太复杂而无法自己进行比较,推荐一个第三方库 use-deep-compare-effect
import useDeepCompareEffect from "use-deep-compare-effect";
function MyComponent({ obj }) {
    useDeepCompareEffect(() => {}, [obj]);
}

useDeepCompareEffect 将进行深度比较并仅在对象 obj 更改时运行回调。

将 useEffect 用于单一目的

上面了解了依赖数组,可能需要分离 useEffect 以在组件的不同生命周期事件上运行,或者只是为了更清晰的代码,函数应该服务于单一目的(就像一个句子应该只传达一个想法一样)。

useEffects 拆分为简短单一用途函数可以降低BUG的出现。例如,假设有与 varB 无关的 varA,并且想要基于 useEffect(带有 setTimeout)构建一个递归计数器,先来看一段不推荐的代码:

import React, { useEffect, useState } from "react";
export default function Home() {
    const [varA, setVarA] = useState(0);
    const [varB, setVarB] = useState(0);

    useEffect(() => {
        const timeoutA = setTimeout(() => setVarA(varA + 1), 1000);
        const timeoutB = setTimeout(() => setVarB(varB + 2), 2000);

        return () => {
            clearTimeout(timeoutA);
            clearTimeout(timeoutB);
        };
    }, [varA, varB]);

    return (
        <>
            <span>
                Var A: {varA}, Var B: {varB}
            </span>
        </>
    );
}

上述代码,变量 varAvarB 中的任何一个更改都会触发两个变量的更新。这就是为什么这个钩子不能正常工作的原因。由于这是一个简短的示例,可能会觉得它很明显,但是,在具有更多代码和变量的较长函数中,会因此错过这一点。所以做正确的事并拆分 useEffect 的逻辑。

import React, { useEffect, useState } from "react";
export default function Home() {
    const [varA, setVarA] = useState(0);
    const [varB, setVarB] = useState(0);

    useEffect(() => {
        const timeout = setTimeout(() => setVarA(varA + 1), 1000);

        return () => clearTimeout(timeout);
    }, [varA]);

    useEffect(() => {
        const timeout = setTimeout(() => setVarB(varB + 2), 2000);

        return () => clearTimeout(timeout);
    }, [varB]);

    return (
        <>
            <span>
                Var A: {varA}, Var B: {varB}
            </span>
        </>
    );
}

上述代码仅为了说明问题,实际编码有些地方可以用其他的方式。

尽可能使用自定义挂钩

再次以上面的例子为例,如果变量 varAvarB 完全独立怎么办?在这种情况下,可以简单地创建一个自定义钩子来隔离每个变量。这样,就可以确切地知道每个函数对哪个变量做了什么。

下面就来构建一些自定义钩子。

import React, { useEffect, useState } from "react";

const useVarA = () => {
    const [varA, setVarA] = useState(0);

    useEffect(() => {
        const timeout = setTimeout(() => setVarA(varA + 1), 1000);

        return () => clearTimeout(timeout);
    }, [varA]);

    return [varA, setVarA];
};

const useVarB = () => {
    const [varB, setVarB] = useState(0);

    useEffect(() => {
        const timeout = setTimeout(() => setVarB(varB + 2), 2000);

        return () => clearTimeout(timeout);
    }, [varB]);

    return [varB, setVarB];
};

export default function Home() {
    const [varA, setVarA] = useVarA();
    const [varB, setVarB] = useVarB();

    return (
        <>
            <span>
                Var A: {varA}, Var B: {varB}
            </span>
        </>
    );
}

这样每个变量都有自己的钩子,更易于维护和易于阅读!

有条件地以正确的方式运行 useEffect

关于 setTimeout ,再来看个例子:

import React, { useEffect, useState } from "react";

export default function Home() {
    const [varA, setVarA] = useState(0);

    useEffect(() => {
        const timeout = setTimeout(() => setVarA(varA + 1), 1000);

        return () => clearTimeout(timeout);
    }, [varA]);

    return (
        <>
            <span>Var A: {varA}</span>
        </>
    );
}

出于某种原因,想将计数器的最大值限制为 5。有正确的方法和错误的方法。

先来看看错误的做法:

import React, { useEffect, useState } from "react";

export default function Home() {
    const [varA, setVarA] = useState(0);

    useEffect(() => {
        let timeout;
        if (varA < 5) {
            timeout = setTimeout(() => setVarA(varA + 1), 1000);
        }

        return () => clearTimeout(timeout);
    }, [varA]);

    return (
        <>
            <span>Var A: {varA}</span>
        </>
    );
}

虽然这有效,但 clearTimeout 将在 varA 发生更改时运行,而 setTimeout 是有条件地运行。

有条件地运行 useEffect 的推荐方法是在函数开头执行条件返回,如下所示:

import React, { useEffect, useState } from "react";

export default function Home() {
    const [varA, setVarA] = useState(0);

    useEffect(() => {
        if (varA >= 5) return;
        const timeout = setTimeout(() => setVarA(varA + 1), 1000);

        return () => clearTimeout(timeout);
    }, [varA]);

    return (
        <>
            <span>Var A: {varA}</span>
        </>
    );
}

在依赖数组中输入 useEffect 中的每个道具

如果正在使用 ESLint,那么可能已经看到来自 ESLint exhaustive-deps 规则的警告。这是至关重要的,当应用程序变得越来越大时,每个 useEffect 中都会添加更多的依赖项(props)。为了跟踪所有这些并避免陈旧的闭包,应该将每个依赖项添加到依赖项数组中。

同样,关于 setTimeout 的问题,假设只想运行一次 setTimeout 并添加到 varA

import React, { useEffect, useState } from "react";

export default function Home() {
    const [varA, setVarA] = useState(0);

    useEffect(() => {
        const timeout = setTimeout(() => setVarA(varA + 1), 1000);

        return () => clearTimeout(timeout);
    }, []); // 避免这种情况:varA 不在依赖数组中!

    return (
        <>
            <span>Var A: {varA}</span>
        </>
    );
}

虽然上述代码会正确执行,但是如果代码变得更大或者更复杂,可能就会带来问题。在这种情况下,需要将所有变量都映射出来,因为这样可以更容易地测试和检测可能出现的问题(例如过时的 props 和闭包)。

正确的做法应该是:

import React, { useEffect, useState } from "react";

export default function Home() {
    const [varA, setVarA] = useState(0);

    useEffect(() => {
        if (varA > 0) return;
        const timeout = setTimeout(() => setVarA(varA + 1), 1000);

        return () => clearTimeout(timeout);
    }, [varA]);

    return (
        <>
            <span>Var A: {varA}</span>
        </>
    );
}

总结

上面学习了什么是 useEffect ?如何更好的使用 useEffect?如果了解基本概念,那么使用 useEffect 就不会有任何问题。学习的一些内容讲通过一个个人项目的形式逐渐完善,丰富功能模块。

本文正在参加「金石计划」 ” 感谢支持