你不知道 useEffect
useEffect 是每个 ReactJS 开发者都应该了解的最常见的 React Hook 之一。但正确使用 useEffect 并不像你想象的那么容易。今天,我将回顾一些我认为很多新手在使用 useEffect 时容易犯的错误,并给出相应的解决方案。
1. 快速概览
我认为我们应该先了解一下 useEffect 的一些基础知识。我们都知道 useEffect 是一个 React Hook,用于处理副作用函数(对于那些不知道副作用函数是什么的人来说——它只是一个与外界交互的函数。例如:在屏幕上记录一些内容、创建文件、将数据保存到数据库、更改 DOM ……)。
如果你了解 React 的生命周期,那么 useEffect 会完成 componentDidMount、componentDidUpdate 和 componentWillUnmount 的工作。没错,一个钩子里包含了三个方法。因此,useEffect 的用例就是上述方法的用例:
- 调用 API
- 当状态/属性改变时执行某些操作
- 卸载时/下次渲染前清理内容
- 不仅如此……
语法: useEffect 的语法非常简单:
useEffect(someFunc, [deps_array]);
第一个参数将是一个副作用函数。
第二个参数将是一个依赖项数组,它决定 useEffect 是否运行。
2. 深入研究 useEffect。
a. 使用形式效果
首先,我们来谈谈 useEffect 的三种形式。我不知道称之为“形式”是否合适,但至少对我来说是合理的(希望你们也能理解!)
useEffect 的形式由第二个参数决定:依赖项数组。
首先,deps_arrray 是可选的,你不必传递第二个参数。如果只传递第一个参数,我们可以使用 useEffect 的第一个形式。
useEffect(func);
在这种情况下,传递给 useEffect 的函数将在组件的每次渲染时运行。当你需要在组件的每次渲染时执行某些操作时,可以使用它。但是,如果你不想造成无限渲染或内存泄漏,则在使用这种形式的 useEffect 时应谨慎。你应该尽可能避免使用这种形式的 useEffect。
例如
const App = () => {
useEffect(() => {
console.log("This effect is called on every render");
});
// return..
}
每次重新渲染组件时,您都会看到该日志。
如果传递一个空数组作为 useEffect 的第二个参数,您将获得它的第二种形式。
useEffect(func, []);
与第一种形式相反,传递给 useEffect 的函数只会运行一次(第一次渲染之后)。
例如:
const App = () => {
useEffect(() => {
console.log("Effect has been called");
}, []);
// return...
}
除了第一次渲染之外,您将不会再看到“效果已被调用”的日志。
useEffect 的第三种形式是传递依赖项数组中带有一些变量的数组
useEffect(func, [variableA, varibleB,...]);
这一次,每次依赖项数组中的任何元素发生变化时,都会运行 func。
例如:
const App = () => {
const [counter, setCounter] = useState(0);
useEffect(() => {
// This will run every time counter changed
console.log('counter: ', counter);
}, [counter]);
// return
}
⚠️ 需要注意的是:无论你是否传递了依赖项数组,而只是打算在某个依赖项发生变化时运行 useEffect 的第三种形式的函数,useEffect 总会在组件第一次挂载时运行。
例如:
const App = () => {
const [a, setA] = useState(0);
const [b, setB] = useState(1);
useEffect(() => {
console.log("run only once");
}, []);
useEffect(() => {
console.log("Change a");
}, [a]);
useEffect(() => {
console.log("Change b");
}, [b]);
return (
...
);
}
在第一次渲染时,您将看到三个日志:
run only once
change a
change b
因此,即使 a 和 b 完全没有改变,与这些变量关联的 useEffect 仍然会在首次渲染时运行。如果你有多个 useEffect 触发了一些繁重的副作用(例如 API 调用),那么这将是一个大问题。例如,你需要渲染一个包含分页和搜索查询的列表。
import { useEffect, useState } from "react";
import "./styles.css";
const App = () => {
const [query, setQuery] = useState(0);
const [page, setPage] = useState(1);
useEffect(() => {
console.log("call api first time");
}, []);
useEffect(() => {
console.log("Call api when query changes");
}, [query]);
useEffect(() => {
console.log("Call api when page changes");
}, [page]);
return (
...
);
};
export default App;
第一次安装组件时,您将看到三个日志:
call api first time
call api when query changes
call api when page changes
让我们想象一下,如果您监听许多其他字段的变化,并且在这些字段的每个 useEffect 上触发 API 调用(或任何其他副作用函数),那么在第一次呈现您的应用程序时,将触发许多不必要的 API 调用,这可能会影响应用程序的性能并导致一些您可能不会想到的错误(如果您真的不需要触发所有 API 调用或所有 useEffect 的副作用函数)
解决这个问题的方法有很多,但我先介绍一种常用的方法——也是我最喜欢的一种。你可以创建一个变量来检查组件是否已挂载。
const App = () => {
const [query, setQuery] = useState(0);
const [page, setPage] = useState(1);
const isMounted = useRef(false);
useEffect(() => {
if (isMounted.current) {
console.log("Call api when query changes");
}
}, [query]);
useEffect(() => {
if (isMounted.current) {
console.log("Call api when page changes");
}
}, [page]);
useEffect(() => {
console.log("call api first time");
isMounted.current = true;
}, []);
return (
...
);
};
这是第一次安装的结果
call api first time
还要注意 useEffect 的顺序,我这样安排是有原因的。为了使这个方案有效,你必须将保存第一次渲染/挂载(或任何你想叫它的名字)值的变量放在最后一个 useEffect 中。React 会按以下顺序执行 useEffects:
b. 依赖关系
在上一节中,我提到了传递给 useEffect 的依赖项列表,通过这样做,您可以“监听”依赖项列表中每个元素的任何变化。
这里的问题是:大多数情况下,你会使用对象和函数,如果你将对象/函数类型的变量传递给依赖列表,有时你的程序可能无法按预期运行。让我们考虑以下示例:
import { memo, useState } from "react";
const List = memo((list) => {
useEffect(() => {
console.log("list changed");
}, [list]);
return <ul>{list?.length > 0 && list.map((e) => <li>{e}</li>)}</ul>;
});
const App = () => {
const [a, setA] = useState(0);
const someFunc = () => console.log("This is a random function");
useEffect(() => {
console.log("Use effect of someFunc's called");
}, [someFunc]);
const fakeList = () => ["number 1", "number 2"];
return (
<div className="App">
<h1>Variable a: {a} </h1>
<button onClick={() => setA((v) => v + 1)}>Increase a</button>
<button onClick={someFunc}>call someFunc()</button>
<List list={fakeList} />
</div>
);
};
不,尝试点击“增加”按钮,
我们将得到这个(不是在第一次渲染中)
list changed
Use effect of someFunc's called
每次我们点击“增加 a”按钮,即使我们并没有修改 someFunc 和 fakeList 的值,监听 someFunc 和 list 变化的 useEffect 都会被触发(注意,我用 memo 包裹了 List 组件,以防止它在 props - list 发生变化时重新渲染)。这是因为在比较对象/函数时,React 会比较它们的引用。所以,当点击按钮“增加 a”时,App 组件会重新渲染(由于状态变化)→someFunc 和 fakeList 会更新,所以在每次渲染时,someFunc 和 fakeList 都会有新的引用,因此,React 会标记 someFunc 和 fakeList 已更改,并运行与它们关联的 useEffect。你应该注意这一点,以防止不必要的重新渲染和不必要的 useEffect 触发。
正如我之前提到的,React 会通过引用来比较对象/函数。处理对象/函数类型的依赖时,有两种常见情况需要注意:
- 情况 1:对象/函数相同,但引用不同(我们示例中的情况)。
- 情况 2:对象具有不同的值,但它们的引用是相同的(当您部分更新对象但不触发重新更新操作时会发生这种情况)。
以上两种情况都会影响我们的 useEffect,从而导致意外行为。
有很多解决方案可以避免这些情况,我将向大家介绍我通常使用的方法。
对于第一种情况:记忆化。
是的,为了做到这一点,我们将提出 2 个新的钩子(也许你们之前听说过:useCallback 和 useMemo)。
为了快速参考,你们可以在这里看到这些钩子的区别:useCallback 和 useMemo 之间的区别,或者在官方网站上阅读详细信息:useCallback和useMemo
稍微改变一下我们的代码
import { memo, useCallback, useEffect, useMemo, useState } from "react";
const List = memo((list) => {
useEffect(() => {
console.log("list changed");
}, [list]);
return <ul>{list?.length > 0 && list.map((e) => <li>{e}</li>)}</ul>;
});
const App = () => {
const [a, setA] = useState(0);
const someFunc = useCallback(
() => console.log("This is a random function"),
[]
);
useEffect(() => {
console.log("Use effect of someFunc's called");
}, [someFunc]);
const fakeList = useMemo(() => ["number 1", "number 2"], []);
return (
<div className="App">
<h1>Variable a: {a} </h1>
<button onClick={() => setA((v) => v + 1)}>Increase a</button>
<button onClick={someFunc}>call someFunc()</button>
<List list={fakeList} />
</div>
);
};
export default App;
我用 useCallback 包装了 someFunc (实际上,如果你将 someFunc 作为 useEffect 的依赖项之一,而没有用 useCallback 包装它,如果你的 IDE/文本编辑器集成了 ESLint,你会收到这样的警告:“someFunc”函数使得 useEffect Hook(第 19 行)的依赖项在每次渲染时都会发生变化。要解决这个问题,请将“someFunc”的定义包装在其 useCallback() Hook 中),并用 useMemo 包装了我们的 fakeList 。出于学习目的,我们暂时将 useCallback 和 useMemo 的依赖项列表留空,但在实际项目中使用这些 Hook 时,应该小心它们的依赖项列表。
现在,如果我们运行程序并点击增加按钮,我们将不再看到来自 someFunc 和 list 的 useEffect 的日志(第一次渲染除外)。
⚠️ 代码中的每一行都是有成本的!useCallback 和 useMemo 会消耗程序的内存(因为它需要将值存储在某处),因此在使用这些钩子时应小心,仅在真正必要时才使用它们。
对于第二种情况,我不会举例说明,因为解决这个问题的方法是简单地监听属性而不是对象。
但是 useEffect 依赖列表的最佳实践是,您应该始终尽可能处理原始类型,以避免出现意外结果。
此部分的源代码可以在这里找到:https://codesandbox.io/s/hopeful-cherry-md0db? file=/src/App.js:356-388
c.清理功能
在概述部分,我说过 useEffect 可以完成 componenWillUnmount 生命周期的工作。它是 useEffect 中的返回函数
useEffect(() => {
// do something
return () => {
// do cleanup stu
}
}, []);
返回函数将在下次调用 useEffect 函数之前执行“清理”操作。
因此,在上面的例子中,它相当于在 componentWillUnmount 中执行一些代码,因为上面例子中 useEffect 的形式是 #2,它只在组件第一次渲染后运行一次。
我知道这有点抽象。所以我们会通过一些例子来解释,希望大家看完之后能理解。
const List = () => {
useEffect(() => {
console.log("first render list");
return () => console.log("unmount list");
}, []);
return <h1>This is a list</h1>;
};
const App = () => {
const [isListVisible, setIsListVisible] = useState(true);
useEffect(() => {
return () => console.log("clean up on change isListVisible");
}, [isListVisible]);
return (
<div className="App">
<button onClick={() => setIsListVisible((v) => !v)}>Toggle List</button>
{isListVisible && <List />}
</div>
);
};
每次单击“切换列表”时,您都会看到两个日志:一个来自列表中表单#2的useEffect,另一个来自#3的useEffect监听isListVisible的变化。
那么为什么清理是必要的呢?让我们考虑下面的例子:
让我们稍微改变一下上面的例子:
const List = () => {
useEffect(() => {
setInterval(() => console.log("interval from list"), 1000);
return () => console.log("unmount list");
}, []);
return <h1>This is a list</h1>;
};
const App = () => {
const [isListVisible, setIsListVisible] = useState(true);
useEffect(() => {
return () => console.log("clean up on change isListVisible");
}, [isListVisible]);
return (
<div className="App">
<button onClick={() => setIsListVisible((v) => !v)}>Toggle List</button>
{isListVisible && <List />}
</div>
);
};
我给 List 添加了 setInterval 函数,它会每 1 秒记录一次。但关键在于:即使 List 被卸载,这个间隔函数仍然会运行。
所以,即使组件被卸载了,我们施加到该组件上的一些副作用仍然会运行。在我们的例子中,它只是一个时间间隔,但在现实生活中,如果是一堆 API 调用,一堆其他副作用,想象一下,即使它们的组件被卸载了,它们仍然会运行,那么这可能是一个影响我们应用性能的黑洞。
在我们的示例中,为了解决这个问题,我们可以简单地将 clearInterval 添加到我们的清理函数中:
const List = () => {
useEffect(() => {
const listInterval = setInterval(
() => console.log("interval from list"),
1000
);
return () => {
console.log("unmount list");
clearInterval(listInterval);
};
}, []);
return <h1>This is a list</h1>;
};
我知道如果你是新手,有时你不会注意到清理的东西,但它们确实是必要的,你应该花时间考虑它们。
此部分的代码可以在这里找到:https://codesandbox.io/s/flamboyant-andras-xo86e? file=/src/App.js:69-357
3.总结
好了,我已经深入讲解了 useEffect 的一些内容。除了我提到的注意事项之外,在使用 useEffect 时,你还需要注意更多场景,才能让你的应用以最佳状态运行。所以,请继续学习,如果你有任何问题或更正,请留言,我会仔细查看。谢谢。再见。😈
文章来源:https://dev.to/trunghieu99tt/you-don-t-know-useeffect-4j9h