useEffect 的陷阱与救赎:一个资深 React 开发者的踩坑实录
用了三年 React,我才真正理解 useEffect。
如果你写过 React,一定用过 useEffect。这个看似简单的 Hook,却是新手最容易踩坑、老手也时常翻车的重灾区。今天,我想结合自己三年来的实战经验,聊聊 useEffect 的那些"坑",以及如何优雅地避开它们。
一、依赖数组:你以为的依赖,真的是依赖吗?
经典陷阱:闭包问题
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log(count); // 永远是 0
setCount(count + 1); // 永远变成 1
}, 1000);
return () => clearInterval(timer);
}, []); // 空依赖数组是罪魁祸首
return <div>{count}</div>;
}
这个问题困扰了无数 React 开发者。为什么 count 永远是 0?因为 useEffect 的回调在组件第一次渲染时创建,它"记住"了那个时刻的 count 值。即使 count 后续变化,定时器里的 count 依然是旧的。
解决方案
方案 1:使用函数式更新
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1); // ✅ 使用函数式更新
}, 1000);
return () => clearInterval(timer);
}, []);
方案 2:使用 ref(需要读取最新值但不触发重渲染时)
function Counter() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
useEffect(() => {
countRef.current = count;
});
useEffect(() => {
const timer = setInterval(() => {
console.log(countRef.current); // ✅ 永远是最新值
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{count}</div>;
}
二、依赖数组的"过度诚实"问题
有时候,ESLint 会提示你添加依赖,但添加后却导致无限循环。
场景:对象作为依赖
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const config = { apiKey: 'xxx', timeout: 5000 }; // 每次渲染都创建新对象
useEffect(() => {
fetchUser(userId, config).then(setUser);
}, [userId, config]); // ❌ config 每次都变,导致无限请求
return <div>{user?.name}</div>;
}
解决方案
方案 1:将对象移到组件外
const CONFIG = { apiKey: 'xxx', timeout: 5000 }; // 组件外定义
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId, CONFIG).then(setUser);
}, [userId]); // ✅ 只有 userId 变化时才请求
return <div>{user?.name}</div>;
}
方案 2:使用 useMemo
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const config = useMemo(() => ({ apiKey: 'xxx', timeout: 5000 }), []);
useEffect(() => {
fetchUser(userId, config).then(setUser);
}, [userId, config]); // ✅ config 引用稳定
return <div>{user?.name}</div>;
}
方案 3:使用 useRef(如果确实不需要响应式)
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const configRef = useRef({ apiKey: 'xxx', timeout: 5000 });
useEffect(() => {
fetchUser(userId, configRef.current).then(setUser);
}, [userId]); // ✅ 不需要在依赖数组中
return <div>{user?.name}</div>;
}
三、竞态条件:当请求顺序变得重要
问题场景
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(data => setResults(data));
}, [query]);
return <ResultsList results={results} />;
}
如果用户快速输入 "react" -> "react hooks",第二个请求可能比第一个先返回,导致显示错误的结果。
解决方案:使用 AbortController
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setResults(data))
.catch(err => {
if (err.name !== 'AbortError') {
console.error('Search failed:', err);
}
});
return () => controller.abort(); // ✅ 清理时取消请求
}, [query]);
return <ResultsList results={results} />;
}
四、清理函数:被遗忘的守护者
事件监听器泄漏
function MouseTracker() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
window.addEventListener('mousemove', (e) => {
setPosition({ x: e.clientX, y: e.clientY });
});
// ❌ 忘记移除监听器!
}, []);
return <div>Mouse: {position.x}, {position.y}</div>;
}
每次组件卸载后,事件监听器还在,导致内存泄漏和状态更新错误。
正确做法
function MouseTracker() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMouseMove = (e) => {
setPosition({ x: e.clientX, y: e.clientY });
};
window.addEventListener('mousemove', handleMouseMove);
return () => {
window.removeEventListener('mousemove', handleMouseMove); // ✅ 清理
};
}, []);
return <div>Mouse: {position.x}, {position.y}</div>;
}
五、useLayoutEffect:什么时候用它?
useLayoutEffect 和 useEffect 几乎一样,但它会在浏览器绘制之前同步执行。
使用场景:防止视觉闪烁
function Tooltip({ targetRef }) {
const [position, setPosition] = useState({ top: 0, left: 0 });
const tooltipRef = useRef();
useLayoutEffect(() => {
const targetRect = targetRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
// 计算 tooltip 位置,避免超出视口
setPosition({
top: targetRect.bottom + 8,
left: targetRect.left + (targetRect.width - tooltipRect.width) / 2
});
}, []);
return (
<div ref={tooltipRef} style={{ position: 'absolute', ...position }}>
Tooltip content
</div>
);
}
如果用 useEffect,用户可能会看到 tooltip 从错误位置"跳"到正确位置。useLayoutEffect 确保在绘制前就调整好位置。
但注意:useLayoutEffect 会阻塞浏览器绘制,只在必要时使用。大多数情况下,useEffect 是更好的选择。
六、自定义 Hook:封装最佳实践
把常用的 useEffect 逻辑封装成自定义 Hook:
// hooks/useAsync.js
function useAsync(asyncFunction, immediate = true) {
const [status, setStatus] = useState('idle');
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const execute = useCallback(() => {
setStatus('pending');
setData(null);
setError(null);
return asyncFunction()
.then(response => {
setData(response);
setStatus('success');
})
.catch(error => {
setError(error);
setStatus('error');
});
}, [asyncFunction]);
useEffect(() => {
if (immediate) {
execute();
}
}, [execute, immediate]);
return { execute, status, data, error };
}
// 使用
function UserProfile({ userId }) {
const { data: user, status, error } = useAsync(
useCallback(() => fetchUser(userId), [userId])
);
if (status === 'pending') return <Loading />;
if (status === 'error') return <Error message={error.message} />;
return <div>{user?.name}</div>;
}
七、React 18 的并发特性与 useEffect
React 18 引入了并发渲染,useEffect 的行为有了一些变化:
- useEffect 可能在渲染被中断后再次执行:确保你的 effect 可以处理多次执行的情况。
- 严格模式下组件会故意双重挂载:帮助发现副作用问题。
// 确保 effect 可以安全地多次执行
useEffect(() => {
let cancelled = false;
fetchData().then(data => {
if (!cancelled) {
setData(data);
}
});
return () => {
cancelled = true; // ✅ 防止设置已卸载组件的状态
};
}, []);
总结
useEffect 看似简单,但要真正用好它,需要理解:
- 闭包陷阱:使用函数式更新或 ref 解决
- 依赖数组:谨慎处理对象和函数依赖
- 竞态条件:使用 AbortController 或标志位
- 清理函数:别忘了移除事件监听器、定时器等
- useLayoutEffect:只在需要防止闪烁时使用
- 自定义 Hook:封装重复逻辑,提高复用性
希望这篇文章能帮你避开 useEffect 的那些坑。记住:好的代码不是一次写成的,而是在踩坑中不断完善的。
你有什么 useEffect 的踩坑经历?欢迎在评论区分享。
评论区
登录 后参与评论