前言
HttpClient 是 Angular 对 XMLHttpRequest 和 Fetch 的封装。
HttpClient 的 DX (Developer Experience) 比 XMLHttpRequest 和 Fetch 都好,只是学习成本比较高,因为它融入了 RxJS 概念。
要深入理解 HttpClient 最好先掌握 3 个基础技能:
-
XMLHttpRequest -- 看这篇
-
Fetch -- 看这篇
-
RxJS -- 看这系列 (如果只是为了 HttpClient 不需要看完,不过 RxJS 其实挺好用的,所以我推荐大家把它学起来)
文章来源地址https://www.toymoban.com/news/detail-840677.html
Provide HttpClient
创建 Angular 项目
ng new http-client --ssr=false --routing=false --style=scss --skip-tests
app.config.ts
import { provideHttpClient } from '@angular/common/http'; import { ApplicationConfig } from '@angular/core'; export const appConfig: ApplicationConfig = { // 1. 添加 HttpClient 相关的 providers providers: [provideHttpClient()], };
HttpClient 是一个 Class Provider,我们需要在 appConfig 中提供。
provideHttpClient 源码在 provider.ts
两个点:
-
HttpClient 是主角,其它 Service Provider 本篇也会粗略介绍一下
-
Angular 默认内部是使用 XMLHttpRequest 发送请求,而不是比较 modern 的 Fetch。
withFetch
如果想把默认使用的 XMLHttpRequest 换成 Fetch 也是可以,在 provideHttpClient 参数中添加 withFetch 执行就可以了。
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient(withFetch())],
};
withFetch 函数的源码在 provider.ts
提醒:
Fetch 不支持上传进度哦,这个是 Fetch 目前最大的缺陷,这也是为什么 Angular 任然以 XMLHttpRequest 作为默认。
Simple Get Request & Response
import { HttpClient } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; interface Product { id: number; name: string; } @Component({ selector: 'app-root', standalone: true, imports: [], templateUrl: './app.component.html', styleUrl: './app.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class AppComponent { // 1. inject HttpClient private readonly httpClient = inject(HttpClient); sendRequest() { // 2. create HTTP get products Observable const products$ = this.httpClient.get<Product[]>('https://192.168.1.152:44300/products'); // 3. subscribe Observable products$.subscribe(products => console.log(products)); // [{ id: 1, name: 'iPhone14' }, { id: 2, name: 'iPhone15' }] } }
短短几行代码里隐藏了诸多的概念。我们一条一条看。
-
HttpClient 是通过 DI 注入的
-
HttpClient.get 方法返回的是一个 RxJS 的 Observable。
Observable 有 Deferred Execution (延期执行) 概念,也就是说 Observable 在 subscribe 之前是不会发出请求的,subscribe 了才会发。
另外,Observable 有 Unicast 概念,也就是说每一次执行 Observable.subscribe 都会发送一次请求。
-
HttpClient.get 方法有一个泛型,这个用于表达 response 的数据类型。
Angular 默认 XMLHttpRequest.responseType = 'json'。Observable 返回的是 XMLHttpRequest.response。
所以上面例子 subscribe Observable 可以直接获得 Array<Product>。
Observable.subscribe to await Promise
当 Observable 被立刻 subscribe 执行,同时它内部是一个异步发布,而且只发布一次,这个时候它和 Promise 最像,通常使用 Promise 会更恰当。
我们上面发请求的例子就完全满足了 Observable to Promise 的条件。这种时候用 Promise 会更恰当。
通过 await + firstValueFrom 我们可以把 Observable 转换成 Promise,这样代码就整齐了。
export class AppComponent { private readonly httpClient = inject(HttpClient); async sendRequest() { const products = await firstValueFrom(this.httpClient.get<Product[]>('https://192.168.1.152:44300/products')); console.log(products); // [{ id: 1, name: 'iPhone14' }, { id: 2, name: 'iPhone15' }] } }
Response Status
上面例子中,我们可以看到,在默认情况下 Angular 只会返回数据,不会返回 Response Status 😲。
这是因为当 Response Status 不在 200-299 内时,Angular 会 throw error。这个处理方式和 XMLHttpRequest 或 Fetch 是不一样的哦。
XMLHttpRequest 或 Fetch 只有在请求失败 (networking issue) 时才会 throw error,Response Status 即便是 400-599 都不会 throw error。
try { const products = await firstValueFrom(this.httpClient.get<Product[]>('https://192.168.1.152:44300/products')); console.log(products); } catch (error) { if (error instanceof HttpErrorResponse) { const errorResponse = error; console.log('error status', errorResponse.status); // 400 } }
如果我们希望获取到 Response Status 甚至整个 Response,我们可以这样设置
const response = await firstValueFrom( this.httpClient.get<Product[]>('https://192.168.1.152:44300/products', { observe: 'response', }) ); console.log(response.status); // 200 console.log(response.body); // [ { id: 1, name: 'iPhone14' }, { id: 2, name: 'iPhone15' } ]
observe 用于表达我们想观察的对象,默认是 'body',所以 Observable 直接返回 Response Body 数据。
observe: 'response' 就是表达让 Observable 返回整个 Response。
还有一个 observe: 'events' 下面会教。
Request with Query Parameters
Fetch 和 XMLHttpRequest 都没有 built-in 对 Query Parameters 的处理,但 Angular 有!而且它很特别,特别容易掉坑😜。
const queryParameters = new HttpParams({ fromObject: { key1: 'value1', key2: 'value2', }, }); console.log(queryParameters.toString()); // key1=value1&key2=value2 const products = await firstValueFrom( this.httpClient.get<Product[]>('https://192.168.1.152:44300/products', { params: queryParameters, }) );
HttpParams 是 Angular built-in 的 class。它类似 URLSearchParams,但又不太一样。
在 HttpClient.get 时传入参数 params 就可以了,Angular 会把 queryParameters.toString() 拼接到 Request URL。
Shortcut Way
我们也可以直接把 fromObject 当作 params 参数。
const products = await firstValueFrom( this.httpClient.get<Product[]>('https://192.168.1.152:44300/products', { params: { key1: 'value1', key2: 'value2', }, }) );
HttpClient.get 内部会替我们 new HttpParams。
HttpParams vs URLSearchParams?
这 2 句是等价的
const urlSearchParams = new URLSearchParams({ key1: 'value1' }); const httpParams = new HttpParams({ fromObject: { key1: 'value1' } });
这 2 句是等价的
const urlSearchParams = new URLSearchParams('?key1=value1'); // 不 starts with ? 也可以 const httpParams = new HttpParams({ fromString: '?key1=value1' }); // 不 starts with ? 也可以
Immutable
这 2 句是不等价的
urlSearchParams.append('key2', 'value2');
httpParams.append('key2', 'value2');
因为 HttpParams 有 immutable 概念,要添加 key 需要 reassignment。
httpParams = httpParams.append('key2', 'value2');
Encoding
HttpParams 和 URLSearchParams encode 的方式是不同的
const urlSearchParams = new URLSearchParams({ key1: 'v,alue 1' }); let httpParams = new HttpParams({ fromObject: { key1: 'v,alue 1' } }); console.log(urlSearchParams.toString()); // key1=v%2Calue+1 console.log(httpParams.toString()); // key1=v,alue%201
比如
URLSearchParams encode 空格会变成 + 加号,HttpParams 会变成 %20。
URLSearchParams encode , 逗号会变成 %2c,HttpParams 依然时 , 逗号。
这样的不一致自然有很多人都抱怨过,相关 Issue: HttpParameterCodec improperly encodes special characters like '+' and '='
不过 Angular Team 视乎不太敢去修改它。因为它是 legacy code。
相关源码在 params.ts
Custom Encoder
如果我们不能接受 HttpParams 的 encode 方式,我们可以提供一个自定义的 encoder 来解决这个问题。
const customEncoder: HttpParameterCodec = { decodeKey(key) { return decodeURIComponent(key); }, decodeValue(value) { return decodeURIComponent(value); }, encodeKey(key) { return encodeURIComponent(key); }, encodeValue(value) { return encodeURIComponent(value); }, }; const queryParameters = new HttpParams({ encoder: customEncoder, fromObject: { key1: 'v,alue', }, }); console.log(queryParameters.toString()); // key1=v%2Calue const products = await firstValueFrom( this.httpClient.get<Product[]>('https://192.168.1.152:44300/products', { params: queryParameters, }) );
Request with Header
和 query parameters 一样,在 HttpClient.get 时传入参数即可。
const products = await firstValueFrom( this.httpClient.get<product[]>('https://192.168.1.152:44300/products', { headers: { Accept: 'application/json', }, }) );
或者先创建 HttpHeaders 对象
let headers = new HttpHeaders({ Accept: 'application/json', }); // HttpHeaders 和 HttpParams 一样是 Immutable,add header 需要 reassignment headers = headers.append('Custom-Header', 'value'); const products = await firstValueFrom( this.httpClient.get<product[]>('https://192.168.1.152:44300/products', { headers, }) );
另外,Angular 默认会替我们设置 Accept Header 和 Content-Type Header。
相关源码在 xhr.ts
Response Header
首先 observe: 'response'
const response = await firstValueFrom( this.httpClient.get<Product[]>('https://192.168.1.152:44300/products', { observe: 'response', }) );
接着
console.log(response.headers.get('custom')); // a
如果遇到重复的 header 那么它会合并起来
console.log(response.headers.get('custom')); // a, b
通过 getAll 方法可以让它返回 Array<string>
console.log(response.headers.getAll('custom')); // ['a', 'b']
通过 key 方法 foreach all headers
for (const headerKey of response.headers.keys()) { console.log([headerKey, response.headers.get(headerKey)]); // ['key', 'a, b'] }
Cross-Origin 跨域请求携带 Cookie
和 XMLHttpRequest 一样,Angular 也有 withCredentials 设置。
const products = await firstValueFrom( this.httpClient.get<Product[]>('https://192.168.1.152:44300/products', { withCredentials: true, }) );
规则也和 XMLHttpRequest 完全一样。
Request Error
XMLHttpRequest 的 Request Error 指的是请求失败,通常是 networking issue。
Angular 只要不是 status 200-299 都算 Request Error,所以我们需要做额外的判断才可以确定是不是 networking issue。
try { const products = await firstValueFrom(this.httpClient.get<Product[]>('https://192.168.1.152:44300/products')); } catch (error) { if ( error instanceof HttpErrorResponse && error.error instanceof TypeError && error.error.message === 'Failed to fetch' ) { console.log('networking issue'); } else { // other error } }
HttpErrorResponse.error 就是 original XMLHttpRequest 的 error。
Abort Request
Angular 是透过 unsubscribe observable subscription 来做到 abort request 的。
const products$ = this.httpClient.get<Product[]>('https://192.168.1.152:44300/products'); const subscription = products$.subscribe(products => console.log(products)); setTimeout(() => { subscription.unsubscribe(); }, 1000);
相关源码在 xhr.ts
这里有一个 RxJS 的知识点要留意,当 subscription 被 unsubscribe 后,Observable.subscribe 的 next, error, complete 都不会在接收到任何发布。
const products$ = this.httpClient.get<Product[]>('https://192.168.1.152:44300/products'); // need 5 seconds to respond const subscription = products$.subscribe({ next() {}, // won't be called error() {}, // won't be called complete() {} // won't be called }); setTimeout(() => { subscription.unsubscribe(); }, 1000);
如果我们想在 abort 后做一些事情,比较常见的手法是参考 Fetch,搞一个类似 AbortControler 的概念。
const abortSubject = new Subject<string>(); abortSubject.subscribe(reason => console.log('The request has been aborted, reason: ' + reason)); setTimeout(() => { abortSubject.next('abort reason'); }, 1000); try { const products = await firstValueFrom( this.httpClient.get<Product[]>('https://192.168.1.152:44300/products').pipe(takeUntil(abortSubject)) ); } catch (error) { if (error instanceof Object && 'name' in error && error.name === 'EmptyError') { console.log('The request has been aborted'); } }
这里也有 RxJS 的知识点要留意,takeUntil 会 unsubscribe 上游 (HttpClient) 导致 request 被 abort,同时它会发布 complete 到下游。
firstValueFrom 在接收到 complete 后发现没有 value 就会 throw 一个 EmptyError。
所有有 2 种方式可以监听到 abort,第一种就是直接 subscribe 源头 abortSubject,第二种就是 catch products$ 的 EmptyError (虽然不是 100% 准,而且拿不到 reason)。
Request Timeout
Angular 底层虽然是用 XMLHttpRequest,但它却像 Fetch 那样不支持 timeout 设置。
我们只可以用 Abort Request 的方式来实现 Request Timeout,也是醉了😵。
HttpEvent
XMLHttpRequest 在请求的时候会发布很多事件,比如 readystatechange、download progress、upload progress 等等。
Angular 就一个 Observable 返回 response body 或 response,那我们要怎样监听不同阶段的事件呢?比如 XMLHttpRequest.readystate 的 HEADERS_RECEIVED 阶段。
我来给一个完整的例子,里面会涉及到 upload 和 download,但不要在意这部分的代码,我们专注事件监听就好,upload download 下面会再教。
首先是要 POST 的资料,里面包含了一个 file 要 upload。
const formData = new FormData(); formData.append('name', 'iPhone14'); formData.append('document', inputFile.files![0]);
接着是 HttpClient
const httpEvent$ = this.httpClient.post('https://192.168.1.152:44300/products', formData, { responseType: 'blob', observe: 'events', reportProgress: true, });
responseType: 'blob' 表示 response 返回的是 blob,因为我要显示 download event,所以必须返回 blob。
observe: 'body' 指示 HttpClient 返回 Obserable<Blob>
observe: 'response' 指示 HttpClient 返回 Obserable<HttpResponse<Blob>>
observe: 'events' 指示 HttpClient 返回 Obserable<HttpEvent<Blob>>
reportProgress: true 用于 observe: 'events' 的情况,它表示也要发布 upload 和 download progress。
HttpEvent 长这样
源码在 response.ts
HttpEvent 由不同类型的 HttpEvent union 而成,其中一个是 HttpResponse,它也是一种 HttpEvent 哦。
另外,HttpProgressEvent 还可以细分成 upload 和 download。
不同类型的 HttpEvent 会有不同的属性,比如 HttpResponse 有 body 属性,
HttpSentEvent 只有 type 属性
所有的 HttpEvent 至少都有一个 type 属性,这是为了方便做 TypeScript Narrowing。
每个 HttpEvent 发布的时机:
-
HttpSentEvent 是请求发送后第一个发布的事件
-
接着是 HttpUploadProgressEvent
它就是 XMLHttpRequest 的 upload progress 事件
如果 request 没有 body,那就不会发布。
reportProgress: false 也不发布。
-
接着是 HttpHeaderResponse
它在第一个 download progress 事件时发布
提醒:由于它是借助 download progress event 发布的,所以 reportProgress: false 的情况下它是不发布的。
-
接着是 HttpDownloadProgressEvent
它就是 XMLHttpRequest 的 download progress 事件
reportProgress: false 的情况下不发布。
-
HttpResponse
内部监听的是 XMLHttpRequest 的 load 事件。提醒:load 事件在 request error 和 abort 的情况下是不发布的哦。
-
HttpUserEvent
这个是我们自定义的 HttpEvent,下面会教。
HttpEvent 的使用方式是这样:
httpEvent$.subscribe(httpEvent => { if (httpEvent.type === HttpEventType.Sent) { console.log('request sent'); } else if (httpEvent.type === HttpEventType.UploadProgress) { console.log('uploading request body'); } else if (httpEvent.type === HttpEventType.ResponseHeader) { // 1. TypeScript Narrowing 后 httpEvent 的类型就从 HttpEvent 变成 HttpHeaderResponse 类型 // 可以拿到 headers 属性等等。 const contentLength = httpEvent.headers.get('Content-Length'); console.log('response header loaded'); } else if (httpEvent.type === HttpEventType.DownloadProgress) { console.log('downloading response body'); } else if (httpEvent.type === HttpEventType.Response) { // 2. TypeScript Narrowing 后 httpEvent 的类型就从 HttpEvent 变成 HttpResponse 类型 // 可以拿到 body 属性等等。 const body = httpEvent.body; console.log('response body loaded'); } });
Request 对象
Fetch 有 Request、Headers、Response 对象。
HttpClient 有 HttpRequest、HttpHeaders、HttpParams、HttpResponse 对象。
Fetch vs HttpClient:
-
Fetch 的 Request 和 Response 有 clone 方法,HttpClient 的 HttpRequest 和 HttpResponse 也有
-
Fetch 没有 Params,HttpClient 有 HttpParams
-
Fetch 的对象都是 mutable,HttpClient 的对象都是 immutable
创建 HttpRequest 对象
// 创建 HttpParams const params = new HttpParams({ fromObject: { key1: 'value1', }, }); // 创建 HttpHeaders const headers = new HttpHeaders({ custom: 'value1', }); // 创建 HttpRequest,只传入 HttpParams let request = new HttpRequest<Product[]>('GET', 'https://192.168.1.152:44300/products', { params, }); // HttpRequest, HttpHeaders 和 HttpParams 都是 Immutable, // 想要 add HttpHeaders 或修改 URL 等等,都需要使用 clone 方法做 reassignment request = request.clone({ headers, });
发送 HttpRequest
const httpEvent$ = this.httpClient.request<Product[]>(request);
HttpClient.request 有很多重载,但是可放入 HttpRequest 的只有一个,它的类型是
返回的类型竟然是 Observable<HttpEvent<R>>,而且这个 R 泛型竟然和 HttpRequest 的泛型不一致😕
如果我们只想获取 response body,可以这样写
const products = await firstValueFrom( this.httpClient.request(request).pipe( // 过滤出 HttpResponse 事件 filter((e): e is HttpResponse<Product[]> => e.type === HttpEventType.Response), // 从 HttpResponse 提取出 body map(httpResponse => httpResponse.body!) ) );
代码挺繁琐的,这个接口设计的太烂了。
Download File
除了请求 JSON 数据,偶尔我们也会需要下载文件。
download txt file
const memoryStream = await firstValueFrom( this.httpClient.get('https://192.168.1.152:44300/data.txt', { responseType: 'arraybuffer', }) ); const bytes = new Uint8Array(memoryStream); const textDecoder = new TextDecoder(); const text = textDecoder.decode(bytes); console.log(text); // 'Hello World'
关键是 responseType: 'arraybuffer',它会返回 ArrayBuffer,再通过 Uint8Array 和 TextDecoder 从 ArrayBuffer 读取 data.txt 的内容。
Download Video
Video 通常 size 比较大,用 ArrayBuffer 怕内存会不够,所以用 Blob 会比较合适。
const blob = await firstValueFrom( this.httpClient.get('https://192.168.1.152:44300/video.mp4', { responseType: 'blob', }) ); console.log(blob.size / 1024); // 124,645 kb console.log(blob.type); // video/mp4
download progress
文件大下载慢,最好可以显示进度条
首先是 HttpClient.get 的设置
const httpEvent$ = this.httpClient.get('https://192.168.1.152:44300/video.mp4', { responseType: 'blob', // response 类型是 blob observe: 'events', // 返回 Observable<HttpEvent> reportProgress: true, // 要监听 progress });
接着过滤出 download progress event
const downloadProgressEvent$ = httpEvent$.pipe( // 过滤出 download progress event filter((e): e is HttpDownloadProgressEvent => e.type === HttpEventType.DownloadProgress) )
接着 subscribe
downloadProgressEvent$.subscribe(e => { const percentage = ((e.loaded / e.total!) * 100).toFixed() + '%'; console.log(percentage); console.log(e.partialText); });
HttpDownloadProgressEvent 有 loaded 和 total 属性,可以计算出 percentage。
partial data on downloading
XMLHttpRequest 支持 partial data (下载未完成前,获取当前已下载的数据),但仅限于 responseType = 'text',其它 response type 不支持 partial data。
Fetch 支持 ReadableStream 所以任何 response type 都可以拿到 partial data。
那 HttpClient 呢?
当无法统一做到最好时,那就统一做到最差,这是 Angular Team 的风格🙄。
所以 Angular 选择了跟随 XMLHttpRequest 的行为,只支持 responseType: 'text'。
downloadProgressEvent$.subscribe(e => {
console.log(e.partialText);
});
当 responseType 不是 'text' 时,event.partialText 会是 undefined。
提醒:即便我们用 withFetch 把 HttpBackend 切换成 Fetch,它依然只会处理 responseType: 'text' 的情况。
相关源码在 fetch.ts
POST Request
POST 和 GET 大同小异
POST JSON Data
const productDto = { name: 'iPhone12', }; const response = await firstValueFrom( this.httpClient.post('https://192.168.1.152:44300/products', productDto, { observe: 'response', }) ); console.log(response.status); // 201 console.log(response.body); // { id: 1, name: 'iPhone12' }
我们不需要添加 Content-Type header,也不需要 JSON.stringify,HttpClient 会依据我们传入的 body 类型做各种处理。
POST FormData or FormUrlEncoded (multipart/form-data or application/x-www-form-urlencoded)
POST FormData or FormUrlEncoded 和 POST JSON data 大同小异
// POST multipart/form-data const productFormData = new FormData(); productFormData.set('name', 'iPhone12'); // POST application/x-www-form-urlencoded const productFormUrlEncoded = new HttpParams({ fromObject: { name: 'iPhone12', }, }); const response1 = await firstValueFrom( this.httpClient.post('https://192.168.1.152:44300/products', productFormData, { observe: 'response', }), ); const response2 = await firstValueFrom( this.httpClient.post('https://192.168.1.152:44300/products', productFormUrlEncoded, { observe: 'response', }), );
只是把 body 数据从 Object 换成 FormData or HttpParams 就可以了。
提醒:Angular 只认 HttpParams,URLSearchParams 不行哦
POST Blob (upload file)
FormData 支持 Blob 类型的 value,所以我们可以使用 FormData 上传二进制文件。
const productFormData = new FormData(); productFormData.set('name', 'iPhone12'); const productDocument = 'Product Detail'; const textEncoder = new TextEncoder(); const bytes = textEncoder.encode(productDocument); const blob = new Blob([bytes], { type: 'text/plain', }); productFormData.set('document', blob); const response = await firstValueFrom( this.httpClient.post('https://192.168.1.152:44300/products', productFormData, { observe: 'response', }) );
或者直接发送 Blob 也是可以的。
const productDocument = 'Product Detail'; const textEncoder = new TextEncoder(); const bytes = textEncoder.encode(productDocument); const blob = new Blob([bytes], { type: 'text/plain', // 如果二进制没有明确类型,type 就放 application/octet-stream }); const response = await firstValueFrom( this.httpClient.post('https://192.168.1.152:44300/products', blob, { observe: 'response', }) );
upload progress
和 download 大同小异。
const httpEvent$ = this.httpClient.get('https://192.168.1.152:44300/video.mp4', { observe: 'events', // 返回 Observable<HttpEvent> reportProgress: true, // 要监听 progress }); const uploadProgressEvent$ = httpEvent$.pipe( // 过滤出 upload progress event filter((e): e is HttpUploadProgressEvent => e.type === HttpEventType.UploadProgress) ); uploadProgressEvent$.subscribe(e => { const percentage = ((e.loaded / e.total!) * 100).toFixed() + '%'; console.log(percentage); });
提醒:Fetch 不支持 upload progress event,如果我们通过 withFetch 把 HttpBackend 从默认的 XMLHttpRequest 换成 Fetch,那这个 HttpUploadProgressEvent 将不会发布。
小总结
以上是 XMLHttpRequest 和 Fetch 常见功能在 HttpClient 上的体现。
HttpClient 借鉴了一些 XMLHttpRequest 的使用体验,比如设置 responseType 属性,
又借鉴了 Fetch 的使用体验,比如 HttpRequest clone,
最后在加入 RxJS 和 TypeScript Overload。
怎么说呢...你说它集结各家所长吧,也对。你说它用起来得心应手嘛,倒也没有,但学习成本确实提高了不少。
近年 Angular 一直在尝试降低它的入门门槛,或许有一天它们会在 HttpClient 上舍弃 RxJS 吧。
好,下面我们继续学习 HttpClient 的其它扩展功能。
Resend Request When Error (retry)
由于 HttpClient 基于 RxJS,所以它很容易实现 retry。
try { const products = await firstValueFrom( this.httpClient.get<Product[]>('https://192.168.1.152:44300/products').pipe( retry({ delay: (error, retryCount) => { console.log('failed', retryCount); // 条件:只可以 retry 3 次,只有 status 503 才 retry if (retryCount <= 3 && error instanceof HttpErrorResponse && error.status === 503) { return timer(1000); // 延迟 1 秒后才发出 retry request } else { return error; // 其它情况不 retry,直接返回 error } }, resetOnSuccess: true, // reset retry count when success }) ) ); console.log('succeeded', products); // 成功 } catch { console.log('total failed 4 times'); // retry 3 次还是失败,加第一次总共 4 次 request }
不熟悉 RxJS retry 的朋友,可以看这篇 RxJS 系列 – Error Handling Operators
效果
XSRF 跨站请求伪造
不熟悉 XSRF (a.k.a CSRF) 的朋友,可以先看这篇 安全 – CSRF。
传统 Website 防 XSRF 过程:
-
用户访问 bank.com
-
服务端创建一个 Token 随机数,然后把 Token 写入 Cookie,
接着渲染页面 form 时,加入一个 input hidden,value 是 Token。
-
用户 submit form 时,Cookie 和 input hidden 都会被发送到服务端。
-
服务端从 form 和 Cookie 里拿出 2 个 Token 查看是否一致,一致表示请求确实来自 bank.com,于是可以处理。
Angular Web Application 防 XSRF 过程:
-
用户访问 bank.com
-
服务端创建一个 Token 随机数,然后把 Token 写入 Cookie。
由于 Angular 是 Client-side rendering,所以服务端不负责渲染。
-
用户 submit form。
游览器 form submission 会刷新页面,这个体验 Angular Web Application 是不能接受的,所以会改成用 HttpClient 发 request。
-
获取 Cookie 中的 Token,把 Token 添加到 request header,发送。
-
服务端从 header 和 Cookie 里拿出 2 个 Token 查看是否一致,一致表示请求确实来自 bank.com,可以处理。
最关键在第 4 步。
默认情况下 HttpClient 会自动从 Cookie 中获取 Token 并且放入到每一个 request 的 header。
Cookie Name 是 XSRF-TOKEN,Header Name 是 X-XSRF-TOKEN。
相关源码在 xsrf.ts
服务端需要配合的地方是在访问 bacnk.com 时,创建一个随机数 Token 并且返回一个 Cookie XSRF-TOKEN with the Token。
然后在所有 Web API 获取 request header X-XSRF-TOKEN 和 Cookie XSRF-TOKEN 的 Token 做比较,如果两个 Token 一样就处理,不一样就报错。
关闭 XSRF
如果我们不希望 HttpClient 去做这些 XSRF (比如我们使用了 Bearer Token 就不需要 XSRF 了),我们可以通过 withNoXsrfProtection 函数把它关了。
app.config.ts
import { provideHttpClient, withNoXsrfProtection } from '@angular/common/http'; import { ApplicationConfig } from '@angular/core'; export const appConfig: ApplicationConfig = { providers: [provideHttpClient(withNoXsrfProtection())], };
Rename XSRF Cookie and Header Name
如果不喜欢 HttpClient 默认 XSRF 的 Cookie 和 Header Name,可以通过 withXsrfConfiguration 函数做需改
import { provideHttpClient, withXsrfConfiguration } from '@angular/common/http'; import { ApplicationConfig } from '@angular/core'; export const appConfig: ApplicationConfig = { providers: [ provideHttpClient( withXsrfConfiguration({ headerName: 'CSRF-TOKEN', cookieName: 'CSRF-TOKEN', }) ), ], };
XSRF not on GET、HEAD、absolute URL request
GET、HEAD、绝对路径的 request 都不会又 XSRF 概念。
相关源码在 xsrf.ts
因为 GET、HEAD 请求普遍被认为是对服务端没有 side-effect 的,所以即便被 hacker 携带 Cookie 访问也不会对服务端造成伤害。
绝对路径通常被视为是跨域访问,所以也不需要 XSRF。比如说 bank.com 要发 request 到 bank2.com,因为是跨域,HttpClient 无法获取到 bank2.com 的 XSRF-TOKEN Cookie,
也就搞不出 X-XSRF-TOKEN header,那 XSRF 就做不了丫。
拦截请求 Intercept Request and Response
拦截请求指的是拦截所有的请求,并在请求发送前对它进行改装,拦截响应也是同理。
比如说,每个请求都需要添加 Bearer Token Header,如果我们每一个请求都要写一遍 add Bearer Token Header 的代码,这样就很繁琐。
这种情况就可以透过拦截所有请求,然后添加 Bearer Token Header。
before intercept
export class AppComponent { private readonly httpClient = inject(HttpClient); private readonly authen = inject(Authentication); async sendRequest() { const products = await firstValueFrom( this.httpClient.get<Product[]>('https://192.168.1.152:44300/products', { headers: { Authorization: `Bearer ${this.authen.bearerToken}`, }, }) ); } }
每一次 HttpClient.get 我们都需要添加 headers。
after intercept
async sendRequest() { const products = await firstValueFrom(this.httpClient.get<Product[]>('https://192.168.1.152:44300/products')); }
不需要添加 header,同时也不再需要为了 Bearer Token inject Authentication。代码瞬间干净不少。
HttpInterceptorFn
HttpInterceptorFn 是一个 HTTP 拦截者的函数定义,顾名思义,我们需要实现这个接口来拦截请求。
const myInterceptorFn: HttpInterceptorFn = (request, next) => { request = request.clone(); // modify request const response$ = next(request).pipe( filter((httpEvent): httpEvent is HttpResponse<unknown> => httpEvent.type === HttpEventType.Response), map(response => { response = response.clone(); // modify response return response; }) ); return response$; };
参数一 request 是当前拦截到的请求,我们可以 clone 修改它。
参数二 next 是一个代理函数,调用它就是把职责交还给 HttpClient,它会去做后续的处理 (比如:执行下一个 HttpInterceptorFn 或 发请求)。
HttpInterceptorFn 函数最终需要返回 Observable<HttpEvent<unknown>>,
上面例子的过程:
-
首先我们修改了 request
-
然后执行 next 代理函数 with modified request,
它会继续执行其它的 HttpInterceptorFn 或者发送请求。
-
next 函数会返回 Observable<HttpEvent<unknown>>,
Sent -> UploadProgress -> ResponseHeader -> DownloadProgress -> Response
-
我们只关注最后的 Response event,最后修改了 response
提醒:我们不一定要执行 next,也可以自己创建一个新的 Observable<HttpEvent<unknown>>,只要返回的类型正确就可以了。
HttpInterceptor
HttpInterceptor 是面向对象版本的 HttpInterceptorFn,只是写法不一样而已,推荐使用 HttpInterceptorFn。
class MyInterceptor implements HttpInterceptor { intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { request = request.clone(); // modify request const response$ = next.handle(request).pipe( filter((httpEvent): httpEvent is HttpResponse<unknown> => httpEvent.type === HttpEventType.Response), map(response => { response = response.clone(); // modify response return response; }) ); return response$; } }
Provide / Register HttpInterceptorFn
app.config.ts
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient(withInterceptors([myInterceptorFn, myInterceptorFn2, myInterceptorFn3]))],
};
withInterceptors 函数的源码在 provider.ts
原来它是 multiple ValueProvider
Provide HttpInterceptor
export const appConfig: ApplicationConfig = { providers: [ provideHttpClient(), { provide: HTTP_INTERCEPTORS, useClass: MyInterceptor, multi: true }, { provide: HTTP_INTERCEPTORS, useClass: MyInterceptor2, multi: true }, { provide: HTTP_INTERCEPTORS, useClass: MyInterceptor3, multi: true }, ], };
它是 multiple ClassProvider
Inject Service in HttpInterceptorFn and HttpInterceptor
既然 HttpInterceptorFn 和 HttpInterceptor 是 Provider,那自然可以使用 DI 咯。
const myInterceptorFn: HttpInterceptorFn = (request, next) => { const authen = inject(Authentication); // inject Service return next(request); }; class MyInterceptor implements HttpInterceptor { private readonly authen = inject(Authentication); // 或者复古风写法 // private readonly authen!: Authentication; // constructor(authen: Authentication) { // this.authen = authen; // } intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { return next.handle(request); } }
Multiple HttpInterceptorFn Process Flow
app.config.ts
export const appConfig: ApplicationConfig = { providers: [ provideHttpClient(), { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }, { provide: HTTP_INTERCEPTORS, useClass: LoggingInterceptor, multi: true }, ], };
流程图
执行的顺序依据 provde 的顺序。
-
HttpClient.get 后
-
AuthInterceptor.intercept 被调用,修改 request 后调用 next.handle
-
LoggingInterceptor.intercept 被调用,修改 request 后调用 next.handle
-
HttpClient 发送请求到服务端
-
HttpClient 发布 HttpEvent
-
LoggingInterceptor next 返回的 Observable 接收到 HttpEvent,修改 HttpEvent
-
AuthInterceptor next 返回的 Observable 接收到 HttpEvent,修改 HttpEvent
-
HttpClient 接收到 HttpEvent
需要注意的是 Interceptor 修改 request 和 HttpEvent 的时机。
bearerTokenInterceptorFn 例子
Bearer Token HttpInterceptorFn
const bearerTokenInterceptorFn: HttpInterceptorFn = (request, next) => { const authen = inject(Authentication); // inject Authentication Service if (authen.loggedIn) { // 添加 Bearer Token Header const headers = request.headers.append('Authorization', `Bearer ${authen.bearerToken}`); request = request.clone({ headers, }); } return next(request); // process request };
app.config.ts
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient(withInterceptors([bearerTokenInterceptorFn]))],
};
HttpClient
async sendRequest() { const products = await firstValueFrom(this.httpClient.get<Product[]>('https://192.168.1.152:44300/products')); }
HttpClient 和 Interceptors 们之间的沟通
如果有一个特别的请求,想要 skip 掉 Interceptors,要怎么表达,怎样沟通呢?
简单,做一个沟通对象,把对象放到 request 里,每一个 Interceptor 都可以访问 request,自然就可以访问到这个沟通对象,在依据对象内容做相应的处理。
HttpContext
HttpContext 就是这么一个沟通对象,它是一个 key value pair,内部用 Map 来维护沟通内容。
相关源码在 context.ts
Map 的 key 不是 string 类型,而是 HttpContextToken 对象,和 DI 的 InjectionToken 一样,怕 string 会撞名字。
HttpContextToken 没什么特别的,就是一个简单对象而已。
const byPassInterceptorToken = new HttpContextToken(() => false); // false 是 default value
创建 HttpContext 把 byPassInterceptorToken set 进去
let context = new HttpContext(); context = context.set(byPassInterceptorToken, true);
注意:其实 HttpContext 是 mutable🤔,其它 HttpRequest、HttpHeaders、HttpParams、HttpResponse 都是 immutable。
原因
set HttpContext to Request
把 Context 传入 request
const products = await firstValueFrom( this.httpClient.get<Product[]>('https://192.168.1.152:44300/products', { context, }) );
get HttpContext in Interceptor
在 Interceptor 获取 Context
提醒:Interceptor 里也是可以修改或添加 Context 的哦,所有各个 Interceptor 都可以透过 HttpContext 做沟通。
目录
上一篇 Angular 17+ 高级教程 – NgModule
下一篇 Angular 17+ 高级教程 – Reactive Forms
想查看目录,请移步 Angular 17+ 高级教程 – 目录文章来源:https://www.toymoban.com/news/detail-840677.html
到了这里,关于Angular 17+ 高级教程 – HttpClient的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!