2.8 HttpClient 与异步数据 HttpClient 是 Angular 内置的 HTTP 客户端,基于可观察对象封装请求、拦截器、错误处理与类型化响应。变更检测视角下,HTTP 响应是"框架外到达的异步事件"——Zone.js 拦截了底层请求接口,响应回调结束后一轮检查自然到来,这就是"接口回来界面自动刷新"的全部秘密。 表单管用户输入,本节管服务器数据:请求怎么发、拦截器怎么串、错误怎么处理,以及异步数据进入界面的两条路线(订阅回填字段 vs async 管道直连)。 带着一个问题进入 带着一个问题进入:数据从服务器回来之后,是谁唤醒了变更检测?读完这节你能给出完整答案: 发起类型化请求,解释泛型参数如何约束响应形状。
HttpClient 是 Angular 内置的 HTTP 客户端,基于可观察对象封装请求、拦截器、错误处理与类型化响应。变更检测视角下,HTTP 响应是"框架外到达的异步事件"——Zone.js 拦截了底层请求接口,响应回调结束后一轮检查自然到来,这就是"接口回来界面自动刷新"的全部秘密。
表单管用户输入,本节管服务器数据:请求怎么发、拦截器怎么串、错误怎么处理,以及异步数据进入界面的两条路线(订阅回填字段 vs async 管道直连)。
带着一个问题进入:数据从服务器回来之后,是谁唤醒了变更检测?读完这节你能给出完整答案:
import { HttpClient } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; interface Order { orderNo: string; amount: number; status: string } interface Page<T> { items: T[]; total: number } @Injectable({ providedIn: 'root' }) export class OrderApi { private http = inject(HttpClient); // 泛型参数把响应钉成 Page<Order>:订阅里拿到的数据有完整类型 list(kw: string, page: number) { return this.http.get<Page<Order>>('/api/orders', { params: { kw, page: String(page) } }); } create(draft: { amount: number }) { return this.http.post<Order>('/api/orders', draft); } }
注意 HttpClient 是冷可观察对象:声明不发请求,订阅才发;多次订阅多次请求。这一点是后面竞态处理与取消逻辑的基础。
import { HttpInterceptorFn } from '@angular/common/http'; import { inject } from '@angular/core'; import { catchError, throwError } from 'rxjs'; // 函数式拦截器:请求方向加令牌,响应方向统一接错 export const authInterceptor: HttpInterceptorFn = (req, next) => { const token = localStorage.getItem('token') ?? ''; const authed = req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }); return next(authed).pipe( catchError(err => { if (err.status === 401) { // 统一登出逻辑,而不是每个调用点各自处理 localStorage.removeItem('token'); location.assign('/login'); } return throwError(() => err); // 继续向上抛,调用点可再做局部提示 }) ); }; // 注册(应用配置中):provideHttpClient(withInterceptors([authInterceptor]))
多拦截器按注册顺序穿过请求方向、按逆序穿过响应方向——先注册的先改请求、后收到响应。常见组合:认证拦截器在前、日志/重试拦截器在后。
⚠️ 坑:拦截器里返回新的可观察(例如切换到刷新令牌请求再重放原请求)时要小心并发重放,多个 401 同时出现会触发多次刷新;解法是共享一个刷新中可观察,后到者搭车。
import { AsyncPipe } from '@angular/common'; import { Component, inject, OnInit } from '@angular/core'; import { of } from 'rxjs'; @Component({ selector: 'app-order-list-page', imports: [AsyncPipe], template: ` <!-- 路线二:async 管道直连流,不写任何订阅代码 --> <p>共 {{ (page$ | async)?.total ?? 0 }} 条</p> <li *ngFor="let o of (page$ | async)?.items"> {{ o.orderNo }} - {{ o.amount }} </li> ` }) export class OrderListPageComponent implements OnInit { private api = inject(OrderApi); page$ = of<Page2>({ items: [], total: 0 }); // 占位类型见下方说明 ngOnInit() { // 路线一的对照写法(本组件采用路线二,故不启用): // this.api.list('手机', 1).subscribe(p => this.page = p); this.page$ = this.api.list('手机', 1); // 只声明,async 订阅 } } type Page2 = { items: { orderNo: string; amount: number }[]; total: number };
两条路线的本质差别在订阅的管理者:手动订阅,管理者是你(必须 ngOnDestroy 里退订,否则页面离开后回调仍执行、内存泄漏);async 管道,管理者是框架(组件销毁自动退订)。我的默认建议:组件模板直连简单数据用 async;数据要经过多步加工、或要同时写进多个字段,用订阅回填,配合 takeUntil 等模式统一销毁(2.10 节展开)。
async 管道还有一层主线意义:它是不纯管道(呼应 2.5 节)——每轮检查都看一眼流有没有新值,新值到达后标记绑定过期并触发更新。框架把它做成了特化的不纯管道,用极小的检查成本换来了订阅管理的自动化。
背景:订单列表切换筛选标签,快速点"待支付/已发货/已完成",偶发界面显示的是上一次筛选的结果——旧请求晚到覆盖了新响应。
操作:用切换操作符让新请求取代旧订阅:
import { Subject } from 'rxjs'; import { switchMap } from 'rxjs/operators'; export class RaceSafeListComponent { private api = inject(OrderApi); private filter$ = new Subject<string>(); page$ = this.filter$.pipe( switchMap(f => this.api.list(f, 1)) // 新值到达:自动退订旧请求的订阅 ); pick(filter: string) { this.filter$.next(filter); } }
结果:无论点多快,界面最终显示的一定是最后一次筛选的结果。
解读:switchMap 在新值到达时取消对旧内部可观察的订阅,旧响应即使返回也不再有订阅者,赋值无处发生——竞态在数据层被消解,而不是靠界面层兜底。
变式:搜索联想场景用 concatMap 保序(结果必须与输入顺序一致);写操作防重复提交用 exhaustMap(进行中时忽略新触发)。

下一节把"数据从哪来、存哪里"系统化:状态管理。