React Hooks 是从功能组件访问 React 的状态和生命周期方法的最佳方式。 useEffect
Hook 是一个在渲染之后和每次 DOM 更新时运行的函数(效果)。在本文中,将讨论一些技巧以更好地使用 useEffect
Hook。
通过项目来发现问题,加深对其理解应用到项目中。
- 项目GITHUB:github.com/QuintionTan…
开始之前先简单来理解一下 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 视为 componentDidMount
、componentDidUpdate
和 componentWillUnmount
的组合。所以 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("我只在初始渲染后被调用一次"); }, []);
这类似于 componentDidMount
和 componentWillUnmount
(返回)生命周期方法。这是添加页面所需的所有侦听器和订阅的地方。
对特定 props 变化的运行效果
假设必须根据用户感兴趣的产品来获取数据(产品详细信息),如,所选产品有一个 productId
,需要在每次 productId
更改时运行回调——而不仅仅是在初始渲染或每次 DOM 更新时。
useEffect(() => { getProductDetails(productId); }, [productId]);
这基本上复制了 componentDidUpdate
生命周期方法。还可以将多个值传递给依赖数组。
一个经典的反例可以帮助更好地理解这一点:
useEffect(() => { console.log(`当counter1: ${counter1}或counter2: ${counter2}发生变化时,我会被调用。`); }, [counter1, counter2]);
在上面的示例中,counter1
或 counter2
中的更新将触发。
在依赖数组中传递对象
现在,如果回调依赖是一个对象怎么办。如果这样做,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 ( <> Var A: {varA}, Var B: {varB} ); }
上述代码,变量 varA
和 varB
中的任何一个更改都会触发两个变量的更新。这就是为什么这个钩子不能正常工作的原因。由于这是一个简短的示例,可能会觉得它很明显,但是,在具有更多代码和变量的较长函数中,会因此错过这一点。所以做正确的事并拆分 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 ( <> Var A: {varA}, Var B: {varB} ); }
上述代码仅为了说明问题,实际编码有些地方可以用其他的方式。
尽可能使用自定义挂钩
再次以上面的例子为例,如果变量 varA
和 varB
完全独立怎么办?在这种情况下,可以简单地创建一个自定义钩子来隔离每个变量。这样,就可以确切地知道每个函数对哪个变量做了什么。
下面就来构建一些自定义钩子。
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 ( <> Var A: {varA}, Var B: {varB} ); }
这样每个变量都有自己的钩子,更易于维护和易于阅读!
有条件地以正确的方式运行 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
就不会有任何问题。学习的一些内容讲通过一个个人项目的形式逐渐完善,丰富功能模块。