为什么要用 Http 模块
在前端开发中,使用 Http 协议进行数据传输是必不可少的。比如获取资源,请求 Restful Api 等。
浏览器提供了 XMLHttpRequest 对象以及 fetch 接口,便于我们向服务器异步请求数据。诸如 JQuery,Axios 等库也帮我们封装了 基于 XMLHttpRequest 对象的请求接口,便于我们使用。
认识 Http 模块
Angular 为我们集成了自己实现的 Http 请求模块 HttpClientModule,位于 @angular/common/http 包下。它提供了如下几个常用的定义
- HttpClientModule
- HttpClient
- HttpInspector
HttpClient
HttpClient 为我们提供了如下几个发起请求的方法
标准的 Request 请求,签名如下
// 发起一个Reuqest请求request<T>(req:HttpRequest<T>): Observable<HttpEvent<T>>
常用的快捷的请求方式,签名如下
// 这里我们定义一个HttpRequestOptions实际没有这个对象interface HttpRequestOptions {// 自定义的 HttpHeadersheaders?: HttpHeaders|{[header: string]: string | string[]},observe?: 'body',//请求参数params?: HttpParams|{[param: string]: string | string[]},// 是否通进度条 仅request请求时有效reportProgress?: boolean,// 响应类型,决定如何反序列化最终的bodyresponseType: 'arraybuffer'|"blob"|"text"|"json",// 跨域请求时是否传递cookiewithCredentials?: boolean,}// 发起一个Get请求 并直接拿到 HttpResponse 的 bodyget<T>(url:string,options?:HttpRequestOptions):Observable<T>// 发起一个Post请求 并直接拿到 HttpResponse 的 bodypost<T>(url:string,body:any,options?:HttpRequestOptions):Observable<T>// 发起一个 Put 请求 并直接拿到 HttpResponse 的 bodyput<T>(url:string,body:any,options?:HttpRequestOptions):Observable<T>// 发起一个 delete 请求 并直接拿到 HttpResponse 的 bodydelete<T>(url:string,options?:HttpRequestOptions):Observable<T>... 后边还有 options patch jsonp等
HttpRequest
HttpRequest 这个对象封装了我们请求需要的重要信息,其签名如下
export class HttpReuqeust{// Bodyreadonly body: T|null = null;// Headersreadonly headers!: HttpHeaders;// 是否报告进度 这个通常用于上传&下载readonly reportProgress: boolean = false;// 存在跨域请求时是否传递cookie信息readonly withCredentials: boolean = false;// 响应类型readonly responseType: 'arraybuffer'|'blob'|'json'|'text' = 'json';// 请求类型 get/post/put/delete这些readonly method: string;// url参数readonly params!: HttpParams;// 带参数的url 对应构造函数中的urlreadonly urlWithParams: string;}
这里我们可以发现 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 是一个注入的服务,在根模块注入后,后续的所有模块都可以通过依赖注入树找到它。
// app.module.tsimport {HttpClientModule} from "@angular/common/http"@NgModule(...imports:[...HttpClientModule])export class AppModule{}
在导入 HttpClientModule 后,我们的 组件/服务中就可以直接注入 HttpClient 了
export interface User{name:string;}@Component({...})export class AppComponent{constructor(private readonly http:HttpClient){}// 用户数据user:User[]=[];ngOnInit(): void {this.getUsers();}// 获取所有用户getUsers(){const url = `/api/users`;this.users = this.http.get<User[]>(url).subscribe(users=>this.users = users);}addUser(){const url = `/api/users`;const vo:User = { name:"New User"}this.http.post(url,vo).subscribe();}}
这里我们用了一个简单的例子来说明了一下 HttpClient 的使用方式,在这里大家应该看到了我所有的请求 最后都 Subscribe 了。这是因为 rxjs 具有延迟执行的特性,即 Observable 在未被 Subscribe 之前是不会调用 XMLHttpRequest 去请求服务器的。
常见问题
Q: “我的请求没有发出去,在 network 里没有看到请求”
A: 这个要么是你压根没调用,要么就是刚刚上边说的问题,你没 subscribe
Q: Network 里看到响应了 HttpStatus 200 但是数据没回来
A: 首先看看有没有出错信息,如果有 分为如下两种情况
- 出错信息中有提示 Access-Control-Allow-Origin 表示本次是个跨域请求,解决办法有两个,一个是服务端提供 CORS 相关 Header 的支持,另一个如果是纯粹的 get 请求,在服务器支持的情况下可以使用 jsonp 代替 get。
- 其他出错信息,有可能是服务器响应的 content-type 并不是 json。此时需要在请求时将 responseType 设置为对应的类型 ,这里支持 arrayBuffer / blob / text /json,在处理 Response 信息的时候,HttpClient 会根据期望的类型进行不同的反序列化操作
如果没有,看看有没有其他影响吧,比如 拦截器,又或者逻辑问题。
Q: 我们要求请求头里带 Token 怎么办?
A: 方法一,在请求的 options 的 headers 里 添加 token 信息
this.http.get(url,{headers:{"X-AUTH-TOKEN":token}})
方法二,写一个拦截器,统一处理所有的请求
文件的上传与下载
文件上传与文件下载,在我们的页面中也是很常见的一个操作。
不考虑进度的上传&下载
在不考虑进度的情况下,上传与下载文件比较简单,直接使用 get,post 这些快捷接口就可以实现
// 上传 这里与服务端约定的key是file method为postupload(file:File){const url =`/api/files`// 包装为FormData对象const formData= new FormData();formData.append("file",file,file.name);// 直接post就完了 body为FormData 在请求时content-type会被设置成 multipart/form-datathis.http.post(url,formData).subscribe(()=>{// Todo});}// 下载download(){const url =`/api/files?name=demo.txt`;//直接发起请求, 并设置responseType为 blob 即二进制,此时不会对body进行任何的反序列化操作this.http.get(url,{responseType:"blob"}).subscribe((data)=>{// 用a标签处理后续const blob = new Blob([data], { type: "application/octet-stream" });const objectUrl = URL.createObjectURL(blob);const a = document.createElement('a');a.setAttribute('style', 'display:none');a.setAttribute('href', objectUrl);a.setAttribute('download', "download-file");a.click();URL.revokeObjectURL(objectUrl);})}
带进度的上传&下载
之前我们说过 Observable 对象的优势,就是在 complete 或者 error 之前,可以一直传递数据,因此 HttpClient 也利用了这个特性。
在之前我们看到它提供的标准请求接口是 request,这个接口返回的是 HttpEvent,我们先来看下 HttpEventType 的签名
enum HttpEventType {// 当前请求已经发出Sent,// 收到上传进度事件UploadProgress,// 收到了响应状态码与响应状态头ResponseHeader,// 收到了下载进度事件DownloadProgress,// 包括响应体在内的完整响应对象Response,// 来自拦截器或者后端的自定义事件User}
这些 Type 又提供了对应的 HttpEvent 实现 ,这里我们关注下 HttpUploadProgressEvent 和 HttpDownloadProgressEvent 的签名
// HttpUploadProgressEventinterface HttpUploadProgressEvent extends HttpProgressEvent {type: HttpEventType.UploadProgress// 继承自 common/http/HttpProgressEventtype: HttpEventType.DownloadProgress | HttpEventType.UploadProgress// 已经上传的字节数loaded: number// 要上传的总字节数total?: number}// HttpDownloadProgressEventinterface HttpDownloadProgressEvent extends HttpProgressEvent {type: HttpEventType.DownloadProgresspartialText?: string// 继承自 common/http/HttpProgressEventtype: HttpEventType.DownloadProgress | HttpEventType.UploadProgress// 已经下载的字节数loaded: number// 要下载的字节数total?: number}
在这里我们可以看到,这两个 event 我们可以拿到总字节数与已完成的字节数,此时我们的进度条就出来了。
那怎么样算是完成了呢?很简单,ResponseEvent 发生的时候,下面我们来做个完整的例子
@Component({...})export class AppComponent{constructor(private readonly http:HttpClient){}isUploading:boolean = false;uploadPercentage:number = 0;isDownloading:boolean = true;downloadPercentage:number = 0;// 上传文件uploadFile(){const url = `/api/files`;// 这里我们就不写具体代码了const file = ...;// 构建FormDataconst formData = new FormData();formData.append("file",file,file.name);// 构建HttpRequestconst request = new HttpRequest("post",url,formData);this.isUploading = true;this.http.request(request).pipe(finialize(()=>{this.isUploading = false;this.uploadPercentage = 0;})).subscribe(e=>{if(e.type === HttpEventType.UploadProgress){this.uploadPercentage = e.loaded / e.total ;}else if (e.type ===HttpEventType.Response){// 上传完毕 Todo}})}// 下载文件download(){const url =`/api/files?name=demo.txt`;// 构建 HttpRequestconst request = new HttpRequest("get",url,{responseType:"blob"});this.isDownloading = true;//直接发起请求, 并设置 responseType 为 blob 即二进制,此时不会对 body 进行任何的反序列化操作this.http.request(request).pipe(finialize(()=>{this.isDownloading = false;this.downloadPercentage = 0;})).subscribe((e)=>{if(e.type===HttpEventType.DownloadProgress){this.downloadPercentage = e.loaded / e.total ;}else if(e.type === HttpEventType.Response){// 用 a 标签处理后续const blob = new Blob([data], { type: "application/octet-stream" });const objectUrl = URL.createObjectURL(blob);const a = document.createElement('a');a.setAttribute('style', 'display:none');a.setAttribute('href', objectUrl);a.setAttribute('download', "download-file");a.click();URL.revokeObjectURL(objectUrl);}})}}
拦截器
HttpCilentModule 为我们提供了一个叫拦截器(Interceptor)概念,用来处理请求过程中的数据,拦截器的签名如下
interface HttpInterceptor {intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>>}
如果我们想要实现自己的拦截器,只需要实现这个接口 并标记为可被注入,下面我们来实现一个 TokenHttpInterceptor 用来自动帮们处理 Token
@Injectable()export class TokenHttpInterceptor implements HttpInterceptor{private readonly TOKEN_STORAGE_KEY = "appToken";// 实现接口的方法intercept(req:HttpRequest<any>,next:HttpHandler):Observable<HttpEvent<any>>>{// 这里我们从localstorage拿到我们的Tokenconst cachedToken = localstorage.getItem(this.TOKEN_STORAGE_KEY);// 由于之前说过 HttpRequest的所有属性都是只读的,所以我们不能直接更改,因此我们需要clone一个修改后的副本const request = req.clone({ headers:req.headers.append("token",cachedToken)});// 把这个request对象,交给下一个HttpHandler处理,可能是下一个拦截器,也可能是XhrBackendreturn next.handle(req).pipe(tap(e=>{// 这里拿到的是HttpEvent,这里我们需要的类型只有Responseif(e.type === HttpEventType.Response){//从body中拿到token 这里我们与服务端的约定是{token:""}const {token} = e.body;if(token){// 保存到localStorage里localStorage.setItem(this.TOKEN_STORAGE_KEY,token);}}))}}
我们实现了接口,但是这个拦截器现在还没有生效,这是因为我们没有把它注入到我们的依赖树中
@NgModule({...imports:[...HttpClientModule,],providers:[{provide:HTTP_INTERCEPTORS,multi:true,useClass:TokenHttpInterceptor}]})export class AppModule{}
现在我们每次的 Http 请求 都会检查一遍 Token,在请求时添加到 HttpHeaders 中,在响应的时候如果发现了新的 Token,则更新我们的 localStorage。
拦截器的执行顺序
HTTP_INTERCEPTORS 这个 provider,是 multi 的,这表示我们的拦截器可以存在多个,这里就有一个顺序问题。假设我们依序注入了 3 个拦截器 A,B,C。
@NgModule({...imports:[...HttpClientModule,],providers:[{provide:HTTP_INTERCEPTORS,multi:true,useClass:AHttpInterceptor},{provide:HTTP_INTERCEPTORS,multi:true,useClass:BHttpInterceptor},{provide:HTTP_INTERCEPTORS,multi:true,useClass:CHttpInterceptor},]})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 时,会存在如下几种情况
- 啥都没干
- 直接提供了 HTTP_INTERCEPTORS 的 Provider 没有引入 HttpClientModule
- 提供了 HTTP_INTERCEPTORS 的 Provider 同时重新引入了 HttpClientModule
- 重新引入了 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 “继承“ 了父级的拦截器。
