告别无限循环:深入理解并征服 React Hooks 的依赖数组
你的 useEffect 又双叒叕无限循环了?也许问题就出在那个不起眼的依赖数组上。
作为 React 开发者,我们每天都在使用 Hooks。useEffect, useCallback, useMemo 极大地提升了函数组件的表达能力,但它们都离不开一个核心概念------依赖数组。
这个看似简单的数组,却是许多 Bug 和性能问题的根源。今天,我们就来深入剖析它,帮你彻底告别依赖数组带来的困扰。
1. 依赖数组的本质:捕捉"快照"还是订阅"动态值"?
一个常见的误解是:依赖数组里的值,是函数组件在 mount 时捕获的一个"快照"。这是错误的!
实际上,每次渲染都是一个独立的"快照",而依赖数组的作用是告诉 React:"请比较这次渲染和上次渲染时,数组里的值是否有变化。如果有,请重新执行我的副作用/回调/记忆化计算。"
让我们看一个经典的无限循环案例:
jsx
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
// 模拟 API 调用
fetchUser(userId).then(setUser);
}, [user]); // 🚨 错误!将 user 作为依赖
return <div>{user ? user.name : 'Loading...'}</div>;
}
发生了什么?
- 组件挂载,
user为null,useEffect执行。 fetchUser完成,setUser更新了状态。- 状态更新触发重新渲染,新的
user对象生成。 - React 对比依赖数组
[user],发现user从null变成了一个对象(引用变化),于是再次执行useEffect。 - 回到第2步... 无限循环诞生!
2. 正确的依赖项管理:ESLint 是你的朋友
React 提供了一个强大的 ESLint 规则:eslint-plugin-react-hooks,其中的 exhaustive-deps 规则会强制你声明所有必要的依赖。
永远不要禁用这个规则! 它是防止遗漏依赖导致 Bug 的最佳防线。
案例:修复无限循环
上面的问题如何解决?我们需要思考:我们真的想在 user 改变时重新获取用户吗?不,我们只想在 userId 改变时获取。
正确做法:
jsx
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]); // ✅ 正确:只在 userId 变化时重新获取
return <div>{user ? user.name : 'Loading...'}</div>;
}
3. 依赖数组的"陷阱"与"解药"
陷阱一:对象和函数的"引用变化"
在 JavaScript 中,对象、数组、函数每次重新渲染时都会生成新的引用。
jsx
function MyComponent() {
const [count, setCount] = useState(0);
const config = { type: 'increment' }; // 🚨 每次渲染都是新对象
useEffect(() => {
console.log('Config changed'); // 会每次都执行!
doSomething(config);
}, [config]); // config 的引用每次都不同
return <button onClick={() => setCount(c => c + 1)}>Click {count}</button>;
}
解药:useMemo 和 useCallback
使用 useMemo 来缓存对象/数组,使用 useCallback 来缓存函数。
jsx
function MyComponent() {
const [count, setCount] = useState(0);
const config = useMemo(() => ({ type: 'increment' }), []); // ✅ 依赖为空,只创建一次
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []); // ✅ 依赖为空,函数身份稳定
useEffect(() => {
console.log('Config changed'); // 只会在首次渲染时执行
doSomething(config);
}, [config]);
return <button onClick={handleClick}>Click {count}</button>;
}
陷阱二:状态更新依赖当前状态
当在 useEffect 中更新状态,且这个更新依赖于之前的状态时,你可能会想把状态写入依赖。
jsx
const [count, setCount] = useState(0);
useEffect(() => {
const intervalId = setInterval(() => {
setCount(count + 1); // 🚨 依赖 count,但 interval 里拿到的永远是初始的 0
}, 1000);
return () => clearInterval(intervalId);
}, [count]); // 导致 interval 被不断重置
解药:使用函数式更新
setState 可以接受一个函数,该函数接收先前的状态作为参数。
jsx
const [count, setCount] = useState(0);
useEffect(() => {
const intervalId = setInterval(() => {
setCount(c => c + 1); // ✅ 使用 c,它总是最新的状态
}, 1000);
return () => clearInterval(intervalId);
}, []); // ✅ 依赖为空,interval 只设置一次
陷阱三:不必要的依赖导致过度重渲染
有时 ESLint 会提示你加入一个依赖,但这个依赖的变化其实并不需要触发副作用。
jsx
function MyComponent({ data, onSuccess }) {
useEffect(() => {
if (data) {
doSomething(data);
onSuccess(); // 🚨 ESLint 会提示将 onSuccess 加入依赖
}
}, [data]); // ❌ ESLint: `React Hook useEffect has a missing dependency: 'onSuccess'`
}
如果 onSuccess 是父组件传下来的 prop,且每次父组件渲染都会创建一个新的 onSuccess,那么把它加入依赖会导致 useEffect 过度执行。
解药:使用 useCallback 稳定父组件的函数 / 使用 useRef
方案A (推荐): 让父组件用 useCallback 包装 onSuccess。
jsx
// 父组件
const onSuccess = useCallback(() => {
// ... 逻辑
}, []); // 确保依赖正确
<MyComponent data={data} onSuccess={onSuccess} />
方案B (谨慎使用): 在 Effect 内部使用 Ref 来持有最新的函数,但不将其作为依赖触发执行。
jsx
function MyComponent({ data, onSuccess }) {
const onSuccessRef = useRef(onSuccess);
// 保持 ref 的值始终是最新的
useEffect(() => {
onSuccessRef.current = onSuccess;
});
useEffect(() => {
if (data) {
doSomething(data);
onSuccessRef.current(); // ✅ 通过 ref 调用,避免将其加入依赖
}
}, [data]); // ✅ 依赖清晰
}
这是一个高级模式,需要谨慎处理,但它能有效解决"不稳定的依赖"问题。
4. 最佳实践总结
- 信任 ESLint : 永远遵循
exhaustive-deps规则。 - 心智模型转换: 依赖数组不是"快照",而是"重新执行的触发条件"。
- 稳定依赖 : 使用
useMemo、useCallback来稳定对象、数组和函数的引用。 - 函数式更新 : 当新状态依赖于旧状态时,使用
setState(c => ...)。 - 移除不必要的依赖 : 通过重构 Effect 依赖、使用
useRef或要求父组件稳定 props 来减少不必要的依赖。 - 保持 Effect 简单: 如果一个 Effect 做了多件不相关的事,考虑拆分成多个 Effect。
结语
理解并掌握 React Hooks 的依赖数组,是编写高效、无 Bug 的 React 函数组件的关键。它要求我们从"生命周期"的思维模式,彻底转向"同步副作用"与"渲染结果"的思维模式。
希望本文能帮你理清思路,下次当你的控制台出现那个熟悉的 "Warning: Maximum update depth exceeded" 时,你能自信地找到问题所在并解决它!
你觉得在管理 Hooks 依赖时,最大的挑战是什么?欢迎在评论区分享你的经历和技巧!