为什么要用 Http 模块

在前端开发中,使用 Http 协议进行数据传输是必不可少的。比如获取资源,请求 Restful Api 等。

浏览器提供了 XMLHttpRequest 对象以及 fetch 接口,便于我们向服务器异步请求数据。诸如 JQuery,Axios 等库也帮我们封装了 基于 XMLHttpRequest 对象的请求接口,便于我们使用。

认识 Http 模块

Angular 为我们集成了自己实现的 Http 请求模块 HttpClientModule,位于 @angular/common/http 包下。它提供了如下几个常用的定义

  • HttpClientModule
  • HttpClient
  • HttpInspector

HttpClient

HttpClient 为我们提供了如下几个发起请求的方法

标准的 Request 请求,签名如下

  1. // 发起一个Reuqest请求
  2. request<T>(req:HttpRequest<T>): Observable<HttpEvent<T>>

常用的快捷的请求方式,签名如下

  1. // 这里我们定义一个HttpRequestOptions实际没有这个对象
  2. interface HttpRequestOptions {
  3. // 自定义的 HttpHeaders
  4. headers?: HttpHeaders|{[header: string]: string | string[]},
  5. observe?: 'body',
  6. //请求参数
  7. params?: HttpParams|{[param: string]: string | string[]},
  8. // 是否通进度条 仅request请求时有效
  9. reportProgress?: boolean,
  10. // 响应类型,决定如何反序列化最终的body
  11. responseType: 'arraybuffer'|"blob"|"text"|"json",
  12. // 跨域请求时是否传递cookie
  13. withCredentials?: boolean,
  14. }
  15. // 发起一个Get请求 并直接拿到 HttpResponse 的 body
  16. get<T>(url:string,options?:HttpRequestOptions):Observable<T>
  17. // 发起一个Post请求 并直接拿到 HttpResponse 的 body
  18. post<T>(url:string,body:any,options?:HttpRequestOptions):Observable<T>
  19. // 发起一个 Put 请求 并直接拿到 HttpResponse 的 body
  20. put<T>(url:string,body:any,options?:HttpRequestOptions):Observable<T>
  21. // 发起一个 delete 请求 并直接拿到 HttpResponse 的 body
  22. delete<T>(url:string,options?:HttpRequestOptions):Observable<T>
  23. ... 后边还有 options patch jsonp

HttpRequest

HttpRequest 这个对象封装了我们请求需要的重要信息,其签名如下

  1. export class HttpReuqeust{
  2. // Body
  3. readonly body: T|null = null;
  4. // Headers
  5. readonly headers!: HttpHeaders;
  6. // 是否报告进度 这个通常用于上传&下载
  7. readonly reportProgress: boolean = false;
  8. // 存在跨域请求时是否传递cookie信息
  9. readonly withCredentials: boolean = false;
  10. // 响应类型
  11. readonly responseType: 'arraybuffer'|'blob'|'json'|'text' = 'json';
  12. // 请求类型 get/post/put/delete这些
  13. readonly method: string;
  14. // url参数
  15. readonly params!: HttpParams;
  16. // 带参数的url 对应构造函数中的url
  17. readonly urlWithParams: string;
  18. }

这里我们可以发现 HttpRequest 对象在构造完毕后其所有的属性都是只读的,因此我们期望对某个属性进行更改的时候,我们需要使用 clone()方法产生一个新的 HttpRequest。

Observable 与 Promise

观察上边的签名,我们发现 HttpClient 所有的响应都是 Observable 的,这与 Axios 返回的 Promise 对象有何不同?

Promise 有两种状态,成功(Resolve) 与 失败(Reject),无论变更到哪一种状态,当前的 Promise 对象都已经结束了。这样的设定很符合我们日常向服务器请求的场景,Axios 库就是这样的。但是它有一个问题,就是只有状态变更,没有一个持续化的过程。

Observable 对象是 rxjs 的定义,它是一个可观察对象。它表示了一个流,这个流在完成(Complete)或者出错(Error)之前,会一直的向观察者发送数据。在 HttpClient 中,所有返回的 Observable 对象,最终都会被 complete 掉。

知道了这两个对象的区别,我们来思考一个场景,就是数据上传与下载。

在这个场景下,数据的上传与下载本身是一个耗时的操作。在这个过程中,我们经常会期望有一个进度条,帮助我们知道这项工作当前的进度。此时使用 Promise 就不合适了,Observable 的特性则十分适合这项工作。

使用 HttpClient 发起请求

由于 HttpClient 是由 HttpClientModule 提供的,因此我们需要将这个模块导入进来。通常我们会在根模块导入它,这是由于 HttpClient 是一个注入的服务,在根模块注入后,后续的所有模块都可以通过依赖注入树找到它。

  1. // app.module.ts
  2. import {HttpClientModule} from "@angular/common/http"
  3. @NgModule(
  4. ...
  5. imports:[
  6. ...
  7. HttpClientModule
  8. ]
  9. )
  10. export class AppModule{
  11. }

在导入 HttpClientModule 后,我们的 组件/服务中就可以直接注入 HttpClient 了

  1. export interface User{
  2. name:string;
  3. }
  4. @Component({
  5. ...
  6. })
  7. export class AppComponent{
  8. constructor(private readonly http:HttpClient){}
  9. // 用户数据
  10. user:User[]=[];
  11. ngOnInit(): void {
  12. this.getUsers();
  13. }
  14. // 获取所有用户
  15. getUsers(){
  16. const url = `/api/users`;
  17. this.users = this.http.get<User[]>(url)
  18. .subscribe(users=>this.users = users);
  19. }
  20. addUser(){
  21. const url = `/api/users`;
  22. const vo:User = { name:"New User"}
  23. this.http.post(url,vo).subscribe();
  24. }
  25. }

这里我们用了一个简单的例子来说明了一下 HttpClient 的使用方式,在这里大家应该看到了我所有的请求 最后都 Subscribe 了。这是因为 rxjs 具有延迟执行的特性,即 Observable 在未被 Subscribe 之前是不会调用 XMLHttpRequest 去请求服务器的。

常见问题

Q: “我的请求没有发出去,在 network 里没有看到请求”

A: 这个要么是你压根没调用,要么就是刚刚上边说的问题,你没 subscribe

Q: Network 里看到响应了 HttpStatus 200 但是数据没回来

A: 首先看看有没有出错信息,如果有 分为如下两种情况

  1. 出错信息中有提示 Access-Control-Allow-Origin 表示本次是个跨域请求,解决办法有两个,一个是服务端提供 CORS 相关 Header 的支持,另一个如果是纯粹的 get 请求,在服务器支持的情况下可以使用 jsonp 代替 get。
  2. 其他出错信息,有可能是服务器响应的 content-type 并不是 json。此时需要在请求时将 responseType 设置为对应的类型 ,这里支持 arrayBuffer / blob / text /json,在处理 Response 信息的时候,HttpClient 会根据期望的类型进行不同的反序列化操作

如果没有,看看有没有其他影响吧,比如 拦截器,又或者逻辑问题。

Q: 我们要求请求头里带 Token 怎么办?

A: 方法一,在请求的 options 的 headers 里 添加 token 信息

  1. this.http.get(url,{headers:{"X-AUTH-TOKEN":token}})

方法二,写一个拦截器,统一处理所有的请求

文件的上传与下载

文件上传与文件下载,在我们的页面中也是很常见的一个操作。

不考虑进度的上传&下载

在不考虑进度的情况下,上传与下载文件比较简单,直接使用 get,post 这些快捷接口就可以实现

  1. // 上传 这里与服务端约定的key是file method为post
  2. upload(file:File){
  3. const url =`/api/files`
  4. // 包装为FormData对象
  5. const formData= new FormData();
  6. formData.append("file",file,file.name);
  7. // 直接post就完了 body为FormData 在请求时content-type会被设置成 multipart/form-data
  8. this.http.post(url,formData).subscribe(()=>{
  9. // Todo
  10. });
  11. }
  12. // 下载
  13. download(){
  14. const url =`/api/files?name=demo.txt`;
  15. //直接发起请求, 并设置responseType为 blob 即二进制,此时不会对body进行任何的反序列化操作
  16. this.http.get(url,{responseType:"blob"}).subscribe((data)=>{
  17. // 用a标签处理后续
  18. const blob = new Blob([data], { type: "application/octet-stream" });
  19. const objectUrl = URL.createObjectURL(blob);
  20. const a = document.createElement('a');
  21. a.setAttribute('style', 'display:none');
  22. a.setAttribute('href', objectUrl);
  23. a.setAttribute('download', "download-file");
  24. a.click();
  25. URL.revokeObjectURL(objectUrl);
  26. })
  27. }

带进度的上传&下载

之前我们说过 Observable 对象的优势,就是在 complete 或者 error 之前,可以一直传递数据,因此 HttpClient 也利用了这个特性。

在之前我们看到它提供的标准请求接口是 request,这个接口返回的是 HttpEvent,我们先来看下 HttpEventType 的签名

  1. enum HttpEventType {
  2. // 当前请求已经发出
  3. Sent,
  4. // 收到上传进度事件
  5. UploadProgress,
  6. // 收到了响应状态码与响应状态头
  7. ResponseHeader,
  8. // 收到了下载进度事件
  9. DownloadProgress,
  10. // 包括响应体在内的完整响应对象
  11. Response,
  12. // 来自拦截器或者后端的自定义事件
  13. User
  14. }

这些 Type 又提供了对应的 HttpEvent 实现 ,这里我们关注下 HttpUploadProgressEvent 和 HttpDownloadProgressEvent 的签名

  1. // HttpUploadProgressEvent
  2. interface HttpUploadProgressEvent extends HttpProgressEvent {
  3. type: HttpEventType.UploadProgress
  4. // 继承自 common/http/HttpProgressEvent
  5. type: HttpEventType.DownloadProgress | HttpEventType.UploadProgress
  6. // 已经上传的字节数
  7. loaded: number
  8. // 要上传的总字节数
  9. total?: number
  10. }
  11. // HttpDownloadProgressEvent
  12. interface HttpDownloadProgressEvent extends HttpProgressEvent {
  13. type: HttpEventType.DownloadProgress
  14. partialText?: string
  15. // 继承自 common/http/HttpProgressEvent
  16. type: HttpEventType.DownloadProgress | HttpEventType.UploadProgress
  17. // 已经下载的字节数
  18. loaded: number
  19. // 要下载的字节数
  20. total?: number
  21. }

在这里我们可以看到,这两个 event 我们可以拿到总字节数与已完成的字节数,此时我们的进度条就出来了。

那怎么样算是完成了呢?很简单,ResponseEvent 发生的时候,下面我们来做个完整的例子

  1. @Component({
  2. ...
  3. })
  4. export class AppComponent{
  5. constructor(private readonly http:HttpClient){}
  6. isUploading:boolean = false;
  7. uploadPercentage:number = 0;
  8. isDownloading:boolean = true;
  9. downloadPercentage:number = 0;
  10. // 上传文件
  11. uploadFile(){
  12. const url = `/api/files`;
  13. // 这里我们就不写具体代码了
  14. const file = ...;
  15. // 构建FormData
  16. const formData = new FormData();
  17. formData.append("file",file,file.name);
  18. // 构建HttpRequest
  19. const request = new HttpRequest("post",url,formData);
  20. this.isUploading = true;
  21. this.http.request(request)
  22. .pipe(
  23. finialize(()=>{
  24. this.isUploading = false;
  25. this.uploadPercentage = 0;
  26. })
  27. )
  28. .subscribe(e=>{
  29. if(e.type === HttpEventType.UploadProgress){
  30. this.uploadPercentage = e.loaded / e.total ;
  31. }else if (e.type ===HttpEventType.Response){
  32. // 上传完毕 Todo
  33. }
  34. })
  35. }
  36. // 下载文件
  37. download(){
  38. const url =`/api/files?name=demo.txt`;
  39. // 构建 HttpRequest
  40. const request = new HttpRequest("get",url,{responseType:"blob"});
  41. this.isDownloading = true;
  42. //直接发起请求, 并设置 responseType 为 blob 即二进制,此时不会对 body 进行任何的反序列化操作
  43. this.http.request(request)
  44. .pipe(
  45. finialize(()=>{
  46. this.isDownloading = false;
  47. this.downloadPercentage = 0;
  48. })
  49. ).subscribe((e)=>{
  50. if(e.type===HttpEventType.DownloadProgress){
  51. this.downloadPercentage = e.loaded / e.total ;
  52. }else if(e.type === HttpEventType.Response){
  53. // 用 a 标签处理后续
  54. const blob = new Blob([data], { type: "application/octet-stream" });
  55. const objectUrl = URL.createObjectURL(blob);
  56. const a = document.createElement('a');
  57. a.setAttribute('style', 'display:none');
  58. a.setAttribute('href', objectUrl);
  59. a.setAttribute('download', "download-file");
  60. a.click();
  61. URL.revokeObjectURL(objectUrl);
  62. }
  63. })
  64. }
  65. }

拦截器

HttpCilentModule 为我们提供了一个叫拦截器(Interceptor)概念,用来处理请求过程中的数据,拦截器的签名如下

  1. interface HttpInterceptor {
  2. intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>>
  3. }

如果我们想要实现自己的拦截器,只需要实现这个接口 并标记为可被注入,下面我们来实现一个 TokenHttpInterceptor 用来自动帮们处理 Token

  1. @Injectable()
  2. export class TokenHttpInterceptor implements HttpInterceptor{
  3. private readonly TOKEN_STORAGE_KEY = "appToken";
  4. // 实现接口的方法
  5. intercept(req:HttpRequest<any>,next:HttpHandler):Observable<HttpEvent<any>>>{
  6. // 这里我们从localstorage拿到我们的Token
  7. const cachedToken = localstorage.getItem(this.TOKEN_STORAGE_KEY);
  8. // 由于之前说过 HttpRequest的所有属性都是只读的,所以我们不能直接更改,因此我们需要clone一个修改后的副本
  9. const request = req.clone({ headers:req.headers.append("token",cachedToken)});
  10. // 把这个request对象,交给下一个HttpHandler处理,可能是下一个拦截器,也可能是XhrBackend
  11. return next.handle(req)
  12. .pipe(
  13. tap(e=>{
  14. // 这里拿到的是HttpEvent,这里我们需要的类型只有Response
  15. if(e.type === HttpEventType.Response){
  16. //从body中拿到token 这里我们与服务端的约定是{token:""}
  17. const {token} = e.body;
  18. if(token){
  19. // 保存到localStorage里
  20. localStorage.setItem(this.TOKEN_STORAGE_KEY,token);
  21. }
  22. })
  23. )
  24. }
  25. }

我们实现了接口,但是这个拦截器现在还没有生效,这是因为我们没有把它注入到我们的依赖树中

  1. @NgModule({
  2. ...
  3. imports:[
  4. ...
  5. HttpClientModule,
  6. ],
  7. providers:[{provide:HTTP_INTERCEPTORS,multi:true,useClass:TokenHttpInterceptor}]
  8. })
  9. export class AppModule{}

现在我们每次的 Http 请求 都会检查一遍 Token,在请求时添加到 HttpHeaders 中,在响应的时候如果发现了新的 Token,则更新我们的 localStorage。

拦截器的执行顺序

HTTP_INTERCEPTORS 这个 provider,是 multi 的,这表示我们的拦截器可以存在多个,这里就有一个顺序问题。假设我们依序注入了 3 个拦截器 A,B,C。

  1. @NgModule({
  2. ...
  3. imports:[
  4. ...
  5. HttpClientModule,
  6. ],
  7. providers:[
  8. {provide:HTTP_INTERCEPTORS,multi:true,useClass:AHttpInterceptor},
  9. {provide:HTTP_INTERCEPTORS,multi:true,useClass:BHttpInterceptor},
  10. {provide:HTTP_INTERCEPTORS,multi:true,useClass:CHttpInterceptor},
  11. ]
  12. })
  13. export class AppModule{}

由于在源码中,对 Interceptors 数组进行了 reduceRight 操作,因整个处理链路是这样的。

请求:

CHttpInterceptor -> BHttpInterceptor -> AHttpInterceptor -> XhrBackend

响应

XhrBackend -> AHttpInterceptor -> BHttpInterceptor -> CHttpInterceptor

因此我们在注入拦截器的时候,需要注意注册的顺序。

由于拦截器是链式调用,因此我们需要考虑到异常的问题,因为某一个拦截器出现异常,都会阻止后续的执行,此时我们需要根据需要,决定是否要 catch 掉异常。如果出现在请求阶段,使用正常的 try catch 保证 next.handle()被正常调用,如果出现在响应阶段,可以使用 rxjs 的 catchError()操作符来处理异常。

拦截器的注入时机

拦截器的注入时机,也是一个容易引起 bug 的地方,这是由于依赖注入规则导致的。

我们在日常写项目的时候,不可能把所有的的代码都放到一个模块里,因此我们会分割出很多的模块,并且在路由中设置为懒加载。

拦截器出问题的情况,通常出现在我们为懒加载模块提供了 新的 HTTP_INTERCEPTORS 的情况下。

首先我们要明确一点,就是 HTTP_INTERCEPTORS ,提供的是 ModuleInjector。这种注入器,在遇到懒加载模块时,会提供一个新的实例。而 查找依赖的顺序是 优先从自己这一级找,如果没有,则往父级找,依次递归,直到到 RootInjector 都没有,就会返回一个 NullInjector,如果没有标记依赖是@Optional 的情况下,就会引起报错。

我们在懒加载模块中提供 HTTP_INTERCEPTORS 时,会存在如下几种情况

  1. 啥都没干
  2. 直接提供了 HTTP_INTERCEPTORS 的 Provider 没有引入 HttpClientModule
  3. 提供了 HTTP_INTERCEPTORS 的 Provider 同时重新引入了 HttpClientModule
  4. 重新引入了 HttpClientModule,没有提供 HTTP_INTERCEPTORS 的 Provider

啥都没干

这种情况下,根据依赖注入树的查找原则,会拿到父级的 HttpClient,以及父级提供的 HTTP_INTERCEPTORS

直接提供了 HTTP_INTERCEPTORS 没有引入 HttpClientModule

此时根据依赖注入树的查找原则,会拿到父级的 HttpClient,但是父级的 Client 会从哪找 HttpInterceptor 呢?自然也是父级开始,因此提供的 HTTP_INTERCEPTORS 全部报废。

提供了 HTTP_INTERCEPTORS 的 Provider 同时重新引入了 HttpClientModule

此时就有意思了,由于懒加载模块,会产生新的 HttpClient 实例,因此此时拿到的 HttpClient,实在懒加载模块中的实例,这时候它会找同级别的 HTTP_INTERCEPTORS。

由于我们在这个模块中也提供了 HTTP_INTERCEPTORS,所以这个模块注入器中的 HTTP_INTERCEPTORS 实例 是我们在这个模块的 Providers 里提供的,跟父级的是完全两个不同的数组实例。因此生效的是我们新注入的 HTTP_INTERCEPTORS,父级注入的 Providers 被抛弃掉了。

重新引入了 HttpClientModule,没有提供 HTTP_INTERCEPTORS 的 Provider

这个不难理解,我们拿到的 HttpClient 实例,是当前模块的实例,但是由于当前模块不存在 HTTP_INTERCEPTORS 的实例,因此会依赖注入树中继续向上递归,此时可以拿到父级提供的 HTTP_INTERCEPTORS,虽然实际的结果和不重新引入一样的,但是在这里我们可以理解为这个 HttpClient “继承“ 了父级的拦截器。