
一句话:依赖数组的规则是「把副作用里用到的每一个响应式值都列进去」;三类错误是「漏依赖(逻辑错误)、多依赖(性能问题)、不稳定依赖(完全失效)」,而
exhaustive-deps的警告几乎总是对的。
一、依赖数组的作用
useEffect(fn, deps); // deps 变化 → 先执行旧 fn 的清理,再执行新 fn
useMemo(fn, deps); // deps 变化 → 重新计算
useCallback(fn, deps); // deps 变化 → 返回新函数
useImperativeHandle(ref, fn, deps);
useLayoutEffect(fn, deps); // 同 useEffect
React 的比较方式:
① 逐个元素用 Object.is 比较新旧依赖
② 只要有【一个】不同 → 认为依赖「变了」
③ 依赖数组的长度也必须一致(长度变化会警告)
「响应式值」的定义(官方):
props、state、以及「在组件体内声明的变量与函数」
→ 它们每次渲染都可能变化,因此需要列进依赖
不需要列进依赖的:
· 组件外部(模块作用域)的常量与函数
· setState / dispatch(React 保证引用稳定)
· ref 对象本身(ref.current 的读写不需要依赖,因为它不参与渲染)
二、错误一:漏依赖(逻辑错误)
// ❌ 漏了 id
function UserProfile({ id }: { id: string }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(id).then(setUser); // ⚠️ id 变了但不会重新请求
}, []); // ❌ 应该写 [id]
return {user?.name};
}
// 现象:切换用户时,页面显示的仍是上一个用户
// ❌ 漏了外部函数
function Comp({ onSave }: { onSave: () => void }) {
useEffect(() => {
onSave(); // ⚠️ onSave 变了但不会重新执行
}, []);
}
| 漏掉的东西 | 表现 |
|---|---|
| props | 切换 props 后 Effect 不重跑(数据不更新) |
| state | 闭包陷阱(读到旧值) |
| 组件内定义的函数 | 函数逻辑变化后行为不更新 |
| 组件内定义的对象/数组 | 依赖它们的计算不更新 |
// ✅ 正确写法
useEffect(() => {
fetchUser(id).then(setUser);
}, [id]);
三、错误二:多依赖(性能问题)
// ⚠️ 依赖了「用不到的值」→ 不必要的重新执行
useEffect(() => {
console.log(a);
}, [a, b]); // ⚠️ b 变化也会重新执行(虽然没用到 b)
// ⚠️ 依赖了整个对象,但只用到其中一项
useEffect(() => {
console.log(user.name);
}, [user]); // ⚠️ user 的任何字段变化都会触发(而且通常是新对象 → 每次触发)
// ✅ 依赖「具体用到的那一项」
useEffect(() => {
console.log(user.name);
}, [user.name]);
多依赖的典型来源:
① 为了「避免警告」把一堆东西都塞进去
② 依赖了整个对象/数组(而实际只用一个字段)
③ 依赖了「每次渲染新建的对象/函数」(既是多依赖,也是不稳定依赖)
四、错误三:不稳定依赖(完全失效)
// ❌ 依赖了「每次渲染都新建的对象字面量」
useEffect(() => {
doSomething(config);
}, [{ url: '/api', method: 'GET' }]); // ⚠️ 每次渲染都是新对象 → Effect 每次都执行
// ❌ 依赖了「每次渲染都新建的内联函数」
useEffect(() => {
doSomething(onChange);
}, [() => {}]); // ⚠️ 同样每次都执行
// ❌ 依赖了「上游传下来的内联函数」(上游每次都新建)
function Parent() {
return console.log('x')} />; // ⚠️ 每次渲染都是新函数
}
function Child({ onChange }: { onChange: () => void }) {
useEffect(() => {
onChange();
}, [onChange]); // ⚠️ 每次渲染都执行
}
// ✅ 解法一:把常量提到组件外(最省事,适用于「纯常量」)
const CONFIG = { url: '/api', method: 'GET' };
function Comp() {
useEffect(() => {
doSomething(CONFIG);
}, []); // ✅ 常量不需要依赖
}
// ✅ 解法二:依赖「原始值」而不是对象
useEffect(() => {
doSomething({ url, method });
}, [url, method]); // ✅ 只在 url / method 变化时执行
// ✅ 解法三:用 useMemo / useCallback 稳定引用
const config = useMemo(() => ({ url, method }), [url, method]);
useEffect(() => {
doSomething(config);
}, [config]); // ✅ 只有 url / method 变化时 config 才变
const handleChange = useCallback(() => {
console.log(value);
}, [value]);
useEffect(() => {
handleChange();
}, [handleChange]); // ✅ 只有 value 变化时执行
// ✅ 解法四:把函数定义移到 Effect 内部(最推荐,除非它需要在别处复用)
useEffect(() => {
function handleChange() {
console.log(value);
}
handleChange();
}, [value]); // ✅ 依赖变成函数用到的值
| 不稳定依赖的来源 | 解法 |
|---|---|
| 对象/数组字面量 | 提到组件外 / 依赖字段 / useMemo |
| 内联箭头函数 | useCallback / 移到 Effect 内 / 提出组件外 |
| 上游传入的内联函数 | 让上游稳定(useCallback) |
| 上游传入的对象 | 让上游 useMemo,或在子组件依赖具体字段 |
useSelector 返回的新对象 |
用 shallowEqual 或选择器拆细 |
五、函数依赖的三种处理方式
// 情形:Effect 里用到了某个函数
// ① 函数只在 Effect 内部用 → 移进去(推荐)
useEffect(() => {
function fetchData() {
return api.get(`/users/${userId}`);
}
fetchData().then(setData);
}, [userId]);
// ② 函数需要在多处复用 → useCallback 稳定它
const fetchData = useCallback(() => api.get(`/users/${userId}`), [userId]);
useEffect(() => {
fetchData().then(setData);
}, [fetchData]); // ✅ fetchData 只在 userId 变化时变
// ③ 函数来自 props 且上游无法稳定 → 用 ref 保存
function Child({ onLoad }: { onLoad: () => void }) {
const onLoadRef = useRef(onLoad);
useEffect(() => { onLoadRef.current = onLoad; });
useEffect(() => {
onLoadRef.current(); // ✅ 引用稳定 + 读最新
}, []); // 依赖为空
}
六、exhaustive-deps 的正确态度
// eslint.config.js
{
"rules": {
"react-hooks/exhaustive-deps": "warn" // 至少是 warn
}
}
✅ 正确态度:
① 警告几乎总是对的——它是「闭包陷阱」的自动检测
② 看到警告不要立刻 disable,先分析:
· 漏了真正需要的依赖 → 补上
· 依赖会导致无限循环 → 说明依赖里有不稳定对象 → 修它
· 依赖会导致副作用频繁重建 → 用 useRef 保存最新值
· 这段逻辑本就不该在 Effect 里 → 移出去
③ 确实需要 disable 时,写清楚原因:
// eslint-disable-next-line react-hooks/exhaustive-deps -- 仅需在挂载时执行一次,xxx 用 ref 读取
// ❌ 典型的「用 disable 掩盖问题」
useEffect(() => {
setCount(count + 1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 逻辑依然是错的
// ✅ 正确:用函数式更新
useEffect(() => {
setCount((c) => c + 1);
}, []);
七、依赖数组的常见「修复」验证清单
加完依赖后,用这三个问题自测:
① 「依赖变了,这个副作用真的应该重跑吗?」
❌ 不应该 → 说明依赖写多了,或者逻辑放错地方了
② 「依赖没变,副作用会不会漏掉必要的更新?」
❌ 会 → 说明漏依赖了
③ 「加完依赖后会不会死循环 / 频繁重跑?」
✅ 会 → 依赖里有「每次渲染都新建的值」→ 需要稳定它
// 一个完整的排查示例
function Search({ query, onResults }: Props) {
const [results, setResults] = useState([]);
// ❌ 问题版本
useEffect(() => {
const filtered = filterData(query);
setResults(filtered);
onResults(filtered); // ⚠️ onResults 是用到的外部函数
}, [query]); // ⚠️ 漏了 onResults
// ✅ 修复版本(假设 onResults 上游用 useCallback 稳定了)
useEffect(() => {
const filtered = filterData(query);
setResults(filtered);
onResults(filtered);
}, [query, onResults]);
// ✅ 如果 onResults 上游无法稳定 → 用 ref
const onResultsRef = useRef(onResults);
useEffect(() => { onResultsRef.current = onResults; });
useEffect(() => {
const filtered = filterData(query);
setResults(filtered);
onResultsRef.current(filtered);
}, [query]);
}
八、常见坑
// ① 依赖数组写成「值而不是数组」
useEffect(() => {}, dep); // ❌ 应该是 [dep](传非数组会每次都执行)
// ② 依赖数组长度变化
useEffect(() => {}, cond ? [a] : [a, b]); // ⚠️ 长度不稳定(React 会警告)
// ③ 依赖了「未定义的变量」(会报错)
useEffect(() => {}, [undefinedVar]); // ❌
// ④ 依赖了「ref.current」(不该依赖它)
useEffect(() => {}, [ref.current]); // ⚠️ ref 变化不触发渲染,依赖它没意义
// ✅ 依赖 ref 对象本身(或者不要依赖)
// ⑤ 在依赖里写「函数调用」
useEffect(() => {}, [getValue()]); // ❌ 每次渲染都执行 getValue()
// ⑥ 依赖了「setState / dispatch」
useEffect(() => {}, [setCount]); // ⚠️ 它们稳定,写了无害但多余
// ⑦ 依赖了「每次渲染新建的数组」
useEffect(() => {}, [items.map((i) => i.id)]); // ❌ 每次都是新数组
// ⑧ 认为「依赖数组是性能优化手段」
// → 它的作用是「语义」(什么时候重新同步),不是优化
// ⑨ 为了让 Effect「只跑一次」而删依赖
// → 制造闭包陷阱
// ⑩ 在 useMemo 的依赖里漏了「在计算中读到的 props」
const v = useMemo(() => a + b, [a]); // ❌ 漏了 b
// ⑪ 加了 useCallback 但依赖里放了「新对象」→ 引用还是不稳定
const fn = useCallback(() => f({ a }), [{ a }]); // ❌
// ⑫ 认为「加 useMemo 就能让它稳定」(但依赖不稳定时无效)
九、面试延伸
- 「
useEffect的依赖数组有什么用?」
它的作用是语义而非性能:告诉 React「这个副作用依赖哪些值」,从而在依赖变化时重新同步(先执行上一次的清理,再执行新的)。三种写法分别是「不写(每次渲染都执行)」「[](只在挂载时执行)」「[deps](依赖变化时重新执行)」。它不是「优化开关」——写[]不会让代码更快,只会改变「什么时候执行」。 -
「漏写依赖会怎样?」
会产生闭包陷阱(stale closure):副作用捕获的是「某次渲染的变量快照」,后续变化不会反映进去。典型表现是「setInterval里读到的永远是初始值」「切换 id 后不重新请求」「事件回调里用的是旧 state」。eslint-plugin-react-hooks的exhaustive-deps规则就是用来检测这类问题的,它的警告几乎总是对的。 -
「为什么加了依赖会导致无限循环?」
因为依赖里出现了「每次渲染都新建的值」——对象/数组字面量、内联箭头函数、useSelector返回的新对象。Effect 执行 → 修改 state → 重新渲染 → 那个值又是新引用 → React 认为依赖变了 → 再执行 → 死循环。解法:① 把常量提到组件外;② 依赖具体字段(user.id而不是user);③ 用useMemo/useCallback稳定引用;④ 把函数移到 Effect 内部。 -
「依赖了整个对象,但只用到一个字段,该怎么处理?」
有三种做法:① 依赖那个字段([user.name])——最推荐,最精确;② 如果上游能保证对象引用稳定,依赖整个对象也没问题;③ 如果对象经常新建又必须传整个对象,可以让上游用useMemo稳定它。核心原则是「依赖的粒度越细越好」——依赖越细,副作用被不必要地重建的概率越低。 -
「函数依赖怎么处理?」
三种方式:① 函数只在 Effect 里用 → 把定义移进 Effect 内部(推荐,依赖就变成函数用到的值);② 函数需要在多处复用 → 用useCallback稳定它,然后把它列进依赖;③ 函数来自 props 且上游无法稳定 → 用useRef保存最新值(useEffect(() => { ref.current = fn; })),Effect 依赖写[],调用时读ref.current。 -
「
exhaustive-deps的警告要不要理?」
要理。它检测的正是「漏依赖 → 闭包陷阱」这类 bug,警告几乎总是对的。看到警告应该按这个顺序处理:① 漏了真正需要的依赖 → 补上;② 加上会导致无限循环 → 说明依赖里有不稳定对象 → 先修它;③ 加上会导致副作用频繁重建(性能问题) → 改用useRef保存最新值;④ 这段逻辑本就不该在 Effect 里(派生数据、事件响应)→ 移出去。只有在确实需要「只执行一次」时才 disable,并且要写清原因。 -
「为什么
ref和setState不需要写进依赖?」
因为它们引用稳定:useRef返回的对象在整个组件生命周期内是同一个引用,setState与dispatch也由 React 保证引用不变。所以把它们写进依赖数组是无害的(但多余)。另外ref.current也不应该写进依赖——因为修改ref.current不会触发渲染,把它作为依赖不会产生任何有意义的行为(exhaustive-deps也会提示你「ref.current 通常不应该在依赖里」)。 -
「依赖数组的设计哲学是什么?」
核心是「让 Effect 与它所依赖的数据保持一致的同步语义」:Effect 应该能正确反映「任何一次渲染中它看到的状态」——也就是说,如果某个值变化了而 Effect 没有重新执行,那么 Effect 里的逻辑就是过期的。从这个角度看,依赖数组不是「性能调优」而是「正确性保证」:写全依赖保证正确,再通过「稳定引用 / 用 ref 读最新值 / 用函数式更新」来优化执行频率,而不是反过来「先删依赖、再去修 bug」。
一句话速记
依赖数组是语义不是优化:列全副作用里用到的每一个响应式值(props、state、组件内声明的变量与函数),
setState/dispatch/ref对象本身不需要写、ref.current不该写;三类错误是「漏依赖(闭包陷阱)」「多依赖(不必要的重跑)」「不稳定依赖(完全失效)」;修不稳依赖的四招是「提到组件外 → 依赖具体字段 →useMemo/useCallback稳定 → 函数移进 Effect」;exhaustive-deps的警告几乎总是对的,不要靠 disable 掩盖,实在需要就写清原因。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!