聊天讨论 我给前端项目的接口请求套了 6 层保护——才发现以前一直在裸奔

193577746(kyriewen) · 2026年07月23日 · 14 次阅读

打开项目里的请求代码一看——没有超时、没有取消、没有重试、错误直接吞掉。这不叫调接口,这叫裸奔。

大部分前端项目里的接口请求长这样:

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 的依赖项变了导致重新执行,旧的请求会被自动取消,不会和新请求打架。


第五层:竞态保护

用户在搜索框里快速输入"张三",触发了三次请求:

  1. "张" → 请求 A 发出
  2. "张三" → 请求 B 发出
  3. "张" 的结果先回来 → 页面显示"张"的结果
  4. "张三"的结果回来 → 页面闪一下变成"张三"的结果

如果网络不稳定,步骤 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)));
    }
  }
}

三个细节:

  1. 只重试 5xx 和网络错误:4xx 不重试,避免无意义的请求浪费
  2. 递增延迟:第一次等 1 秒,第二次等 2 秒,第三次等 3 秒(简化版退避)
  3. AbortError 不重试:用户主动取消的请求不应该自己又跑起来

接口请求保护层级速查表

保护层 解决的问题 核心代码 不加的后果
错误兜底 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 倍。

暂无回复。
需要 登录 后方可回复, 如果你还没有账号请 注册新账号