项目里的接口越来越多,每次发请求都要手动带上 token、处理错误弹窗、控制 loading 按钮,这套重复代码散落在各个页面里。更头疼的是,当登录状态过期的时候,所有接口几乎同时报 401,每个页面都跳一次登录页,浏览器控制台密密麻麻全是红色报错。这种体验无论是用户还是开发都很难受。其实 Angular 自带的 HTTP 拦截器就是专门用来解决这些问题的,只是很多人只把它当成一个加 token 的工具。把拦截器真正用到业务场景里,它能帮你把身份凭证、错误映射、加载状态和请求风暴这些问题一次性理顺。
一、先从场景说起
1.1 身份凭证为什么不能每次手写
每个接口都要带 token,这是最常见的需求。你当然可以在每个 service 里拼 header,但一旦接口数量超过几十个,漏写一个就会导致某个页面报 401,排查起来特别费劲。更麻烦的是,token 可能存放在 localStorage 里,也可能在 sessionStorage 里,或者在一个全局内存变量中。如果哪天存储方案换了,所有 service 都得跟着改。统一写在拦截器里,只需要改一个地方,所有请求就都带上了。
1.2 后端错误码千奇百怪
后端返回的错误格式通常有两种:一种是 HTTP 状态码本来就正确,比如 400、401、403;另一种是状态码永远 200,但 body 里放一个业务码,比如 { code: 1001, message: "库存不足" }。前端拿到的数据五花八门,有的叫 msg,有的叫 error,有的叫 message。如果每个页面都自己判断一遍,代码会非常啰嗦。拦截器里可以统一把各种错误“翻译”成前端认识的对象,让页面只关心“成功还是失败”,以及“失败后展示什么文案”。
1.3 加载态不该在组件里到处散落
很多开发喜欢在组件里写 isLoading,然后手动在 subscribe 前后置 true 和 false。这一套逻辑在多个请求并发时会很难控制,比如两个接口同时发,一个先回来,另一个还没回来,你如果把 isLoading 设为 false,页面就会提前显示完成状态。更好的做法是在拦截器里统计当前还在请求中的数量,当数量大于 0 时显示 loading,等于 0 时隐藏。这样任何组件都不用关心 loading 的开关,只要请求存在,界面自然就有反馈。
1.4 认证失效时的连锁请求风暴
后端返回 401 通常意味着 token 过期。如果一个页面同时发了好几个请求,它们都会同时收到 401。如果每个请求都去跳转登录页,页面会刷新 N 次;如果每个请求都弹出“登录已过期”,用户得点 N 次确定。更糟糕的是,有些业务还会在 401 后自动刷新 token,如果多个请求同时触发刷新,就会出现并发调用刷新接口的情况,后端被刷爆。用拦截器统一处理就能避免这些连锁反应。
二、技术优缺点与注意事项
2.1 拦截器的优点
拦截器最大的优势是集中管理。所有请求和响应都会经过同一个管道,你可以在这里做统一的预处理。代码可维护性高,业务逻辑不用重复。而且它天然支持响应流,可以在拿到结果后继续交给 subscribe,也可以直接抛错中断后续流程。拦截器还可以按顺序组合,多个拦截器各管一件事,比如一个管 token,一个管错误,一个管 loading,职责非常清晰。
2.2 拦截器的缺点
有一些开发者觉得拦截器是“隐藏魔法”,因为调用 service 的地方看不到拦截器做了什么,如果拦截器里逻辑写得复杂,调试起来会比较费劲。另外,拦截器必须依赖 Angular 的依赖注入系统,如果你在一个纯工具函数或普通类里发请求,拦截器不会生效。还有一个问题是,如果拦截器自身也使用 HttpClient(比如刷新 token),很容易不小心造成递归请求,需要特别注意。
2.3 注意事项
不要拦截器里把所有事情都堆在一起,每个拦截器只做一件事。错误对象最好自定义成统一的接口,不要让组件去猜。加载态的计数要小心加减不匹配,特别是请求抛异常的时候,一定要保证最终会触发减一。还有,对于上传文件或下载流这种大任务,加载态的处理方式可能要单独考虑,不要把所有请求都套同一个 loading 逻辑。最后,要记得在拦截器里放行那些不需要 token 的白名单接口,比如登录接口本身。
三、统一携带身份凭证
下面我们用 Angular 的拦截器来写一个实际案例。技术栈统一使用 TypeScript + Angular 15+。先看第一个拦截器,它负责给请求加上 token。这里我们用 HttpInterceptor 接口和 HttpRequest.clone 方法。
// auth.interceptor.ts
import { Injectable } from '@angular/core';
import {
HttpInterceptor,
HttpRequest,
HttpHandler,
HttpEvent
} from '@angular/common/http';
import { Observable } from 'rxjs';
// 定义几个不需要 token 的接口路径
const WHITE_LIST = [
'/auth/login',
'/auth/refresh-token',
'/public/region/list'
];
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
// 核心方法:每个请求都会走进来
intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
// 先从 localStarage 中拿 token
const token = localStorage.getItem('access_token');
// 判断当前请求是否在白名单中
const isPublic = WHITE_LIST.some(prefix => req.url.includes(prefix));
// 白名单请求直接放行,不添加任何 header
if (isPublic) {
return next.handle(req);
}
// 有 token 说明可以带上身份信息
if (token) {
// 通过 clone 方式创建一份新请求,避免修改原请求对象
const authReq = req.clone({
setHeaders: {
// 按照后端要求,通常写成 Bearer 形式
Authorization: `Bearer ${token}`
}
});
// 将新请求传给下一个处理器
return next.handle(authReq);
}
// 没有 token 也先放行,后续错误拦截器会处理 401
return next.handle(req);
}
}
上面这个拦截器非常直观。需要注意 clone 的用法,它生成的是一个全新的请求对象,原请求不会被改动。每次请求进来,我们都读取最新的 token,所以登录后 token 更新,下一次请求自然带上新值。
四、映射后端错误码
再来写一个统一错误码映射的拦截器。这个拦截器要同时处理两种错误:HTTP 状态码异常,以及业务码异常。我们约定后端在业务码异常时返回 HTTP 200,但 body 里带有 code 和 message。为了后续组件里好识别,我们自定义一个 BusinessError 类。
// error.model.ts
// 统一错误结构,前端所有页面都用这个类型
export interface ApiError {
code: number | string;
message: string;
httpStatus?: number;
raw?: unknown; // 保留原始返回,方便排查
}
// 自定义错误类,后面抛错时直接抛出这个实例
export class BusinessError extends Error {
code: number | string;
httpStatus?: number;
raw?: unknown;
constructor(apiError: ApiError) {
// 调用父类 Error 构造函数,message 用于 Error 自带的提示
super(apiError.message);
// 把自定义字段复制到当前实例上
this.name = 'BusinessError';
this.code = apiError.code;
this.httpStatus = apiError.httpStatus;
this.raw = apiError.raw;
// 保证原型链正确,方便使用 instanceof 判断
Object.setPrototypeOf(this, BusinessError.prototype);
}
}
有了错误模型,我们来写拦截器。这里会用到 RxJS 的 tap 和 catchError。收到响应时,先检查 HTTP 状态码是否 2xx,再检查 body 里的业务码。如果业务码不是 0,说明业务失败,直接抛错。如果 HTTP 状态码异常,就把后端返回的 message 提出来,统一包装成 BusinessError。
// error-mapping.interceptor.ts
import { Injectable } from '@angular/core';
import {
HttpInterceptor,
HttpRequest,
HttpHandler,
HttpEvent,
HttpResponse,
HttpErrorResponse
} from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { BusinessError } from './error.model';
// 假设后端业务成功时 code 字段等于 0
const SUCCESS_CODE = 0;
@Injectable()
export class ErrorMappingInterceptor implements HttpInterceptor {
intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
// 接管响应流
return next.handle(req).pipe(
// 先看 HTTP 状态码是成功还是失败
tap({
next: (event: HttpEvent<unknown>) => {
// 只看完整的响应,文件流等类型不处理
if (event instanceof HttpResponse) {
// HTTP 状态 2xx 不代表业务成功
const body = event.body as any;
if (body && typeof body === 'object' && 'code' in body) {
const code = body.code;
const message = body.message || '请求失败';
// 业务码不是约定的成功码,就把这个响应当成错误处理
if (code !== SUCCESS_CODE) {
// 手动抛出一个 BusinessError
throw new BusinessError({
code,
message,
httpStatus: event.status,
raw: event.body
});
}
}
}
}
}),
// 如果上面抛错,会被这里捕获
catchError((err: unknown) => {
// HTTP 层错误
if (err instanceof HttpErrorResponse) {
// 从后端错误响应中尽量提取 message
const serverMsg =
err.error?.message ||
err.error?.msg ||
err.message ||
'网络异常,请稍后再试';
return throwError(
() =>
new BusinessError({
code: err.status,
message: serverMsg,
httpStatus: err.status,
raw: err.error
})
);
}
// 如果是我们刚才抛出的 BusinessError,原样传递
if (err instanceof BusinessError) {
return throwError(() => err);
}
// 其他未知错误统一处理
return throwError(
() => new BusinessError({ code: -1, message: '未知错误', raw: err })
);
})
);
}
}
这个拦截器写完后,组件里就不需要再频繁判断后端返回的 code 了。你只需要在 subscribe 的 next 里拿正常数据,在 error 里拿到 BusinessError 就能展示提示信息。
五、精细控制加载态
接下来是加载态控制。我们实现一个 LoadingInterceptor,它记录当前正在进行的请求数量,并用一个全局的 LoadingService 来广播状态。其他组件可以订阅这个状态来显示全局进度条,也可以用在按钮级 loading 上。
// loading.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
// 这个服务用于全局共享 loading 状态
@Injectable({ providedIn: 'root' })
export class LoadingService {
// BehaviorSubject 保证新订阅者能立即拿到当前值
private pendingCount = 0;
private loadingSubject = new BehaviorSubject<boolean>(false);
// 暴露只读的 loading 状态供组件订阅
get loading$(): Observable<boolean> {
return this.loadingSubject.asObservable();
}
// 发起一个请求时调用
begin(): void {
this.pendingCount++;
// 只要大于 0,就当作正在加载中
this.loadingSubject.next(this.pendingCount > 0);
}
// 请求结束或出错时调用
end(): void {
// 减到最小为 0,避免负数
this.pendingCount = Math.max(0, this.pendingCount - 1);
// 计数为 0 时表示没有进行中的请求
this.loadingSubject.next(this.pendingCount > 0);
}
}
有了服务,我们还需要每个请求开始时调用 begin,结束时调用 end。这里要用 finalize 操作符,它可以保证无论请求成功还是失败都会执行。
// loading.interceptor.ts
import { Injectable } from '@angular/core';
import {
HttpInterceptor,
HttpRequest,
HttpHandler,
HttpEvent
} from '@angular/common/http';
import { Observable } from 'rxjs';
import { finalize } from 'rxjs/operators';
import { LoadingService } from './loading.service';
@Injectable()
export class LoadingInterceptor implements HttpInterceptor {
// 注入 LoadingService,用来计数
constructor(private loadingService: LoadingService) {}
intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
// 请求发出前,计数加一
this.loadingService.begin();
// 使用 finalize 确保请求结束时计数减一
return next.handle(req).pipe(
finalize(() => {
this.loadingService.end();
})
);
}
}
这个拦截器实现得非常简洁。需要注意,它不关心具体请求是成功还是失败,只要 next.handle 返回的可观察对象结束了,就会触发 finalize。这样就不会出现计数不匹配的问题。
在组件里,你只需要订阅 loading$,页面上的 loading 效果就会自动出现和消失。比如全局顶部的进度条,或者遮罩层。
// app.component.ts 片段
import { Component } from '@angular/core';
import { LoadingService } from './core/loading.service';
@Component({
selector: 'app-root',
template: `
<div *ngIf="isLoading" class="global-loading">页面加载中...</div>
<router-outlet></router-outlet>
`
})
export class AppComponent {
isLoading = false;
constructor(private loadingService: LoadingService) {
// 订阅全局 loading 状态,自动更新页面
this.loadingService.loading$.subscribe(value => {
this.isLoading = value;
});
}
}
六、避免认证失效时的请求风暴
最后是这个文章里最关键的部分。当多个请求同时返回 401 时,我们要保证只处理一次“登录过期”跳转,并且如果存在刷新 token 的机制,也要保证刷新接口只被调用一次。下面的方案是:在错误拦截器里拦截 401,维护一个 isRefreshing 标志,并把等待刷新期间的请求缓存起来,等新 token 拿到后,用新 token 重放这些请求。
先看一下要用的 token 刷新服务,它负责调用后端刷新接口。
// token-refresh.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
export class TokenRefreshService {
constructor(private http: HttpClient) {}
// 调用后端的刷新 token 接口
refreshToken(): Observable<{ token: string }> {
// refresh_token 同样存在本地存储
const refreshToken = localStorage.getItem('refresh_token');
// 这是一个白名单请求,不需要走 AuthInterceptor
return this.http
.post<{ token: string }>('/auth/refresh-token', { refreshToken })
.pipe(
// 拿到新 token 后立即更新本地存储
tap(res => {
localStorage.setItem('access_token', res.token);
})
);
}
}
现在来写处理 401 的核心拦截器。这个拦截器需要处理两类情况:第一类是普通请求返回 401;第二类是刷新 token 的请求本身返回 401,此时说明刷新也失败,应该直接跳到登录页。我们使用一个 pendingQueue 数组来保存等待重放的请求。
// auth-error.interceptor.ts
import { Injectable } from '@angular/core';
import {
HttpInterceptor,
HttpRequest,
HttpHandler,
HttpEvent,
HttpErrorResponse
} from '@angular/common/http';
import { Observable, throwError, BehaviorSubject } from 'rxjs';
import { catchError, filter, switchMap, take, tap } from 'rxjs/operators';
import { TokenRefreshService } from './token-refresh.service';
import { Router } from '@angular/router';
@Injectable()
export class AuthErrorInterceptor implements HttpInterceptor {
// 用于记录当前是否正在刷新 token
private isRefreshing = false;
// 使用 Subject 来通知所有等待的请求:token 已经刷新完成
private refreshSubject = new BehaviorSubject<string | null>(null);
// 这里要注入刷新服务和路由服务
constructor(
private tokenRefreshService: TokenRefreshService,
private router: Router
) {}
intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
return next.handle(req).pipe(
catchError((err: unknown) => {
// 只拦截 HTTP 401 错误,其他错误直接抛给上层
if (err instanceof HttpErrorResponse && err.status === 401) {
// 如果是刷新请求自身返回 401,说明登录状态彻底失效
if (req.url.includes('/auth/refresh-token')) {
this.handleLogout();
return throwError(() => err);
}
// 如果当前没有正在刷新,就启动刷新流程
if (!this.isRefreshing) {
this.isRefreshing = true;
// 通知所有等待者:目前还没有新 token
this.refreshSubject.next(null);
// 调用刷新接口
return this.tokenRefreshService.refreshToken().pipe(
switchMap((res: { token: string }) => {
// 刷新成功,把新 token 记录下来
const newToken = res.token;
this.isRefreshing = false;
// 广播给所有等待中的请求
this.refreshSubject.next(newToken);
// 使用新 token 重放当前请求
return next.handle(this.addTokenToRequest(req, newToken));
}),
catchError((refreshErr: unknown) => {
// 刷新失败,重置状态并跳转登录
this.isRefreshing = false;
this.refreshSubject.next(null);
this.handleLogout();
return throwError(() => refreshErr);
})
);
}
// 如果已经在刷新中,那么把这个请求挂起来,等待 token 刷新完成
return this.refreshSubject.pipe(
// 只关心有值的新 token,null 代表刷新还在进行
filter((token: string | null): token is string => token !== null),
take(1),
// 获取到新 token 后,把它添加到当前请求的 header 里
switchMap(newToken => {
return next.handle(this.addTokenToRequest(req, newToken));
})
);
}
// 其他错误直接抛出去
return throwError(() => err);
})
);
}
// 私有方法:在请求上添加 Authorization 头
private addTokenToRequest(req: HttpRequest<unknown>, token: string): HttpRequest<unknown> {
return req.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
});
}
// 私有方法:统一处理登出,避免重复跳转
private handleLogout(): void {
// 清除本地存储的凭证
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
// 通过路由跳转到登录页
this.router.navigate(['/login']);
}
}
这段代码的关键在于 refreshSubject。第一个请求返回 401 后,它看到没有在刷新,于是发起刷新,并把自己这个请求也放进一个等待状态?注意上面的实现中,第一个请求自身直接就走到刷新逻辑里了,而后续的并发请求则会走到 refreshSubject.pipe(...) 等待。第一个请求刷新成功后,他直接重放了自己;同时 refreshSubject.next(newToken) 会通知其他等待的请求,它们也会重放。这样就保证了刷新接口只被调用一次,其它请求不会重复触发刷新。
如果刷新失败,catchError 里把状态重置,并且调用 handleLogout。由于 isRefreshing 已经变成 false,后续如果用户又手动发起请求,还能再次进入刷新流程,而不是永远卡死。
七、把拦截器注册到模块里
前面的拦截器都写好了,最后一步是注册到 Angular 的依赖注入系统里。我们可以提供一个 HTTP_INTERCEPTORS 的数组,按顺序配置。顺序很重要:AuthInterceptor 要在被请求发出前先加 token,AuthErrorInterceptor 要能捕获 401 同时还要能访问到带着 token 的请求,所以通常放在比较靠后的位置。
// app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { AppComponent } from './app.component';
import { AuthInterceptor } from './middleware/auth.interceptor';
import { ErrorMappingInterceptor } from './middleware/error-mapping.interceptor';
import { LoadingInterceptor } from './middleware/loading.interceptor';
import { AuthErrorInterceptor } from './middleware/auth-error.interceptor';
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
HttpClientModule, // 提供 HttpClient 相关能力
AppRoutingModule
],
providers: [
// 注册拦截器,数组顺序就是触发顺序
{
provide: HTTP_INTERCEPTORS,
useClass: AuthInterceptor,
multi: true // 必须为 true,表示这是一个多值提供者
},
{
provide: HTTP_INTERCEPTORS,
useClass: ErrorMappingInterceptor,
multi: true
},
{
provide: HTTP_INTERCEPTORS,
useClass: LoadingInterceptor,
multi: true
},
{
provide: HTTP_INTERCEPTORS,
useClass: AuthErrorInterceptor,
multi: true
}
],
bootstrap: [AppComponent]
})
export class AppModule { }
当这四层拦截器都生效后,一次请求的生命周期大概是这样的:AuthInterceptor 先给请求加上 token;ErrorMappingInterceptor 检查响应里的业务码;LoadingInterceptor 在请求开始和结束时修改计数;AuthErrorInterceptor 专门盯着 401 响应,一旦出现就协调刷新和重放。每个拦截器各司其职,页面上的代码会变得非常干净。
比如你的用户列表组件,现在只需要这样写:
// user-list.component.ts
import { Component } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BusinessError } from '../core/error.model';
@Component({
selector: 'app-user-list',
template: `
<div *ngIf="users.length === 0">暂无用户</div>
<ul>
<li *ngFor="let user of users">{{ user.name }}</li>
</ul>
`
})
export class UserListComponent {
users: any[] = [];
constructor(private http: HttpClient) {
this.loadUsers();
}
private loadUsers(): void {
this.http.get('/api/users').subscribe({
// 只要走到这里,说明业务成功,直接使用数据
next: (res: any) => {
this.users = res.data;
},
// 错误全部被拦截器包装成 BusinessError
error: (err: BusinessError) => {
// 全局拦截器已经把 message 整理好了
console.error(err.code, err.message);
}
});
}
}
如果以后你想在某个请求上跳过 loading 或者跳过错误映射,只需要在请求里加一个自定义的 header 或者参数,然后在对应的拦截器里判断一下然后放行。这种灵活度也是拦截器相对于手动封装 HttpClient 的优势所在。
八、文章总结
回头看这四个痛点,拦截器给出的方案其实很统一:把公共逻辑收归到一个管道里。身份凭证统一处理后,你永远不会忘带 token;错误码统一映射后,组件里不再出现一长串 if 判断;加载态统一计数后,并发请求不会导致闪烁或提前关闭;401 请求风暴被一个刷新队列化解后,用户不会再遇到登录页闪现一堆的情况。在项目越来越大、接口越来越多的背景下,这样的治理方式能让代码保持稳定。
当然,拦截器并不是万能的。它更适合那些非常规整的请求模型。如果你需要支持大量动态拼接 URL、或者需要针对每种请求做完全不同的错误提示,拦截器依然可以做,但逻辑会复杂。此时你可以考虑在拦截器之上再封装一层 service 方法,让每个 service 自己决定如何展示错误。但不管怎样,把最基本的 token 注入和 401 统一处理交给拦截器,总是值得的。
慢慢地把这些细节打磨好,你的 Angular 项目会变得很好维护,页面代码更清爽,用户也能获得更顺滑的交互体验。从今天开始,试着把你项目里的拦截器升级成这种多角色协作的方式,你会感受到基础设施重构带来的踏实感。
评论
围绕“让Angular的HTTP拦截器真正为业务场景服务,统一携带身份凭证、映射后端错误码并精细控制加载态,避免认证失效时引发连锁请求风暴”参与讨论