首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >从复制粘贴到统一 API 层:我踩过的 8 个坑

从复制粘贴到统一 API 层:我踩过的 8 个坑

作者头像
前端达人
发布2026-07-20 22:00:35
发布2026-07-20 22:00:35
580
举报
文章被收录于专栏:前端达人前端达人

咱们从一个真实的场景开始。

假设你刚接了一个活儿:给一家连锁奶茶店做后台管理系统。功能不复杂——查订单、改菜单、看营业额。你打开编辑器,写下第一个请求:

代码语言:javascript
复制
fetch("https://api.qianduandaren.com/orders")
  .then((res) => res.json())
  .then((data) => setOrders(data));

跑通了,很爽。

然后你写第二个页面、第三个页面。两周后,项目里有了四十多个这样的 fetch。这时候产品说:「所有接口都要带上登录令牌(token)。」

你打开搜索,四十多处,一个个加。

这就是 API 层要解决的问题。 不是什么高深架构,就是一句话:把「怎么发请求」这件事,从四十个地方收拢到一个地方。

下面我按这个奶茶店后台一路做下去,每遇到一个问题就加一块东西。你会看到八个函数是怎么长出来的,以及我当年在每一步是怎么摔的。

一、先修个「总机」

把上面那个场景想成一家公司。

现在的写法,是每个员工想联系外部,都自己拿手机打电话——号码自己存、格式自己记、说辞自己编。要统一加一句话(比如「我是奶茶店的」),得挨个通知四十个人。

正确做法是装一台总机。 所有对外的电话都从总机出去,总机统一报家门、统一记录。以后要改,只改总机。

这台总机就是我们的第一个函数:

代码语言:javascript
复制
const BASE_URL = "https://api.qianduandaren.com";

async function request(path, options = {}) {
  const res = await fetch(BASE_URL + path, options);
  return res.json();
}

现在业务代码变成:

代码语言:javascript
复制
const orders = await request("/orders");

短了,也统一了。接下来所有的改进,都在这一个函数里做——这就是整篇文章的价值所在。

二、我在这个函数里藏了一个 bug,一年多没发现

先把常见的默认配置加上。发 JSON 数据要告诉后端「我发的是 JSON」,这个声明叫 Content-Type

代码语言:javascript
复制
async function request(path, options = {}) {
  const res = await fetch(BASE_URL + path, {
    headers: {
      "Content-Type": "application/json",
      ...options.headers,
    },
    ...options,          // ← 问题就在这一行
  });
  return res.json();
}

这段代码网上到处都是,我也照抄了很久。直到有一天做「上传店铺 logo」,怎么传都失败。

问题出在 JS 对象的一个基本规则:后写的会盖掉先写的。

就像你往一个箱子上贴标签,先贴「易碎品」,再贴「普通件」,最后别人看到的是「普通件」——前面那张被盖住了。

上面代码里,我们先认真地把 headers 拼好放进去,结果最后一行 ...options 又把 options 里的东西整个铺开一遍。如果调用的时候传了 headers,它就会把前面拼好的那个 headers 整个替换掉Content-Type 就凭空消失了。

修法很简单,把顺序调过来——先铺 options,再放拼好的 headers:

代码语言:javascript
复制
const res = await fetch(BASE_URL + path, {
  ...options,
  headers: { "Content-Type": "application/json", ...options.headers },
});

这个 bug 阴险在哪?大部分时候你不传 headers,它就是对的。 等你哪天需要覆盖 header 了它才发作,而那时候你根本不会怀疑这个「用了半年都没出事」的底层函数。

顺带说个相关的:上传文件的时候,Content-Type 反而不能设。文件上传用的是 FormData(可以理解成一个虚拟的表单),浏览器需要自己生成一串特殊的分隔标记,你手动写死了它就没法生成,上传必失败。所以加个判断:

代码语言:javascript
复制
const isFile = options.body instanceof FormData;
const headers = {
  ...(isFile ? {} : { "Content-Type": "application/json" }),
  ...options.headers,
};

三、「请求失败」这四个字,等于什么都没说

奶茶店上线了,店长打电话来:「新增菜品那里点保存,弹出来'请求失败',怎么回事?」

你看代码,写着:

代码语言:javascript
复制
if (!res.ok) throw new Error("请求失败");

你也不知道怎么回事。

这就像快递被退回来了,单子上只写「失败」两个字。是地址错了?收件人不在?还是这个地区不派送?完全没法处理。

其实后端说得很清楚,它返回的内容是:

代码语言:javascript
复制
{ "code": "NAME_DUPLICATED", "message": "已有同名商品「杨枝甘露」" }

只是被你那句 throw new Error("请求失败") 全扔了。

所以错误要带着现场信息一起走。 我们自己定义一个错误类型:

代码语言:javascript
复制
class ApiError extends Error {
  constructor(message, { status, code, data } = {}) {
    super(message);
    this.name = "ApiError";  // 别漏这句,否则日志和 Sentry 里全是笼统的 "Error"
    this.status = status;    // HTTP 状态码,比如 404、500
    this.code = code;        // 后端给的业务码,比如 NAME_DUPLICATED
    this.data = data;        // 完整的响应内容
  }
}

抛错的时候,先把后端说的话读出来:

代码语言:javascript
复制
if (!res.ok) {
  const body = await res.json().catch(() => null);
  throw new ApiError(body?.message || `请求出错(${res.status})`, {
    status: res.status,
    code: body?.code,
    data: body,
  });
}

注意那个 .catch(() => null)。因为出错的响应不一定是 JSON——网关挂了给你返回一个 HTML 错误页是很常见的。这时候硬解析会再报一个错,把真正的 500 盖掉,你在控制台看到的会是莫名其妙的 Unexpected token '<'

这种「处理错误的过程中又出错」,排查起来最费时间,加一个 catch 就能避免。

有了它,业务代码终于能好好说话了:

代码语言:javascript
复制
try {
  await request("/products", { method: "POST", body: ... });
} catch (err) {
  if (err.code === "NAME_DUPLICATED") {
    setError("这个名字已经有了,换一个吧");
    return;
  }
  throw err;
}

四、删除成功了,前端却报错

店长又打电话来:「删除商品,明明删掉了,但页面弹了个红条。」

原因是这行:

代码语言:javascript
复制
return res.json();

删除接口成功之后,后端返回的是 204——意思是「办好了,没什么要跟你说的」,响应体是空的。而 res.json() 是要把内容解析成 JSON,你给它一个空的,它当然报错。

打个比方:快递签收单上什么都没写,因为确实没什么要写的,但你非要拿它去做文字识别,机器就报错了。

同理,导出营业额报表返回的是 Excel 文件(二进制),也不能用 json() 解析。

所以解析之前先看一眼「这是什么东西」:

代码语言:javascript
复制
async function parse(res) {
  if (res.status === 204) return null;                    // 空的,直接返回

  const type = res.headers.get("content-type") || "";
  if (type.includes("json")) return res.json();           // JSON
  if (type.includes("text/")) return res.text();          // 纯文本
  return res.blob();                                      // 文件
}

四行代码,省掉一类工单。

五、加载动画转到天荒地老

后端有个统计接口偶尔会卡住,不返回也不报错。前端的转圈动画就一直转,用户以为死机了,狂点刷新。

fetch 本身没有超时机制——你不告诉它什么时候放弃,它就一直等下去。

浏览器给了个工具叫 AbortController,你可以把它想成请求上的一根「拔线开关」,随时能把这通电话挂掉:

代码语言:javascript
复制
const controller = new AbortController();
let timedOut = false;

const timer = setTimeout(() => {
  timedOut = true;          // 标记一下:是「超时」挂断的,不是用户挂断的
  controller.abort();
}, 15000);

try {
const res = await fetch(url, { ...options, signal: controller.signal });
// ...
} catch (err) {
// 换成一个能看懂的错误再抛出去
if (err.name === "AbortError" && timedOut) {
    thrownew ApiError("请求超时了,检查一下网络", { status: 408 });
  }
throw err;
} finally {
  clearTimeout(timer);   // 请求回来了,把定时器取消掉
}

两个细节值得说。

一是 finally 里那句 clearTimeout 别漏。 漏了的话,请求早就成功了,那个定时器还傻等着 15 秒。页面上请求一多,就攒出一堆没用的定时器。

二是那个 timedOut 标记。 请求被中断时,浏览器抛的错误统一叫 AbortError,它不告诉你到底是「等太久超时了」还是「用户自己取消的」。这两件事对业务的意义完全不同——超时该提示「网络不好,重试一下」,用户主动取消则应该悄无声息。不做区分,你就会在用户切走页面时给他弹一个报错。

说到用户主动取消,这个开关最典型的用途是搜索框。用户连打五个字,五个请求全发出去,谁先回来不一定,最后可能是第二个字的结果覆盖了第五个字的——搜「杨枝甘露」显示的却是搜「杨」的结果。

所以要允许调用方把自己的开关传进来:

代码语言:javascript
复制
// request 内部:把外部开关和内部超时接到一起
options.signal?.addEventListener("abort", () => controller.abort(), { once: true });

页面里就能这么用:

代码语言:javascript
复制
const controller = new AbortController();
api.get("/search", { kw }, { signal: controller.signal });

// 用户又敲了一个字
controller.abort();   // 上一次的不要了

六、六个请求同时发现「登录过期了」

奶茶店后台的登录令牌两小时过期。过期之后,接口会返回 401,意思是「你是谁?重新证明一下」。

正常处理是:拦到 401 → 去换一个新令牌 → 拿新的重发一次。我第一版就是这么写的,上线当天翻车。

因为首页一进来,同时发了六个请求(订单、销量、库存、公告……)。令牌恰好在这一刻过期,六个请求同时收到 401,于是同时跑去换新令牌

而后端的规则是「换令牌的凭证一次性有效,用完作废」。第一个换成功了,剩下五个拿着已作废的凭证去换,全部失败——用户被踢回登录页。

这个场景像什么?六个员工同时发现门禁卡失效了,六个人一起冲到前台要换卡,但换卡凭证只有一张,第一个人用掉之后,后面五个全被拒了。

正确做法是:只让一个人去换,其他人在旁边等结果。

代码语言:javascript
复制
let refreshing = null;   // 记录「是不是已经有人在换了」

function refreshToken() {
// 已经有人在换 → 直接跟着等同一个结果,不要再发一次
  refreshing ??= fetch(BASE_URL + "/auth/refresh", { method: "POST" })
    .then((r) => r.json())
    .then((d) => {
      localStorage.setItem("token", d.token);
      return d.token;
    })
    .finally(() => { refreshing = null; });  // 换完了,把标记清空

return refreshing;
}

refreshing ??= ... 的意思是:如果 refreshing 是空的才赋值,否则保持原样。六个请求进来,只有第一个真正发起换令牌,另外五个拿到的是同一个「正在进行中」的结果,等它换完,大家一起用新的。

还有一个细节:换完令牌重发的那次请求,如果又 401,不能再换了,否则会无限循环。打个标记就行:

代码语言:javascript
复制
if (res.status === 401 && !options._retried) {
  await refreshToken();
  return request(path, { ...options, _retried: true });  // 只重来一次
}

七、筛选条件一清空,列表就空了

订单页有个状态筛选。店长选了「已完成」,正常;点「全部」(也就是不筛选),列表空了。

代码是这样的:

代码语言:javascript
复制
const query = new URLSearchParams({ page: 1, status: status }).toString();

选「全部」的时候 statusundefined。你以为它会跳过,实际上它老老实实拼了出来:

代码语言:javascript
复制
/orders?page=1&status=undefined

后端拿到一个叫 "undefined" 的状态,去数据库里找,一条也没有。

这个 bug 我排查过两次,两次都先怀疑是后端的锅。

同类的坑还有两个:

  • 数组会被拼成 ids=1%2C2(逗号分隔),而大多数后端期望的是 ids=1&ids=2
  • 如果路径里本来就带了 ?,直接拼会出现两个问号

一起处理掉:

代码语言:javascript
复制
function buildQuery(params = {}) {
const sp = new URLSearchParams();

for (const [key, value] ofObject.entries(params)) {
    if (value === undefined || value === null || value === "") continue;  // 空的跳过

    if (Array.isArray(value)) {
      value.forEach((v) => sp.append(key, v));   // 数组拆成多条
    } else {
      sp.append(key, value);
    }
  }

const s = sp.toString();
return s ? `?${s}` : "";
}

空字符串我也一并跳过了。搜索框清空后传个 keyword=,后端可能理解成「搜索空字符串」,行为很难预料。这个策略你可以自己定,但一定要定一个,别让每个页面各写各的。

八、网络抖了一下,用户下了两单

最后一个,也是后果最严重的一个。

网络不好的时候请求会失败,很自然的想法是「失败了就重试几次」:

代码语言:javascript
复制
async function retry(fn, times = 3) {
  for (let i = 0; i < times; i++) {
    try { return await fn(); }
    catch (e) { if (i === times - 1) throw e; }
  }
}

这段代码有三个问题,一个比一个严重。

第一,失败了立刻就重试。 网络抖动通常要几百毫秒才恢复,你三次重试在 10 毫秒内跑完,等于三次一起失败。正确做法是每次多等一会儿——400ms、800ms、1600ms,这叫「退避」。

还要加一点随机数。否则页面上二十个请求同时超时、又同时按相同节奏重试,会在同一毫秒对后端形成一次冲击,反而把服务器压垮。

第二,不该重试的也在重试。 参数写错了(400),你重试一百次还是错。只有网络断了、服务器临时故障(500、502、503)这类才值得重试。

第三,也是最要命的:对「下单」这类操作重试。

想象一下:用户点了下单,请求其实已经到后端了,订单也创建了,只是返回的路上网络断了。前端这边等不到回复,判定为失败,于是重试——

用户下了两单。

所以默认只对「重复做多少次结果都一样」的操作重试,比如查询(GET)、删除(DELETE)。而下单、支付这类默认绝不重试,除非后端明确支持了防重机制。

代码语言:javascript
复制
// 值得重试的状态码:请求超时、限流、服务端临时故障
const RETRIABLE_STATUS = newSet([408, 429, 500, 502, 503, 504]);

// 幂等方法:重复执行多少次,结果都一样,所以重试是安全的
const IDEMPOTENT_METHODS = newSet(["GET", "HEAD", "PUT", "DELETE"]);

const sleep = (ms) =>newPromise((resolve) => setTimeout(resolve, ms));

asyncfunction withRetry(fn, { attempts = 3, method = "GET" } = {}) {
// 注意要转成大写,否则传 "get" 会匹配不上,重试会静默失效
if (!IDEMPOTENT_METHODS.has(method.toUpperCase())) {
    return fn();   // 下单、支付这类,一次就是一次
  }

for (let i = 0; ; i++) {
    try {
      returnawait fn();
    } catch (err) {
      // 用户主动取消的,绝不能重试——否则他都切走页面了,你还在偷偷发三次
      if (err.name === "AbortError") throw err;

      // err.status 不存在 = 根本没连上(断网),这种值得重试
      const retriable = !err.status || RETRIABLE_STATUS.has(err.status);
      if (i >= attempts - 1 || !retriable) throw err;

      // 退避等待:400ms → 800ms → 1600ms,各带一点随机浮动
      // 随机是为了避免一堆请求卡在同一毫秒重试,反而把后端压垮
      await sleep(2 ** i * 400 * (0.5 + Math.random()));
    }
  }
}

整个流程串起来

八件事都加完之后,一个请求从发出到拿到数据,会经过这些关卡:

代码语言:javascript
复制
业务代码  api.get('/orders', { page: 2 })
    │
    ▼
 拼地址(跳过 undefined 参数)
    │
    ▼
 装 headers(默认 + 令牌)
    │
    ▼
 挂上 15 秒超时开关
    │
    ▼
  fetch 发出去
    │
 ┌──┴────┬────────┐
 ▼       ▼        ▼
网络错  401过期   成功
 │       │        │
 ▼       ▼        ▼
该重试? 换令牌   看类型解析
 │      重发一次  (空/JSON/文件)
 ▼       │        │
退避后再来        ▼
                返回数据

业务代码这边,还是干干净净的一行:

代码语言:javascript
复制
const orders = await api.get("/orders", { page: 2, status: undefined });
// → GET /orders?page=2      undefined 被自动丢掉了

await api.post("/orders", { skuId: 12, count: 1 });
// 下单,不会重试

前面是拆开一块块讲的,最后拼在一起是这样。这份我在本地起了个 mock 服务实际跑过,十个场景全绿,可以直接抄进项目:

代码语言:javascript
复制
// api/client.js

// ① 单次请求:负责拼地址、带令牌、超时、解析、报错
asyncfunction once(path, options = {}) {
const { params, body, timeout = 15000, signal, headers, _retried, ...rest } = options;

const url = BASE_URL + path + buildQuery(params);   // ← 参数在这里拼进去

const controller = new AbortController();
let timedOut = false;
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeout);
  signal?.addEventListener("abort", () => controller.abort(), { once: true });

const isFile = body instanceof FormData;
const token = localStorage.getItem("token");

try {
    const res = await fetch(url, {
      ...rest,
      signal: controller.signal,
      headers: {
        ...(isFile || body === undefined ? {} : { "Content-Type": "application/json" }),
        ...(token ? { Authorization: `Bearer ${token}` } : {}),
        ...headers,
      },
      // ← 一定要 stringify,直接丢对象进去后端收到的是 "[object Object]"
      body: isFile ? body : body !== undefined ? JSON.stringify(body) : undefined,
    });

    if (res.status === 401 && !_retried) {
      await refreshToken();
      return once(path, { ...options, _retried: true });
    }

    if (!res.ok) {
      const data = await res.json().catch(() =>null);
      thrownew ApiError(data?.message || `请求出错(${res.status})`, {
        status: res.status, code: data?.code, data,
      });
    }
    returnawait parse(res);
  } catch (err) {
    if (err.name === "AbortError" && timedOut) {
      thrownew ApiError("请求超时了,检查一下网络", { status: 408 });
    }
    throw err;
  } finally {
    clearTimeout(timer);
  }
}

// ② 在 once 外面套一层重试
exportasyncfunction request(path, options = {}) {
const method = (options.method || "GET").toUpperCase();
const attempts = options.retry ?? (IDEMPOTENT_METHODS.has(method) ? 3 : 1);

for (let i = 0; ; i++) {
    try {
      returnawait once(path, options);
    } catch (err) {
      if (err.name === "AbortError") throw err;   // 用户取消的不重试
      const retriable = !err.status || RETRIABLE_STATUS.has(err.status);
      if (i >= attempts - 1 || !retriable) throw err;
      await sleep(2 ** i * 400 * (0.5 + Math.random()));
    }
  }
}

// ③ 最外层的语法糖
exportconst api = {
get:    (url, params, o) => request(url, { ...o, method: "GET", params }),
post:   (url, body, o)   => request(url, { ...o, method: "POST", body }),
put:    (url, body, o)   => request(url, { ...o, method: "PUT", body }),
patch:  (url, body, o)   => request(url, { ...o, method: "PATCH", body }),
delete: (url, o)         => request(url, { ...o, method: "DELETE" }),
};

这里有两个地方,是我这次整理文章时才发现自己以前写错的,专门标出来:

一个是 buildQuery 明明写好了,但如果你忘了在 once 里调用它,params 会被静默丢掉——请求照发不误,只是没带筛选条件,页面显示"全部数据",你还以为是后端没做筛选。

另一个是 JSON.stringify。不写的话 fetch 会把对象转成字符串 "[object Object]" 发出去,不报错,后端收到一坨看不懂的东西,你在前端查半天。

这两个的共同点是:都不会抛异常,只会让行为悄悄变得不对。比直接报错难查十倍。

最后说两句

这套东西不是让你别用现成的库。

如果你已经在用 axios 且用得舒服,完全没必要重写——axios 的拦截器解决的是同一批问题,只是位置不同。

我真正想说的是:上面这八件事,你的项目里必须有人管。 用什么工具管,其次。

还有一类事情这套代码不管:缓存、请求去重、切回页面自动刷新、分页。这些该交给 TanStack Query 或 SWR,两者配合正好——

代码语言:javascript
复制
const { data } = useQuery({
  queryKey: ["orders", page],
  queryFn: () => api.get("/orders", { page }),
});

api 负责怎么请求,useQuery 负责什么时候请求。分工干净。

回头看,这套代码跟我三年前复制粘贴的那版相比,函数个数几乎没变,还是八九个。变的全是每个函数里那些「看起来多余」的判断——空响应的判断、undefined 的跳过、下单不重试的白名单、finally 里那句 clearTimeout。

每一行背后,都是一次线上排查。

代码是越写越薄的,前提是坑得踩够。

聊两句

第八个坑(重试导致重复下单)我最想听听大家的做法:

你们项目里,下单/支付这类接口做重试吗? 做的话是靠后端的防重机制,还是前端加锁按钮置灰?

另外,如果你也踩过我上面没写到的坑,评论区补充。已经有两个我打算下篇单独展开:

  • 快速切换页面时,旧请求后返回覆盖了新数据
  • 大文件上传的进度和断点续传
本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-07-19,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 前端达人 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、先修个「总机」
  • 二、我在这个函数里藏了一个 bug,一年多没发现
  • 三、「请求失败」这四个字,等于什么都没说
  • 四、删除成功了,前端却报错
  • 五、加载动画转到天荒地老
  • 六、六个请求同时发现「登录过期了」
  • 七、筛选条件一清空,列表就空了
  • 八、网络抖了一下,用户下了两单
  • 整个流程串起来
  • 最后说两句
  • 聊两句
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档