上周四凌晨两点,我在线上紧急回滚了一个功能——仅仅因为一个useEffect的依赖项漏写了一个看似无关的状态。你以为你对useEffect了如指掌?试试回答这个问题:为什么在useEffect里用setTimeout打印的state值总是旧的?今天我们就来扒一扒那些让资深工程师都栽跟头的useEffect陷阱。
闭包陷阱:为什么我的setTimeout拿不到最新值?
真实场景
在开发一个实时仪表盘时,我们需要在用户切换选项卡时延迟3秒发送埋点。代码看似简单:
function Dashboard() { const [tab, setTab] = useState('overview'); useEffect(() => { const timer = setTimeout(() => { trackAnalytics(tab); // 永远记录的是初始值! }, 3000); return () => clearTimeout(timer); }, []); // 注意这里依赖项为空 }上线后数据分析团队反馈:所有埋点记录的竟然都是首次加载的tab值!
根因分析
这里涉及两个关键机制:
- 闭包:
useEffect回调函数捕获的是声明时的tab值,就像快照 - 依赖项数组:空数组意味着这个
useEffect只在挂载时运行一次,后续tab变化不会触发重新执行
正确解法
要么把tab加入依赖项(会重复创建定时器),要么用useRef保存最新值:
// 方案1:依赖项驱动 useEffect(() => { const timer = setTimeout(() => { trackAnalytics(tab); }, 3000); return () => clearTimeout(timer); }, [tab]); // 每次tab变化会先清理旧定时器 // 方案2:useRef容器 const tabRef = useRef(tab); useEffect(() => { tabRef.current = tab; }); useEffect(() => { const timer = setTimeout(() => { trackAnalytics(tabRef.current); }, 3000); return () => clearTimeout(timer); }, []);- 性能对比:当
tab高频变化时,方案1会产生大量定时器创建/销毁,方案2更稳定但代码更复杂。
无限循环:为什么我的API被调了127次?
真实场景
在电商后台,我们需要在"筛选条件变化时重新拉取商品列表"。菜鸟同事写了这样的代码:
useEffect(() => { fetchProducts(filters).then(data => { setProducts(data); setLoading(false); }); }, [filters]); // filters是对象!结果页面直接卡死——
接口被疯狂调用,直到浏览器崩溃。根因分析
根本原因是
对象每次都会创建新的引用。即便内容没变,filters的内存地址变了,触发useEffect重新执行 → 又生成新filters→ 无限循环。正确解法
根据业务场景选择:
const { category, sort } = filters; useEffect(() => { // 只在category或sort变化时执行 }, [category, sort]);useDeepCompareEffect(来自ahooks)useMemoconst stableFilters = useMemo(() => filters, [ JSON.stringify(filters) ]);- 血的教训:在我们的案例中,修复后API调用次数从127次降为1次,页面加载时间从8秒降至400ms。
竞态条件:为什么我的页面显示错乱的数据?
真实场景
用户详情页有userId下拉框。测试发现:
useEffect(() => { let isCurrent = true; fetchUser(userId).then(data => { if (isCurrent) { setUser(data); // 可能后发先至 } }); return () => { isCurrent = false; }; }, [userId]);根因分析
即使有清理函数,
网络响应返回顺序是不确定的。慢的请求可能覆盖新请求的结果——这就是经典的竞态条件。高级解法
useEffect(() => { const controller = new AbortController(); fetchUser(userId, { signal: controller.signal }) .then(setUser) .catch(err => { if (err.name !== 'AbortError') { // 处理真实错误 } }); return () => controller.abort(); }, [userId]);useEffect(() => { const requestId = Symbol(); latestRequestId = requestId; fetchUser(userId).then(data => { if (latestRequestId === requestId) { setUser(data); } }); }, [userId]);避坑清单:资深工程师的useEffect生存法则
abort()的Controller再调用abort())useLayoutEffectuseMemo包裹下次当你写下useEffect时,不妨先问自己三个问题:
- 这个回调会捕获到最新的状态吗?
- 依赖项变化会不会导致意外循环?
- 如果用户快速操作,会不会产生竞态?
你在项目中还遇到过哪些诡异的useEffect问题?欢迎分享你的战场故事——毕竟,没有什么比真实踩坑更能让人成长了。