
从复制粘贴到统一 API 层:我踩过的 8 个坑
咱们从一个真实的场景开始。
假设你刚接了一个活儿:给一家连锁奶茶店做后台管理系统。功能不复杂——查订单、改菜单、看营业额。你打开编辑器,写下第一个请求:
jsfetch("https://api.qianduandaren.com/orders")
.then((res) => res.json())
.then((data) => setOrders(data));跑通了,很爽。
然后你写第二个页面、第三个页面。两周后,项目里有了四十多个这样的 fetch。这时候产品说:「所有接口都要带上登录令牌(token)。」
你打开搜索,四十多处,一个个加。
这就是 API 层要解决的问题。 不是什么高深架构,就是一句话:把「怎么发请求」这件事,从四十个地方收拢到一个地方。
下面我按这个奶茶店后台一路做下去,每遇到一个问题就加一块东西。你会看到八个函数是怎么长出来的,以及我当年在每一步是怎么摔的。
一、先修个「总机」
把上面那个场景想成一家公司。
现在的写法,是每个员工想联系外部,都自己拿手机打电话——号码自己存、格式自己记、说辞自己编。要统一加一句话(比如「我是奶茶店的」),得挨个通知四十个人。
正确做法是装一台总机。 所有对外的电话都从总机出去,总机统一报家门、统一记录。以后要改,只改总机。
这台总机就是我们的第一个函数:
jsconst BASE_URL = "https://api.qianduandaren.com";
async function request(path, options = {}) {
const res = await fetch(BASE_URL + path, options);
return res.json();
}现在业务代码变成:
jsconst orders = await request("/orders");短了,也统一了。接下来所有的改进,都在这一个函数里做——这就是整篇文章的价值所在。
二、我在这个函数里藏了一个 bug,一年多没发现
先把常见的默认配置加上。发 JSON 数据要告诉后端「我发的是 JSON」,这个声明叫 Content-Type:
jsasync 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:
jsconst res = await fetch(BASE_URL + path, {
...options,
headers: { "Content-Type": "application/json", ...options.headers },
});这个 bug 阴险在哪?大部分时候你不传 headers,它就是对的。 等你哪天需要覆盖 header 了它才发作,而那时候你根本不会怀疑这个「用了半年都没出事」的底层函数。
顺带说个相关的:上传文件的时候,Content-Type 反而不能设。文件上传用的是 FormData(可以理解成一个虚拟的表单),浏览器需要自己生成一串特殊的分隔标记,你手动写死了它就没法生成,上传必失败。所以加个判断:
jsconst isFile = options.body instanceof FormData;
const headers = {
...(isFile ? {} : { "Content-Type": "application/json" }),
...options.headers,
};三、「请求失败」这四个字,等于什么都没说
奶茶店上线了,店长打电话来:「新增菜品那里点保存,弹出来'请求失败',怎么回事?」
你看代码,写着:
jsif (!res.ok) throw new Error("请求失败");你也不知道怎么回事。
这就像快递被退回来了,单子上只写「失败」两个字。是地址错了?收件人不在?还是这个地区不派送?完全没法处理。
其实后端说得很清楚,它返回的内容是:
json{ "code": "NAME_DUPLICATED", "message": "已有同名商品「杨枝甘露」" }只是被你那句 throw new Error("请求失败") 全扔了。
所以错误要带着现场信息一起走。 我们自己定义一个错误类型:
jsclass 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; // 完整的响应内容
}
}抛错的时候,先把后端说的话读出来:
jsif (!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 就能避免。
有了它,业务代码终于能好好说话了:
jstry {
await request("/products", { method: "POST", body: ... });
} catch (err) {
if (err.code === "NAME_DUPLICATED") {
setError("这个名字已经有了,换一个吧");
return;
}
throw err;
}四、删除成功了,前端却报错
店长又打电话来:「删除商品,明明删掉了,但页面弹了个红条。」
原因是这行:
jsreturn res.json();删除接口成功之后,后端返回的是 204——意思是「办好了,没什么要跟你说的」,响应体是空的。而 res.json() 是要把内容解析成 JSON,你给它一个空的,它当然报错。
打个比方:快递签收单上什么都没写,因为确实没什么要写的,但你非要拿它去做文字识别,机器就报错了。
同理,导出营业额报表返回的是 Excel 文件(二进制),也不能用 json() 解析。
所以解析之前先看一眼「这是什么东西」:
jsasync 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,你可以把它想成请求上的一根「拔线开关」,随时能把这通电话挂掉:
jsconst 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) {
throw new ApiError("请求超时了,检查一下网络", { status: 408 });
}
throw err;
} finally {
clearTimeout(timer); // 请求回来了,把定时器取消掉
}两个细节值得说。
一是 finally 里那句 clearTimeout 别漏。 漏了的话,请求早就成功了,那个定时器还傻等着 15 秒。页面上请求一多,就攒出一堆没用的定时器。
二是那个 timedOut 标记。 请求被中断时,浏览器抛的错误统一叫 AbortError,它不告诉你到底是「等太久超时了」还是「用户自己取消的」。这两件事对业务的意义完全不同——超时该提示「网络不好,重试一下」,用户主动取消则应该悄无声息。不做区分,你就会在用户切走页面时给他弹一个报错。
说到用户主动取消,这个开关最典型的用途是搜索框。用户连打五个字,五个请求全发出去,谁先回来不一定,最后可能是第二个字的结果覆盖了第五个字的——搜「杨枝甘露」显示的却是搜「杨」的结果。
所以要允许调用方把自己的开关传进来:
js// request 内部:把外部开关和内部超时接到一起
options.signal?.addEventListener("abort", () => controller.abort(), { once: true });页面里就能这么用:
jsconst controller = new AbortController();
api.get("/search", { kw }, { signal: controller.signal });
// 用户又敲了一个字
controller.abort(); // 上一次的不要了六、六个请求同时发现「登录过期了」
奶茶店后台的登录令牌两小时过期。过期之后,接口会返回 401,意思是「你是谁?重新证明一下」。
正常处理是:拦到 401 → 去换一个新令牌 → 拿新的重发一次。我第一版就是这么写的,上线当天翻车。
因为首页一进来,同时发了六个请求(订单、销量、库存、公告……)。令牌恰好在这一刻过期,六个请求同时收到 401,于是同时跑去换新令牌。
而后端的规则是「换令牌的凭证一次性有效,用完作废」。第一个换成功了,剩下五个拿着已作废的凭证去换,全部失败——用户被踢回登录页。
这个场景像什么?六个员工同时发现门禁卡失效了,六个人一起冲到前台要换卡,但换卡凭证只有一张,第一个人用掉之后,后面五个全被拒了。
正确做法是:只让一个人去换,其他人在旁边等结果。
jslet 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,不能再换了,否则会无限循环。打个标记就行:
jsif (res.status === 401 && !options._retried) {
await refreshToken();
return request(path, { ...options, _retried: true }); // 只重来一次
}七、筛选条件一清空,列表就空了
订单页有个状态筛选。店长选了「已完成」,正常;点「全部」(也就是不筛选),列表空了。
代码是这样的:
jsconst query = new URLSearchParams({ page: 1, status: status }).toString();选「全部」的时候 status 是 undefined。你以为它会跳过,实际上它老老实实拼了出来:
code/orders?page=1&status=undefined后端拿到一个叫 "undefined" 的状态,去数据库里找,一条也没有。
这个 bug 我排查过两次,两次都先怀疑是后端的锅。
同类的坑还有两个:
- 数组会被拼成
ids=1%2C2(逗号分隔),而大多数后端期望的是ids=1&ids=2 - 如果路径里本来就带了
?,直接拼会出现两个问号
一起处理掉:
jsfunction buildQuery(params = {}) {
const sp = new URLSearchParams();
for (const [key, value] of Object.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=,后端可能理解成「搜索空字符串」,行为很难预料。这个策略你可以自己定,但一定要定一个,别让每个页面各写各的。
八、网络抖了一下,用户下了两单
最后一个,也是后果最严重的一个。
网络不好的时候请求会失败,很自然的想法是「失败了就重试几次」:
jsasync 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)。而下单、支付这类默认绝不重试,除非后端明确支持了防重机制。
js// 值得重试的状态码:请求超时、限流、服务端临时故障
const RETRIABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
// 幂等方法:重复执行多少次,结果都一样,所以重试是安全的
const IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE"]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function withRetry(fn, { attempts = 3, method = "GET" } = {}) {
// 注意要转成大写,否则传 "get" 会匹配不上,重试会静默失效
if (!IDEMPOTENT_METHODS.has(method.toUpperCase())) {
return fn(); // 下单、支付这类,一次就是一次
}
for (let i = 0; ; i++) {
try {
return await 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()));
}
}
}整个流程串起来
八件事都加完之后,一个请求从发出到拿到数据,会经过这些关卡:
code业务代码 api.get('/orders', { page: 2 })
│
▼
拼地址(跳过 undefined 参数)
│
▼
装 headers(默认 + 令牌)
│
▼
挂上 15 秒超时开关
│
▼
fetch 发出去
│
┌──┴────┬────────┐
▼ ▼ ▼
网络错 401过期 成功
│ │ │
▼ ▼ ▼
该重试? 换令牌 看类型解析
│ 重发一次 (空/JSON/文件)
▼ │ │
退避后再来 ▼
返回数据业务代码这边,还是干干净净的一行:
jsconst orders = await api.get("/orders", { page: 2, status: undefined });
// → GET /orders?page=2 undefined 被自动丢掉了
await api.post("/orders", { skuId: 12, count: 1 });
// 下单,不会重试前面是拆开一块块讲的,最后拼在一起是这样。这份我在本地起了个 mock 服务实际跑过,十个场景全绿,可以直接抄进项目:
js// api/client.js
// ① 单次请求:负责拼地址、带令牌、超时、解析、报错
async function 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);
throw new ApiError(data?.message || `请求出错(${res.status})`, {
status: res.status, code: data?.code, data,
});
}
return await parse(res);
} catch (err) {
if (err.name === "AbortError" && timedOut) {
throw new ApiError("请求超时了,检查一下网络", { status: 408 });
}
throw err;
} finally {
clearTimeout(timer);
}
}
// ② 在 once 外面套一层重试
export async function 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 {
return await 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()));
}
}
}
// ③ 最外层的语法糖
export const 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,两者配合正好——
jsconst { data } = useQuery({
queryKey: ["orders", page],
queryFn: () => api.get("/orders", { page }),
});api 负责怎么请求,useQuery 负责什么时候请求。分工干净。
回头看,这套代码跟我三年前复制粘贴的那版相比,函数个数几乎没变,还是八九个。变的全是每个函数里那些「看起来多余」的判断——空响应的判断、undefined 的跳过、下单不重试的白名单、finally 里那句 clearTimeout。
每一行背后,都是一次线上排查。
代码是越写越薄的,前提是坑得踩够。
聊两句
第八个坑(重试导致重复下单)我最想听听大家的做法:
你们项目里,下单/支付这类接口做重试吗? 做的话是靠后端的防重机制,还是前端加锁按钮置灰?
另外,如果你也踩过我上面没写到的坑,评论区补充。已经有两个我打算下篇单独展开:
- 快速切换页面时,旧请求后返回覆盖了新数据
- 大文件上传的进度和断点续传
点赞过一百,我把完整版整理成可以直接安装的包,带测试用例开源出来 📦
如果这篇对你有帮助,欢迎关注公众号「前端达人」,每周更新实用前端干货。

