前端请求层怎样封装?拦截器、去重、错误处理和 token 刷新怎样放在一起?
把 fetch 或 axios 包成一个统一的 request 函数,请求前统一加 token 和公共参数,响应后统一判断状态码并抛出带状态的错误;相同的 GET 请求复用进行中的 Promise;遇到 401 只刷新一次 token,再重放原请求。业务代码只拿数据或捕获错误。
简答
项目里几十个页面都要请求接口,如果每个地方都自己写加 token、判断状态码、弹错误提示,改一次规则就要改几十处。请求层就是把这些重复的事集中到一个
request函数里,业务代码只管传参数、拿数据。封装一般分几层:请求前的拦截,统一加 token、公共参数;发送时处理超时和取消;响应后统一检查状态码,把失败变成一个带状态码的错误抛出去。
fetch遇到 404、500 不会 reject,所以“状态码不对就抛错”这一步必须自己写。在这个统一入口上,还能顺手做两件事。相同的 GET 请求正在进行时,后来的调用直接复用同一个 Promise;收到 401 时只刷新一次 token,其他同时 401 的请求等这次刷新结束,再用新 token 重放。
要注意,请求层只做所有接口都通用的事。弹什么提示、要不要重试这类和业务有关的决定,应该留给调用方或者通过配置开关控制;写操作的去重也不能只靠前端,最终要靠服务端幂等。
请求层要解决什么问题
一个请求从发出到拿到数据,中间有很多步骤和具体业务无关:带上登录凭证、拼接公共前缀、设置超时、检查状态码、把后端的错误格式统一成前端能用的结构。这些步骤如果散在每个调用点,既容易漏,也很难统一修改。
所以请求层的目标是:业务代码写 const user = await request({ method: 'GET', url: '/user' }),成功就拿到数据,失败就进 catch,至于 token 从哪里来、401 怎么处理,业务代码不需要知道。
先看一个最小的完整实现
下面的 createClient 把几件事放在一起:请求钩子改写配置、相同 GET 复用、401 时只刷新一次 token 再重放、状态码大于等于 400 时抛错。adapter 是真正发请求的函数,示例里用一个假的 adapter 模拟服务端:token 不是 new 就返回 401,/api/missing 返回 404。
function createClient(adapter) {
const requestHooks = [];
const pending = new Map();
let refreshing = null;
let token = 'old';
function refreshToken() {
// 多个请求同时 401 时只刷新一次,其余请求等同一个 Promise。
if (!refreshing) {
refreshing = adapter({ url: '/refresh' })
.then((res) => { token = res.data; })
.finally(() => { refreshing = null; });
}
return refreshing;
}
async function send(config, retried) {
let finalConfig = { ...config, headers: { Authorization: token } };
for (const hook of requestHooks) finalConfig = hook(finalConfig);
const res = await adapter(finalConfig);
if (res.status === 401 && !retried) {
await refreshToken();
return send(config, true);
}
if (res.status >= 400) {
const error = new Error('HTTP ' + res.status);
error.status = res.status;
throw error;
}
return res.data;
}
function request(config) {
// 相同的 GET 请求在进行中时直接复用,不再发第二次。
const key = config.method === 'GET' ? config.url : null;
if (key && pending.has(key)) return pending.get(key);
const promise = send(config, false).finally(() => {
if (key) pending.delete(key);
});
if (key) pending.set(key, promise);
return promise;
}
return { request, useRequest: (hook) => requestHooks.push(hook) };
}
const log = [];
const client = createClient(async (config) => {
log.push(config.url);
if (config.url === '/refresh') return { status: 200, data: 'new' };
if (config.url === '/api/missing') return { status: 404 };
if (config.headers.Authorization !== 'new') return { status: 401 };
return { status: 200, data: config.url + ' ok' };
});
client.useRequest((config) => ({ ...config, url: '/api' + config.url }));
Promise.all([
client.request({ method: 'GET', url: '/user' }),
client.request({ method: 'GET', url: '/user' }),
client.request({ method: 'GET', url: '/orders' }),
]).then(async (results) => {
console.log(results.join(' | ')); // /api/user ok | /api/user ok | /api/orders ok
console.log(log.join(',')); // /api/user,/api/orders,/refresh,/api/user,/api/orders
try {
await client.request({ method: 'GET', url: '/missing' });
} catch (error) {
console.log(error.message + ' ' + error.status); // HTTP 404 404
}
});
第一行打印出三个结果,第二个 /user 和第一个拿到的是同一份数据。第二行的调用记录能说明背后发生了什么。
调用记录里 /api/user 一开始只出现了一次,说明第二个 /user 没有真的发出去,它从 pending 里拿到了第一个请求的 Promise。之后两个请求都因为旧 token 收到 401,可是 /refresh 只出现了一次,因为第二个 401 发现 refreshing 已经有值,就直接等这个 Promise。刷新完成后,两个请求各自用新 token 重放了一次。
第三行是 404 的情况。adapter 正常返回了响应,send 看到状态码大于等于 400,主动抛出一个带 status 的错误,所以业务代码能在 catch 里拿到它。
为什么要自己判断状态码
MDN 写明,fetch() 只在网络错误、地址格式不对这类情况下 reject;服务端返回 404 这样的错误状态时,fetch() 照样 fulfill,拿到一个 Response。所以如果不检查 response.ok 或状态码,业务代码会把错误页面当成正常数据去解析,详见《为什么 fetch 收到 404 不一定进入 catch?》。
请求层统一做这一步以后,所有失败都走同一条路:抛出一个带状态码和后端错误信息的错误对象。调用方需要区分时看 error.status,不需要区分时直接用统一的提示。
很多后端还会在 HTTP 200 里用业务码表示失败,比如 { code: 1001, message: '余额不足' }。这种约定也应该在请求层里统一转成错误,否则每个调用点都要再判断一遍 code。
拦截器是怎样串起来的
上面的 requestHooks 就是最简单的请求拦截器:每个钩子拿到配置、返回新的配置,按注册顺序依次执行。axios 的拦截器也是这个思路,分成 interceptors.request 和 interceptors.response 两组。要注意 axios 文档写明两组的执行顺序相反:请求拦截器后注册的先执行,响应拦截器按注册顺序执行。这种“一个处理完交给下一个”的结构就是职责链,见《职责链模式是什么?中间件和请求拦截器是怎样用它的?》。
拦截器适合放和业务无关、每个请求都要做的事,比如加 token、加请求 ID、记录耗时。和某个页面有关的逻辑不要塞进全局拦截器,否则拦截器会越来越难懂,也很难单独测试。
超时和取消放在哪里
fetch 本身没有超时参数,超时靠 AbortSignal。MDN 介绍,AbortSignal.timeout(ms) 返回的信号会在超时后以 TimeoutError 中止;如果调用方自己也传了一个用于取消的信号,可以用 AbortSignal.any([userSignal, AbortSignal.timeout(ms)]) 合并,任何一个中止,合并后的信号就中止。
async function fetchWithTimeout(url, { signal, timeout = 10000 } = {}) {
const signals = [AbortSignal.timeout(timeout)];
if (signal) signals.push(signal);
const response = await fetch(url, { signal: AbortSignal.any(signals) });
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.json();
}
这样请求层可以区分两种失败:TimeoutError 是超时,可以提示“网络较慢”;AbortError 通常是调用方主动取消,比如组件卸载或者搜索框换了关键词,这时不应该弹错误提示。取消具体能停掉什么,见《AbortController 能取消什么,不能取消什么?》。
去重和 token 刷新为什么只放一份
示例里的去重只针对 GET。读请求重复发出只是浪费,复用同一个 Promise 没有副作用;写请求要不要合并取决于业务,防连点、幂等键这些做法见《前端怎样防止重复提交和接口重复请求?》。
token 刷新的关键是“同一时间只刷新一次”。如果每个 401 都各自去刷新,并发的几个请求就会发出几次刷新,既浪费,又可能互相覆盖拿到的新 token。示例用 refreshing 保存进行中的刷新 Promise,其他请求等它就行。还有一点是 retried 标志:重放后如果还是 401,说明刷新也解决不了,应该抛错去登录页,而不是无限循环。刷新的时机和轮换细节见《Access Token 过期后,前端怎样做无感刷新?》。
统一错误处理要留出口
请求层可以统一把错误转成提示,比如 500 弹“服务繁忙”。可是有些页面要自己处理,比如表单提交返回 422 时要把错误显示在对应输入框下面,这时全局弹窗就多余了。
常见做法是在请求配置里加开关,比如 silent: true 表示不弹全局提示,请求层仍然把错误抛给调用方。这样默认情况下所有页面行为一致,特殊页面也有办法自己接管。
常见追问
拦截器里刷新 token 时,正在刷新期间新发出的请求怎么办?
可以在请求拦截器里先检查是否有进行中的刷新,有就等它结束再带上新 token 发出去。这样新请求不会先用旧 token 撞一次 401。本质上和示例一样,所有人都等同一个刷新 Promise。
请求失败要不要自动重试?
只对安全的情况重试,比如网络错误或者 GET 请求超时,而且要限制次数、拉长间隔。写请求自动重试可能造成重复写入,除非服务端支持幂等键,否则交给用户决定。
请求层要不要做缓存?
示例里的去重只在请求进行中有效,请求结束就从
pending里删掉。如果想在一段时间内直接用缓存数据,还要考虑过期时间、什么时候重新获取、数据改了以后怎样失效,这些通常交给专门的数据请求库,请求层只负责把请求发对、把错误抛对。