4、useEffect 依赖数组:依赖项怎么选?空数组、不传依赖、传引用类型,这些坑我都踩过。
说实话,useEffect 这个 Hook,刚上手时觉得挺简单的。不就是副作用嘛,组件挂载了执行一下,更新了再执行一下。
但用着用着就发现不对劲了。
依赖数组这个东西,选对了风平浪静,选错了……嗯,轻则死循环,重则内存泄漏,甚至页面直接卡死。
今天我就把依赖数组的几种常见用法,以及我踩过的坑,一次性给你讲清楚。
依赖数组的三种形态
先看最基础的。useEffect 的第二个参数,就是依赖数组。它有三种传法:
| 传参方式 | 执行时机 | 典型场景 |
|---|---|---|
| 不传依赖 | 每次渲染后都执行 | 几乎不用,除非你明确知道自己在干嘛 |
空数组 [] |
只在组件挂载时执行一次 | 初始化请求、订阅事件 |
传入具体依赖 [a, b] |
依赖变化时执行 | 数据联动、响应式逻辑 |
看起来很简单对吧?但坑就藏在细节里。
空数组:你以为只执行一次,其实可能永远不更新
空数组 [] 表示「这个 effect 不依赖任何值」。所以它只在组件挂载时跑一次。
这个用法最常见的场景就是发请求拿初始数据。
useEffect(() => {
fetchData();
}, []);
但问题来了——如果 fetchData 里用到了某个 state 或 props,那这个值就永远是最初的那个版本。
举个例子:
const [userId, setUserId] = useState(1);
useEffect(() => {
// 这里拿到的 userId 永远是 1
fetchUser(userId);
}, []);
为什么会这样?因为闭包捕获了第一次渲染时的 userId。后面 userId 变了,effect 也不会重新执行。
不传依赖:每次渲染都跑,小心死循环
不传第二个参数,effect 会在每次渲染后都执行。
useEffect(() => {
console.log('每次渲染都执行');
});
这个用法我几乎不用。因为太容易出问题了。
你想想看,如果 effect 里修改了 state,那就会触发重新渲染,重新渲染又触发 effect……
useEffect(() => {
setCount(count + 1); // 死循环!
});
页面直接卡死。我在项目中遇到过两次这种情况,一次是同事写的,一次是我自己写的……嗯,都是血的教训。
传引用类型:你以为变了,React 觉得没变
这个坑最隐蔽,也最容易踩。
看这段代码:
const config = { url: '/api/user', method: 'GET' };
useEffect(() => {
fetch(config.url, { method: config.method });
}, [config]);
你觉得这个 effect 会执行几次?
答案是:每次渲染都执行。
因为 config 是对象,每次渲染都会创建一个新的对象引用。React 比较依赖时用的是 Object.is,两个不同的对象引用,肯定不相等。
所以 effect 每次都会执行。
那怎么解决?
有三种方式:
- 把对象拆开传:只传基本类型的属性
- 用 useMemo 缓存对象:让引用稳定
- 把对象定义在组件外部:完全不参与渲染
// 方式一:拆开传
useEffect(() => {
fetch(url, { method });
}, [url, method]);
// 方式二:用 useMemo 缓存
const config = useMemo(() => ({ url, method }), [url, method]);
useEffect(() => {
fetch(config.url, { method: config.method });
}, [config]);
// 方式三:定义在组件外部(如果值不变)
const CONFIG = { url: '/api/user', method: 'GET' };
useEffect(() => {
fetch(CONFIG.url, { method: CONFIG.method });
}, []);
函数作为依赖:又一个常见陷阱
函数和对象一样,每次渲染都是新的引用。
const handleClick = () => {
console.log('clicked');
};
useEffect(() => {
window.addEventListener('click', handleClick);
return () => window.removeEventListener('click', handleClick);
}, [handleClick]); // 每次渲染都执行!
解决方案也很简单:用 useCallback 包裹函数。
const handleClick = useCallback(() => {
console.log('clicked');
}, []); // 依赖不变,函数引用就不变
useEffect(() => {
window.addEventListener('click', handleClick);
return () => window.removeEventListener('click', handleClick);
}, [handleClick]);
避坑指南:我总结的几条原则
踩了这么多坑,我给自己定了几条规矩,分享给你:
- 能不传就不传:如果 effect 里没用任何外部变量,就用空数组
- 用了就一定要传:effect 里用到的所有 state、props、函数、对象,都要列在依赖数组里
- 引用类型要小心:对象、数组、函数,用 useMemo / useCallback 稳定引用
- 不要骗 React:别为了省事故意漏掉依赖,后果很严重
总结
依赖数组的选择,说白了就是回答一个问题:这个 effect 依赖哪些值?
回答清楚了,依赖数组就写对了。
空数组适合初始化逻辑,具体依赖适合响应式逻辑,不传依赖……嗯,我建议你忘掉这个用法。
记住一句话:依赖数组不是摆设,它是你和 React 之间的契约。你告诉 React 依赖了什么,React 才能正确地决定什么时候重新执行。
契约精神,在写代码时同样重要。