React Hooks实战中那些你必须避开的常见陷阱
核心代码就这几行,但踩坑花了我两天
先上最常用、也最容易翻车的场景:在组件里封装一个「防抖搜索框」。不是那种教科书式 debounce + useEffect 的玩具 demo,是真实项目里要对接后端、带 loading、支持取消、还要能清空输入并重置状态的版本。
亲测有效、上线跑了三个月没出问题的写法如下:
import { useState, useEffect, useRef } from 'react';
function SearchInput({ onSearch }) {
const [value, setValue] = useState('');
const [loading, setLoading] = useState(false);
const timeoutRef = useRef(null);
// 清除上一次定时器(关键!)
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
const handleSearch = () => {
if (!value.trim()) return;
setLoading(true);
// 这里注意:我踩过好几次坑,ref 要用 current,不能直接赋值给 timeoutRef
timeoutRef.current = setTimeout(async () => {
try {
const res = await fetch(https://jztheme.com/api/search?q=${encodeURIComponent(value)});
const data = await res.json();
onSearch(data);
} catch (err) {
console.error('搜索失败', err);
} finally {
setLoading(false);
}
}, 300);
};
// 输入变化时重置防抖
useEffect(() => {
if (value === '') {
// 清空时立刻取消请求
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
onSearch([]); // 传空数组表示清空结果
return;
}
// 防抖逻辑:每次输入都重置定时器
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(handleSearch, 300);
}, [value, onSearch]);
return (
<div>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="搜索..."
/>
{loading && <span>加载中...</span>}
</div>
);
}
export default SearchInput;
这个场景最好用:表单联动 + 异步校验
比如「用户名输入后实时检查是否已被占用」——你不能等用户点提交才校验,也不能每敲一个字就发请求。得控制频率、避免竞态、还要及时更新 UI 状态。
我一开始用 useEffect + setState 嵌套搞,结果出现「校验结果和当前输入不匹配」的问题:用户快速输入 abcde,但返回的是 b 的校验结果,UI 显示“b 已被占用”,而 input 里已经是 e 了。折腾了半天发现是没处理竞态请求。
解决方案:用 useRef 记录当前最新输入值,在响应返回时比对是否仍是“最新”:
function UsernameField() {
const [username, setUsername] = useState('');
const [status, setStatus] = useState('idle'); // idle / checking / available / taken
const latestUsernameRef = useRef(username);
useEffect(() => {
latestUsernameRef.current = username;
}, [username]);
useEffect(() => {
if (!username.trim()) {
setStatus('idle');
return;
}
setStatus('checking');
const controller = new AbortController();
fetch(https://jztheme.com/api/check-username?name=${username}, {
signal: controller.signal,
})
.then(res => res.json())
.then(data => {
// 关键判断:只更新“当前最新输入”的状态
if (latestUsernameRef.current === username) {
setStatus(data.available ? 'available' : 'taken');
}
})
.catch(err => {
if (err.name !== 'AbortError' && latestUsernameRef.current === username) {
setStatus('idle');
}
});
return () => controller.abort();
}, [username]);
return (
<div>
<input value={username} onChange={e => setUsername(e.target.value)} />
<span>{status === 'checking' && '检查中...'}
{status === 'available' && '✅ 可用'}
{status === 'taken' && '❌ 已被占用'}</span>
</div>
);
}
踩坑提醒:这三点一定注意
- useEffect 里不要直接依赖函数:比如把 onSearch 写进依赖数组却不加 useCallback,会导致无限触发。要么用 useCallback 包一层,要么把 onSearch 放到 ref 里缓存(更稳妥)。
- 不要在 useEffect 里直接 setState 后立刻读 state:React 的 setState 是异步批处理的,你 set 完马上 console.log(state),大概率还是旧值。需要新值?用回调函数或 useRef 缓存。
- useRef 不会触发重新渲染,但它的 current 是可变的:很多人以为 useRef 就是“不会变的常量”,其实 current 字段随时能被改。它只是不引起 rerender,这点特别容易混淆。
高级技巧:自定义 Hook 抽离通用逻辑
上面两个例子其实可以抽象成一个 useDebouncedFetch。但我不建议一上来就写太“通用”的 Hook —— 项目初期需求变来变去,写得太抽象反而难维护。我现在的做法是:先 copy-paste 复用代码,等重复出现 3 次以上,再抽。
抽出来之后长这样(已用于生产):
function useDebouncedFetch(urlTemplate, options = {}) {
const { delay = 300, method = 'GET', signal } = options;
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const timeoutRef = useRef(null);
const urlRef = useRef('');
const trigger = useCallback((params) => {
const url = urlTemplate.replace(/{(w+)}/g, (_, key) => params[key] || '');
urlRef.current = url;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setLoading(true);
setError(null);
timeoutRef.current = setTimeout(() => {
fetch(url, { method, signal })
.then(r => r.json())
.then(d => {
if (urlRef.current === url) setData(d);
})
.catch(e => {
if (urlRef.current === url) setError(e);
})
.finally(() => {
if (urlRef.current === url) setLoading(false);
});
}, delay);
}, [urlTemplate, delay, method, signal]);
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
return { data, loading, error, trigger };
}
// 使用
function UserSearch() {
const { data, loading, error, trigger } = useDebouncedFetch(
'https://jztheme.com/api/users?q={query}',
{ delay: 400 }
);
return (
<div>
<input onChange={e => trigger({ query: e.target.value })} />
{loading && '搜索中...'}
{data?.length > 0 && <ul>{data.map(u => <li key={u.id}>{u.name}</li>)}</ul>}
</div>
);
}
结尾说点实在的
以上是我过去半年在几个中大型 React 项目里反复打磨出来的 Hooks 实践。没有银弹,也没有“绝对最优解”——比如有些团队坚持所有 effect 都必须加 exhaustive-deps lint 规则,我们试了两周,最后妥协:允许手动 disable 一行,只要注释清楚原因。毕竟人不是机器,代码是给人看的,也是给机器跑的,但最终服务的是业务和时间表。
这个技术的拓展用法还有很多,比如结合 Suspense 做数据预取、用 useTransition 控制高优先级交互、甚至配合 Web Worker 做复杂计算卸载……后续会继续分享这类博客。
以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流 —— 尤其是那些我没踩过、但你已经趟过的坑,求分享!

暂无评论