深入理解 useEffect 的依赖数组
目录
useEffect 的依赖数组(dependency array)是 React 文档里反复强调"不要骗它"的东西。理解它的判定规则,能少踩 80% 的坑——剩下 20% 是竞态问题,本文也一并讲清楚。
依赖数组是什么
|
|
依赖数组只做一件事:告诉 React 什么时候重新执行副作用。 每次渲染后,React 会比较新旧依赖数组,只要有一个元素用 Object.is 判断不相等,就:
- 先执行上一次的清理函数(如果有)
- 再执行新的副作用
Object.is 判定的细节
sameValueZero(Object.is 的变体)意味着:
| 比较 | 结果 | 解释 |
|---|---|---|
1 === 1 |
相等 | 原始值按值比较 |
NaN === NaN |
相等(Object.is 视 NaN 相等) |
与 === 不同 |
{} === {} |
不相等 | 对象按引用比较 |
fn1 === fn2(每次渲染新建) |
不相等 | 函数按引用比较 |
关键推论:依赖数组里放对象 / 函数 / 数组,每次渲染都是新引用 → 每次都会触发 effect。这是海量 Bug 的来源。
三个常见陷阱
陷阱 1:忘记加依赖(闭包陷阱)
|
|
count 永远是首次渲染闭包里的 0,setCount(count + 1) 会一直把状态设成 1。
正确做法:用函数式更新,让 setState 自己读到最新值:
|
|
或:把 count 加进依赖数组(每次 count 变化重建定时器):
|
|
陷阱 2:依赖每次渲染都变(引用不稳定)
|
|
父组件只要重新渲染,props.obj 就是新对象,effect 永远达不到"稳定"状态,反复触发甚至死循环。
解法:用 useMemo 稳定引用,或依赖原始值:
|
|
陷阱 3:函数依赖导致反复重跑
|
|
解法是 useCallback 稳定函数:
|
|
竞态问题(最容易忽略的坑)
异步请求 + 用户快速切换,旧请求可能后返回并覆盖新结果:
|
|
原理:effect 重新执行前,React 会先调用上一次的清理函数——把 cancelled 置 true,旧请求的 then 回调即使晚到也不会污染状态。
这是"不要骗依赖"之外的进阶版:即使依赖填对了,异步竞态依然存在。接口请求、轮询、订阅这三类副作用务必带上清理逻辑。
StrictMode 下 effect 执行两次(开发模式)
React 18+ 的 <StrictMode> 在开发模式下会故意挂载 → 卸载 → 再挂载,让 effect 跑两遍:
|
|
这是故意的,目的是暴露"忘记写清理函数"的问题(比如订阅、监听器没解绑)。不要惊慌,也不要靠 useRef 强行跳过——写对清理函数,生产环境只会跑一次。
exhaustive-deps:让 ESLint 帮你把关
eslint-plugin-react-hooks 的 exhaustive-deps 规则会自动检查依赖是否写全:
|
|
|
|
当它提示 React Hook useEffect has missing dependencies,不要靠 // eslint-disable 蒙混——那等于把 bug 留下来。正确流程:
- 看提示缺了谁
- 问自己:这个值真的应该触发 effect 吗?
- 是 → 加进依赖;不是 → 用
useCallback/useMemo稳定它,或函数式更新化掉
旧 React 时代有人教"依赖数组删掉就是只在挂载时跑一次"——那是误用。真正"只在挂载跑一次"的场景应该没有外部依赖,写了依赖就不该删。
与 useLayoutEffect 的区别
useEffect |
useLayoutEffect |
|
|---|---|---|
| 执行时机 | 浏览器绘制后(异步) | DOM 更新后、绘制前(同步) |
| 适用场景 | 数据请求、订阅、埋点 | 测量 DOM、同步改样式 |
| 闪烁风险 | 无 | 会阻塞绘制 |
| SSR | 不执行 | 警告(SSR 下不执行) |
|
|
绝大多数场景用 useEffect 就够;只有"改完 DOM 之前必须测量/修正"才需要 useLayoutEffect。
结论
依赖数组不是"性能优化开关",而是"同步契约"。不要靠删依赖来"修"问题,要搞清楚副作用真正依赖什么。
把依赖数组当作函数参数来思考,大部分疑难 Bug 都能迎刃而解:
- 依赖什么就写什么,原始值优先
- 对象/函数用 useCallback / useMemo 稳定引用
- 有副作用就有清理,异步请求务必防竞态
- 交给
exhaustive-deps提醒,但理解它为什么提醒