React 中 useEffect 的依赖数组到底该怎么写?
一句话总结:依赖数组写的是「effect 里用到的所有外部响应式值」,漏写了会闭包陷阱,多写了会多余执行。拿不准就用 ESLint exhaustive-deps 规则,别靠猜!
正文目录
- 依赖数组的本质:什么时候 effect 该重新执行
- 5 大高频踩坑场景 & 修复代码
- 万能兜底:依赖数组决策流程
- 预防 checklist(不再踩坑)
- 一句话总结
一、依赖数组的本质:什么时候 effect 该重新执行
useEffect(() => { // 副作用逻辑 return () => { /* 清理 */ }; }, [dep1, dep2]); // 依赖数组核心规则:effect 里用到的所有外部变量(props、state、context、从 Hook 返回的值),只要可能变化,就必须放进依赖数组。
| 依赖数组 | 执行时机 |
|---|---|
| 不传数组 | 每次渲染都执行 |
[]空数组 | 只在挂载时执行一次 |
[a] | a 变化时执行 + 挂载时执行一次 |
[a, b] | a 或 b 变化时执行 + 挂载时执行一次 |
一句话:React 不帮你自动追踪依赖,你必须手动声明。漏了 = 闭包陷阱,多了 = 多余执行。
二、5 大高频踩坑场景 & 修复代码
① 空依赖数组:effect 里用了 state 但没加依赖
// ❌ 空依赖,但 effect 里用了 count → 闭包陷阱 function Counter() { const [count, setCount] = useState(0); useEffect(() => { const timer = setInterval(() => { console.log(count); // ❌ 永远打印 0 setCount(count + 1); // ❌ 永远设成 1 }, 1000); return () => clearInterval(timer); }, []); // ❌ 空依赖,count 不会被更新 }修复方案一:把 count 加进依赖
// ✅ 加依赖,但定时器每次重建 function Counter() { const [count, setCount] = useState(0); useEffect(() => { const timer = setInterval(() => { setCount(count + 1); // ✅ 拿到最新 count }, 1000); return () => clearInterval(timer); }, [count]); // ✅ count 变化时重建定时器 }修复方案二(推荐):函数式更新,不需要 count 依赖
// ✅ 函数式更新,空依赖即可 function Counter() { const [count, setCount] = useState(0); useEffect(() => { const timer = setInterval(() => { setCount(prev => prev + 1); // ✅ 拿到最新值,不需要 count 依赖 }, 1000); return () => clearInterval(timer); }, []); // ✅ 空依赖,定时器只建一次 }② 函数依赖:函数每次重新创建,effect 不断执行
// ❌ 回调函数没用 useCallback,每次渲染新函数 → effect 每次执行 function Search({ query }) { const [results, setResults] = useState([]); const fetchResults = () => { // ❌ 每次渲染都是新函数 fetch(`/api/search?q=${query}`).then(r => r.json()).then(setResults); }; useEffect(() => { fetchResults(); // ❌ fetchResults 变了 → effect 每次执行 }, [fetchResults]); // ❌ 依赖不稳定 }修复:用 useCallback 稳定函数引用,或把函数移进 effect
// ✅ 方案一:把逻辑移进 effect 内部 function Search({ query }) { const [results, setResults] = useState([]); useEffect(() => { fetch(`/api/search?q=${query}`) .then(r => r.json()) .then(setResults); }, [query]); // ✅ 只依赖原始值 query } // ✅ 方案二:用 useCallback 稳定引用 function Search({ query }) { const [results, setResults] = useState([]); const fetchResults = useCallback(() => { fetch(`/api/search?q=${query}`).then(r => r.json()).then(setResults); }, [query]); // ✅ query 不变,函数引用不变 useEffect(() => { fetchResults(); }, [fetchResults]); // ✅ 依赖稳定 }③ 对象依赖:依赖数组放了引用不稳定的对象
// ❌ 依赖对象每次新建 → effect 每次执行 function UserList({ filter }) { const [users, setUsers] = useState([]); useEffect(() => { fetchUsers(filter).then(setUsers); }, [filter]); // ❌ 如果 filter 是父组件每次新建的对象 → effect 每次执行 // 父组件 <UserList filter={{ role: 'admin', page: 1 }} /> ❌ 每次新对象 }修复:依赖原始值,而非对象
// ✅ 依赖原始值 function UserList({ role, page }) { const [users, setUsers] = useState([]); useEffect(() => { fetchUsers({ role, page }).then(setUsers); }, [role, page]); // ✅ 只依赖原始值 } // ✅ 如果父组件传对象,在 effect 内部取属性 function UserList({ filter }) { const [users, setUsers] = useState([]); useEffect(() => { fetchUsers(filter).then(setUsers); }, [filter.role, filter.page]); // ✅ 依赖原始值属性 }④ 异步请求竞态:依赖变化时旧请求覆盖新数据
// ❌ 依赖变化时,旧请求可能后返回,覆盖新数据 function Profile({ userId }) { const [user, setUser] = useState(null); useEffect(() => { fetch(`/api/users/${userId}`) .then(r => r.json()) .then(data => { setUser(data); // ❌ 如果 userId 快速变化,旧请求后回来覆盖新数据 }); }, [userId]); }修复:用 AbortController 或 cancelled 标志
// ✅ 用 cancelled 标志 function Profile({ userId }) { const [user, setUser] = useState(null); useEffect(() => { let cancelled = false; fetch(`/api/users/${userId}`) .then(r => r.json()) .then(data => { if (!cancelled) setUser(data); // ✅ 只有最新请求才更新 }); return () => { cancelled = true; }; // ✅ 清理:标记取消 }, [userId]); } // ✅ 用 AbortController(更优雅,还能真正取消请求) function Profile({ userId }) { const [user, setUser] = useState(null); useEffect(() => { const controller = new AbortController(); fetch(`/api/users/${userId}`, { signal: controller.signal }) .then(r => r.json()) .then(setUser) .catch(err => { if (err.name !== 'AbortError') console.error(err); }); return () => controller.abort(); // ✅ 真正取消请求 }, [userId]); }⑤ 不传依赖数组:effect 每次渲染都执行
// ❌ 不传依赖数组,每次渲染都执行 function Timer({ count }) { useEffect(() => { console.log('effect executed'); // ❌ 每次渲染都打印 document.title = `Count: ${count}`; }); // ❌ 没有依赖数组 }修复:加依赖数组
// ✅ 只在 count 变化时执行 function Timer({ count }) { useEffect(() => { console.log('effect executed'); // ✅ 只在 count 变化时打印 document.title = `Count: ${count}`; }, [count]); // ✅ 依赖数组 }三、万能兜底:依赖数组决策流程
effect 里用了外部变量吗? ├── 没有 → [] └── 有 → 这个变量会变吗? ├── 不会(常量、导入的函数)→ 不用加 └── 会(state、props、context、Hook 返回值)→ 必须加 └── 加了之后每次渲染都执行? ├── 是 → 依赖的引用不稳定 │ ├── 函数 → 用 useCallback 包裹 │ ├── 对象 → 依赖原始值属性 │ └── 数组 → 依赖原始值元素 └── 否 → 正确快速参考表:
| 依赖值类型 | 需要加依赖? | 原因 |
|---|---|---|
| useState 的值 | ✅ 是 | 渲染间会变 |
| setState 函数 | ❌ 否 | React 保证引用稳定 |
| props | ✅ 是 | 父组件传入的会变 |
| 从 Hook 返回的值 | ✅ 是 | 可能变化 |
| 从 Hook 返回的函数 | 看情况 | 如果用 useCallback 包了就稳定 |
| 模块级常量 | ❌ 否 | 永远不变 |
| 组件内定义的普通变量 | ✅ 是 | 每次渲染新建 |
| 从 useRef 读的 .current | ❌ 否 | 不是响应式的(但在 effect 里读是安全的) |
万能模板:
import { useState, useEffect, useCallback } from 'react'; function useFetch(url, options) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // ✅ 依赖只放原始值 useEffect(() => { let cancelled = false; setLoading(true); setError(null); fetch(url, options) .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) .then(json => { if (!cancelled) setData(json); // ✅ 防竞态 }) .catch(err => { if (!cancelled) setError(err); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; // ✅ 清理 }, [url]); // ✅ 只依赖原始值 return { data, loading, error }; }四、预防 checklist
- 开启 ESLint
react-hooks/exhaustive-deps规则,自动检查依赖 - effect 里用到的所有响应式外部变量都必须放进依赖数组
- state 更新用函数式:
setCount(prev => prev + 1),避免依赖 state 本身 - 函数依赖用 useCallback稳定引用,或把逻辑移进 effect 内部
- 对象依赖改依赖原始值属性:
[filter.role, filter.page]而非[filter] - 异步请求必须处理竞态:cancelled 标志或 AbortController
- 不传依赖数组 = 每次渲染都执行,绝大多数情况是 bug
setState函数本身不需要加依赖,React 保证其引用稳定- 模块级常量和导入的纯函数不需要加依赖
- 依赖写多了比写少了安全,写少了 = 闭包陷阱,写多了最多多执行几次
五、一句话总结
「依赖数组 = effect 用到的所有响应式值」。漏了闭包陷阱,多了多余执行。state 用函数式更新避依赖,函数用 useCallback 稳定引用,对象拆成原始值属性,异步请求必防竞态。拿不准就开 ESLint exhaustive-deps,别靠猜!
最后问候亲爱的朋友们,并邀请你们阅读我的全新著作
📚 《React 进阶实战》