Angular 17+ 高级教程 – HttpClient

前言

HttpClient 是 Angular 对 XMLHttpRequest 和 Fetch 的封装。

HttpClient 的 DX (Developer Experience) 比 XMLHttpRequest 和 Fetch 都好,只是学习成本比较高,因为它融入了 RxJS 概念。

要深入理解 HttpClient 最好先掌握 3 个基础技能:

  1. XMLHttpRequest -- 看这篇

  2. Fetch -- 看这篇

  3. RxJS -- 看这系列 (如果只是为了 HttpClient 不需要看完,不过 RxJS 其实挺好用的,所以我推荐大家把它学起来)

 

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

两个点:

  1. HttpClient 是主角,其它 Service Provider 本篇也会粗略介绍一下

  2. 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' }]
  }
}

短短几行代码里隐藏了诸多的概念。我们一条一条看。

  1. HttpClient 是通过 DI 注入的

  2. HttpClient.get 方法返回的是一个 RxJS 的 Observable。

    Observable 有 Deferred Execution (延期执行) 概念,也就是说 Observable 在 subscribe 之前是不会发出请求的,subscribe 了才会发。

    另外,Observable 有 Unicast 概念,也就是说每一次执行 Observable.subscribe 都会发送一次请求。

  3. 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 发布的时机:

  1. HttpSentEvent 是请求发送后第一个发布的事件

  2. 接着是 HttpUploadProgressEvent

    它就是 XMLHttpRequest 的 upload progress 事件

    如果 request 没有 body,那就不会发布。

    reportProgress: false 也不发布。

  3. 接着是 HttpHeaderResponse

    它在第一个 download progress 事件时发布

    提醒:由于它是借助 download progress event 发布的,所以 reportProgress: false 的情况下它是不发布的。

  4. 接着是 HttpDownloadProgressEvent

    它就是 XMLHttpRequest 的 download progress 事件

    reportProgress: false 的情况下不发布。

  5. HttpResponse

    内部监听的是 XMLHttpRequest 的 load 事件。提醒:load 事件在 request error 和 abort 的情况下是不发布的哦。

     

  6. 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:

  1. Fetch 的 Request 和 Response 有 clone 方法,HttpClient 的 HttpRequest 和 HttpResponse 也有

  2. Fetch 没有 Params,HttpClient 有 HttpParams

  3. 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
设置 responseType: 'blob' 就行了。

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 将不会发布。

 

小总结

以上是 XMLHttpRequestFetch 常见功能在 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 过程:

  1. 用户访问 bank.com

  2. 服务端创建一个 Token 随机数,然后把 Token 写入 Cookie,

    接着渲染页面 form 时,加入一个 input hidden,value 是 Token。

  3. 用户 submit form 时,Cookie 和 input hidden 都会被发送到服务端。

  4. 服务端从 form 和 Cookie 里拿出 2 个 Token 查看是否一致,一致表示请求确实来自 bank.com,于是可以处理。

Angular Web Application 防 XSRF 过程:

  1. 用户访问 bank.com

  2. 服务端创建一个 Token 随机数,然后把 Token 写入 Cookie。

    由于 Angular 是 Client-side rendering,所以服务端不负责渲染。

  3. 用户 submit form。

    游览器 form submission 会刷新页面,这个体验 Angular Web Application 是不能接受的,所以会改成用 HttpClient 发 request。

  4. 获取 Cookie 中的 Token,把 Token 添加到 request header,发送。

  5. 服务端从 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>>,

上面例子的过程:

  1. 首先我们修改了 request

  2. 然后执行 next 代理函数 with modified request,

    它会继续执行其它的 HttpInterceptorFn 或者发送请求。

  3. next 函数会返回 Observable<HttpEvent<unknown>>,

    Sent -> UploadProgress -> ResponseHeader -> DownloadProgress -> Response

  4. 我们只关注最后的 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 的顺序。

  1. HttpClient.get 后

  2. AuthInterceptor.intercept 被调用,修改 request 后调用 next.handle

  3. LoggingInterceptor.intercept 被调用,修改 request 后调用 next.handle

  4. HttpClient 发送请求到服务端

  5. HttpClient 发布 HttpEvent

  6. LoggingInterceptor next 返回的 Observable 接收到 HttpEvent,修改 HttpEvent

  7. AuthInterceptor next 返回的 Observable 接收到 HttpEvent,修改 HttpEvent

  8. 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+ 高级教程 – Animation 动画

想查看目录,请移步 Angular 17+ 高级教程 – 目录

 

posted @ 2024-02-03 13:06  兴杰  阅读(352)  评论(0编辑  收藏  举报