React Hooks 闭包陷阱:我被 useEffect 依赖项坑了3次后终于搞懂了
说实话,我第一次用 React Hooks 的时候,内心是拒绝的。明明看起来很简单的代码,怎么跑起来就不对劲了?特别是 useEffect,简直是我的噩梦。今天就把踩过的坑都写出来,希望能帮到和我一样被闭包困扰的兄弟。
第一次踩坑:定时器里的值永远是最旧的
事情是这样的,我想做一个简单的计数器,每秒自动加一。代码大概是这样:
import React, { useState, useEffect } from 'react'; function Counter() { const [count, setCount] = useState(0); useEffect(() => { const timer = setInterval(() => { console.log('当前count值:', count); setCount(count + 1); }, 1000); return () => clearInterval(timer); }, []); // 空依赖数组 return <div>计数: {count}</div>; }运行起来我傻眼了——控制台打印的count永远是 0!页面上的数字永远停在 1。
问题在哪?闭包!setInterval回调函数捕获的是创建时的count值,也就是 0。而[]空依赖数组让 useEffect 只在组件挂载时执行一次,所以 timer 回调永远看不到新的 count 值。
怎么解决?用函数式更新:
useEffect(() => { const timer = setInterval(() => { setCount(prevCount => prevCount + 1); }, 1000); return () => clearInterval(timer); }, []); // 依赖数组还是空的,但没关系setCount(prevCount => prevCount + 1)这种写法,让 React 拿的是上一次的值,不用担心闭包问题。这下定时器终于正常工作了。
第二次踩坑:useEffect 死循环
好景不长,我又遇到了新问题。我想监听一个输入框的内容变化,发送 API 请求:
function SearchBox() { const [keyword, setKeyword] = useState(''); const [results, setResults] = useState([]); useEffect(() => { // 模拟API调用 fetch(`/api/search?q=${keyword}`) .then(res => res.json()) .then(data => setResults(data)); }, [keyword]); // 依赖 keyword return ( <div> <input value={keyword} onChange={e => setKeyword(e.target.value)} /> <ul>{results.map(r => <li key={r}>{r}</li>)}</ul> </div> ); }开始输入的时候一切正常,直到我输入中文——妈呀,请求发了上百次,浏览器卡死了!
问题分析:输入中文时,onChange会触发多次,keyword每次都更新,然后 useEffect 重新执行,又发请求,形成死循环。而且fetch请求是异步的,请求返回后setResults又会触发重渲染,这一套组合拳下来,性能直接崩盘。
解决方案:加 debounce!
import { useState, useEffect } from 'react'; function SearchBox() { const [keyword, setKeyword] = useState(''); const [results, setResults] = useState([]); useEffect(() => { // 防抖 timer const timer = setTimeout(() => { if (keyword.trim()) { fetch(`/api/search?q=${keyword}`) .then(res => res.json()) .then(data => setResults(data)); } else { setResults([]); } }, 300); // 300ms 延迟 // 清理函数:下次 effect 执行前清掉上次的 timer return () => clearTimeout(timer); }, [keyword]); return ( <div> <input value={keyword} onChange={e => setKeyword(e.target.value)} /> <ul>{results.map(r => <li key={r}>{r}</li>)}</ul> </div> ); }加了 300ms 防抖后,世界清净了。每次输入停下来后才会发请求,不会疯狂触发。
第三次踩坑:useEffect 里的异步函数
后来我想在组件挂载时获取用户信息,代码这样写:
function UserProfile({ userId }) { const [user, setUser] = useState(null); useEffect(() => { async function fetchUser() { const res = await fetch(`/api/users/${userId}`); const data = await res.json(); setUser(data); } fetchUser(); }, [userId]); if (!user) return <div>加载中...</div>; return <div>{user.name}</div>; }看起来没问题对吧?但有一个隐藏的坑:如果 userId 快速变化,之前的异步请求可能会在后面的请求之后返回,导致显示错误的用户数据。
问题在于:异步函数没有返回值,useEffect 的清理函数拿它没办法。
更好的写法:用 AbortController 取消请求,或者在 effect 里直接用 Promise:
function UserProfile({ userId }) { const [user, setUser] = useState(null); useEffect(() => { let isCancelled = false; fetch(`/api/users/${userId}`) .then(res => res.json()) .then(data => { if (!isCancelled) { setUser(data); } }); // 清理函数 return () => { isCancelled = true; }; }, [userId]); if (!user) return <div>加载中...</div>; return <div>{user.name}</div>; }用isCancelled标记,即使旧请求返回了,也不会更新状态。这个技巧在处理竞态条件时特别有用。
第四次踩坑:useRef 穿透闭包
有次我想记录组件的渲染次数,用useRef似乎很合适:
function Logger() { const count = useRef(0); count.current++; useEffect(() => { console.log('组件渲染了', count.current, '次'); }, []); // 空依赖,永远只执行一次 return <div>渲染计数器: {count.current}</div>; }等等!上面这样写是错误的!count.current++在每次渲染时都会执行,effect 里的count.current是第一次渲染时的值(1),不是最新的。
正确做法:如果想追踪渲染次数,用 useRef 没问题,但要确保在 effect 内部读取的是最新的值:
function Logger() { const count = useRef(0); count.current++; useEffect(() => { console.log('组件渲染了', count.current, '次'); }); // 不需要依赖数组,effect 每次渲染都执行 return <div>渲染计数器: {count.current}</div>; }或者,更推荐的方式是用useRenderCount这种自定义 hook:
function useRenderCount() { const count = useRef(0); count.current++; return count.current; } function Logger() { const renderCount = useRenderCount(); return <div>渲染了 {renderCount} 次</div>; }第五个坑:事件监听器的闭包陷阱
最后再说一个很容易出问题的场景——事件监听:
function ClickButton() { const [count, setCount] = useState(0); useEffect(() => { function handleClick() { console.log('点击时count:', count); setCount(count + 1); } document.addEventListener('click', handleClick); return () => { document.removeEventListener('click', handleClick); }; }, [count]); // 依赖 count,每次 count 变化都重新绑定 return <button>点击</button>; }每次 count 变化,事件监听器都会被移除再重新添加。虽然能工作,但性能不好,而且如果多个事件监听器相互依赖,可能会出问题。
更好的方式:用 useRef 保存最新的值:
function ClickButton() { const [count, setCount] = useState(0); const countRef = useRef(count); // 保持 ref 和 state 同步 useEffect(() => { countRef.current = count; }, [count]); useEffect(() => { function handleClick() { console.log('点击时count:', countRef.current); setCount(countRef.current + 1); } document.addEventListener('click', handleClick); return () => { document.removeEventListener('click', handleClick); }; }, []); // 空依赖,只绑定一次 return <button>点击</button>; }这样事件监听器只绑定一次,性能更好,而且通过 ref 始终能拿到最新的值。
写在最后
React Hooks 的闭包问题,本质上是因为 JavaScript 的闭包特性——函数能"记住"它创建时的作用域。要搞定这些问题,关键是理解以下几点:
- 依赖数组不是摆设——它决定了 effect 什么时候重新执行
- 函数式更新是神器——
setState(prev => ...)能避免闭包陷阱 - 清理函数很重要——timer、请求、事件监听器都要记得清理
- useRef 是万金油——需要穿透闭包时用它保存最新值
Hooks 确实比 Class 组件简洁,但坑也更多。多踩几次就习惯了,毕竟——代码是调出来的,不是写出来的。
如果觉得有帮助,点个赞再走?有问题评论区见!