打开项目里的请求代码一看——没有超时、没有取消、没有重试、错误直接吞掉。这不叫调接口,这叫裸奔。
大部分前端项目里的接口请求长这样:
const res = await fetch('/api/users');
const data = await res.json();
setUsers(data);
三行代码,干净利落。但这三行代码在生产环境里是颗定时炸弹——接口超时了怎么办?组件卸载了还在 setState 怎么办?用户快速切换页面,旧数据覆盖新数据怎么办?
我花了一个下午给项目里的请求代码做了一次"加固",一层一层往上套保护。下面是这 6 层保护的完整记录,每层都有改造前后的代码对比。
最基本的一层,但大部分项目都没做好。
裸奔版:
async function getUsers() {
const res = await fetch('/api/users');
const data = await res.json();
return data;
}
问题在哪?fetch 只有在网络故障时才会 reject。服务端返回 500、404、403,fetch 都认为"请求成功了",照样走 resolve。你拿到的 data 可能是一段 HTML 错误页面。
加了第一层保护:
async function getUsers() {
try {
const res = await fetch('/api/users');
if (!res.ok) {
throw new Error(`请求失败: ${res.status}`);
}
return await res.json();
} catch (err) {
console.error('获取用户列表失败:', err);
return null;
}
}
res.ok 只在状态码 200-299 时为 true。这一行代码拦住了所有非 2xx 的响应。
fetch 没有内置超时。如果服务端挂了不返回,fetch 会永远等下去。用户看到的就是页面一直在转圈。
裸奔版:
const res = await fetch('/api/users');
加了第二层保护:
const res = await fetch('/api/users', {
signal: AbortSignal.timeout(8000)
});
一行搞定。AbortSignal.timeout(8000) 是原生 API——超过 8 秒自动取消请求,抛出 TimeoutError。不需要手动创建 AbortController + setTimeout 那套老写法了。
结合第一层:
async function getUsers() {
try {
const res = await fetch('/api/users', {
signal: AbortSignal.timeout(8000)
});
if (!res.ok) {
throw new Error(`请求失败: ${res.status}`);
}
return await res.json();
} catch (err) {
if (err.name === 'TimeoutError') {
console.error('请求超时');
} else {
console.error('请求失败:', err);
}
return null;
}
}
接口在请求中,用户看到什么?如果什么都不显示,用户会疯狂点按钮;如果出错了默默吞掉,用户以为页面正常、数据是对的。
裸奔版:
function UserList() {
const [users, setUsers] = useState([]);
useEffect(() => {
fetch('/api/users')
.then(res => res.json())
.then(data => setUsers(data));
}, []);
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
请求中看到空列表,出错了还是空列表。用户分不清"还在加载"和"真的没数据"。
加了第三层保护:
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
setError(null);
fetch('/api/users', { signal: AbortSignal.timeout(8000) })
.then(res => {
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
})
.then(data => setUsers(data))
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, []);
if (loading) return <div>加载中...</div>;
if (error) return <div>出错了: {error}</div>;
if (!users.length) return <div>暂无数据</div>;
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
三个状态位——loading、error、data——是接口请求的最小完备状态机。少了任何一个,用户体验都有盲区。
用户打开页面 A,接口还在请求中,用户切到了页面 B。页面 A 的组件已经卸载了,但请求还在飞。等响应回来,React 尝试在一个已经不存在的组件上调用 setState——控制台报警告,严重时内存泄漏。
裸奔版:
useEffect(() => {
fetch('/api/users')
.then(res => res.json())
.then(data => setUsers(data));
}, []);
加了第四层保护:
useEffect(() => {
const controller = new AbortController();
fetch('/api/users', { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
})
.then(data => setUsers(data))
.catch(err => {
if (err.name !== 'AbortError') {
setError(err.message);
}
})
.finally(() => setLoading(false));
return () => controller.abort();
}, []);
useEffect 的清理函数里调 controller.abort(),组件卸载时自动取消。注意 catch 里要过滤掉 AbortError——这是正常取消,不是报错。
这一层保护还有一个隐藏收益:如果 useEffect 的依赖项变了导致重新执行,旧的请求会被自动取消,不会和新请求打架。
用户在搜索框里快速输入"张三",触发了三次请求:
如果网络不稳定,步骤 3 和 4 可能反过来——用户搜的是"张三",页面显示的是"张"的结果。这就是竞态问题。
裸奔版:
useEffect(() => {
fetch(`/api/search?q=${keyword}`)
.then(res => res.json())
.then(data => setResults(data));
}, [keyword]);
加了第五层保护:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${keyword}`, {
signal: controller.signal
})
.then(res => {
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
})
.then(data => setResults(data))
.catch(err => {
if (err.name !== 'AbortError') {
setError(err.message);
}
});
return () => controller.abort();
}, [keyword]);
关键在于 return () => controller.abort()。当 keyword 变化时,React 先执行上一次的清理函数(取消旧请求),再执行新的 effect(发新请求)。旧请求被取消后不会回来覆盖新数据。
第四层和第五层用的是同一个机制(AbortController + cleanup),但解决的是两个不同问题:第四层防内存泄漏,第五层防数据错乱。
移动端用户网络不稳定,偶尔断一下是常事。如果一次请求失败就直接报错,用户体验很差。但重试也不能无脑重试——只有网络错误和服务端临时错误(5xx)值得重试,客户端错误(4xx)重试一万次结果都一样。
裸奔版:
const res = await fetch('/api/users');
加了第六层保护:
async function fetchWithRetry(url, options = {}, retries = 3) {
for (let i = 0; i <= retries; i++) {
try {
const res = await fetch(url, {
...options,
signal: options.signal || AbortSignal.timeout(8000)
});
if (!res.ok) {
if (res.status >= 500 && i < retries) {
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
continue;
}
throw new Error(`请求失败: ${res.status}`);
}
return await res.json();
} catch (err) {
if (err.name === 'AbortError') throw err;
if (i === retries) throw err;
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
}
}
}
三个细节:
| 保护层 | 解决的问题 | 核心代码 | 不加的后果 |
|---|---|---|---|
| 错误兜底 | 500/404 被当成功 | if (!res.ok) throw |
拿到错误数据当正确数据用 |
| 超时控制 | 请求永远等待 | AbortSignal.timeout(8000) |
页面无限转圈 |
| 状态管理 | 用户看不到状态 | loading + error + data 三态 | 分不清"加载中"和"没数据" |
| 请求取消 | 组件卸载后 setState |
controller.abort() + cleanup |
内存泄漏 + 控制台报警告 |
| 竞态保护 | 旧数据覆盖新数据 | cleanup 取消上一次请求 | 搜索结果和输入不匹配 |
| 自动重试 | 网络闪断一次就挂 | 仅 5xx/网络错误 + 递增延迟 | 偶发网络波动直接报错 |
一句话总结:每次写 fetch,先问自己——超时了怎么办、出错了怎么办、取消了怎么办、重复了怎么办。答不上来就是在裸奔。
这 6 层不需要一次性全加。优先级从上到下递减:
如果你用 React,react-query(TanStack Query)或 SWR 把这 6 层全内置了,不用手写。但理解原理之后再用封装库,和直接无脑用,debug 时的效率差距是 10 倍。