项目里的接口越来越多,每次发请求都要手动带上 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 里带有 codemessage。为了后续组件里好识别,我们自定义一个 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 的 tapcatchError。收到响应时,先检查 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 了。你只需要在 subscribenext 里拿正常数据,在 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 项目会变得很好维护,页面代码更清爽,用户也能获得更顺滑的交互体验。从今天开始,试着把你项目里的拦截器升级成这种多角色协作的方式,你会感受到基础设施重构带来的踏实感。