尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Angular HttpClient GET请求实战:参数传递、响应处理与错误捕获全解析

Angular HttpClient GET请求实战:参数传递、响应处理与错误捕获全解析 讲真的写Angular已经不流行用fetch直接发请求了但很多从Vue转过来的人第一次看到HttpClient还是会把REST调用写成一坨“能用但根本扛不住”的代码。最近在做一个小后台系统的联调正好把Angular后端联动系列第二篇落下来这一篇不聊架构不聊状态管理就聊最基础也最容易出问题的一个动作——HTTP GET请求。你可以把GET拆成三件事参数怎么传、响应怎么接、出错怎么兜底。我写这篇的时候尽量把这三件事背后的原理也讲透基础薄的同学可以直接照着抄已经写过不少接口的同学也可以看看有没有踩漏的坑。先交代一下环境这篇示例默认你用的是Angular 15以上的standalone应用不过传统的NgModule方式我也会提一嘴区别不大。所有代码基于angular/common/http提供的HttpClientRxJS版本是7。好直接进入正题。1. 先把GET请求跑起来HttpClient引入方式与最小可用示例1.1 两种引入方式别踩 No provider 的坑angular和vue在发请求这件事上有一个很本质的区别Vue生态一般是你自己在项目里装axios然后随便找个文件importAngular则强依赖依赖注入体系HttpClient本身是一个服务先要把它“提供”到注入器里组件或服务才能拿到它。很多刚转Angular的人一上来就constructor(private http: HttpClient) {}然后疯狂报错NullInjectorError: No provider for HttpClient!原因就是没做引入。传统NgModule应用里你需要在AppModule或者用到它的模块里导入HttpClientModuleimport { NgModule } from angular/core; import { HttpClientModule } from angular/common/http; NgModule({ imports: [ HttpClientModule ] }) export class AppModule {}如果你用的是standalone应用配置文件里要用provideHttpClientimport { ApplicationConfig } from angular/core; import { provideHttpClient } from angular/common/http; export const appConfig: ApplicationConfig { providers: [ provideHttpClient() ] };这里要说一个我踩过的坑standalone模式下有人会在某个特性组件或服务里直接provideHttpClient以为“随用随取”很干净结果后续注册的全局拦截器压根不生效因为组件级提供的HttpClient是一个全新的实例跟根上的配置不是一套。我的建议是除非你明确要做独立子模块的请求环境隔离否则永远只在根ApplicationConfig里provideHttpClient。这是Angular面试题里经常问到的依赖注入层级问题实际项目中也是排查“拦截器为什么不工作”的第一步。1.2 最小可用请求服务类封装与组件订阅引入HttpClient之后最标准的做法是在service层封装请求而不是在组件里直接new HttpClient。依赖注入的典型写法如下import { Injectable } from angular/core; import { HttpClient } from angular/common/http; export interface User { id: number; name: string; email: string; } Injectable({ providedIn: root }) export class UserApiService { constructor(private http: HttpClient) {} getUsers() { return this.http.getUser[](/api/users); } }组件里如果要展示一个用户列表有两种常见消费方式。第一种是手动订阅并赋值给普通数组users: User[] []; ngOnInit() { this.userApiService.getUsers().subscribe(res { this.users res; }); }第二种是直接把Observable暴露给模板用AsyncPipe来订阅users$ this.userApiService.getUsers();ul li *ngForlet user of users$ | async{{ user.name }}/li /ul我推荐优先用AsyncPipe方式。HttpClient返回的是一个Observable它默认是“冷”的——只有订阅发生请求才会真正发出。AsyncPipe不仅会在组件初始化时订阅还会在组件销毁时自动取消订阅不用你手动去管清理逻辑。而手动subscribe的写法一旦忘记处理销毁日后十有八九会产生“组件都销毁了还报错”的玄学问题。1.3 不写泛型会怎样有人写this.http.get(/api/users)不传泛型也能跑但这时候返回类型在Angular编译期是Object实际在运行时它只是把HTTP响应体原样交给订阅者不做任何结构转换。写泛型最大的作用是让TypeScript在编译期帮你检查字段而不是真的帮你“解析”出对象。这一点能在后文响应处理里引出很多问题先记住一句话泛型是编译期的约束不是运行时的保护。2. 参数传递HttpParams的不可变性、路径参数和空值过滤这一节是很多人翻车的重灾区。后台联调的时候前端拼Query参数的方式五花八门最常见的翻车姿势是把参数直接写死在URL字符串里等接口一变代码全是字符串拼接和if else。2.1 为什么别直接用字符串拼接先看一段反面教材getUsers(page: number, size: number) { return this.http.get(/api/users?page${page}size${size}); }短期看着够用但一旦参数来自用户输入比如搜索关键词里有中文、空格、、#这段代码就随时可能崩。你确实可以手动用encodeURIComponent包一下但每个参数都包代码会变得非常啰嗦而且很容易遗漏。Angular官方提供的HttpParams就是专门干这个的它能帮你做标准URL编码也让你从一堆字符串拼接里解放出来。有一个面试里常问的小陷阱query参数期望传数组比如?tagatagb用字符串拼接很容易拼成?taga,b之类的错误格式。用HttpParams的append方法才能产生多个同名参数。2.2 HttpParams不可变性set完不接等于白写HttpParams和它关系最近的Map不太一样它设计成不可变的。任何set、append、delete操作都不会修改原对象而是返回一个新的实例。很多人第一次用就这样写const params new HttpParams(); params.set(page, 1); params.set(size, 20);然后接口实际发出的URL里一个参数都没有因为前两行set的返回值被丢掉了。正确的写法要么用一个变量接住每次的返回值let params new HttpParams(); params params.set(page, 1); params params.append(size, 20); return this.http.get(/api/users, { params });要么用链式写法配合const因为每次set返回的都是新的对象并不会修改原对象const params new HttpParams() .set(page, 1) .append(size, 20);set和append的区别需要记住set如果参数已经存在会覆盖append则是在已有参数后面追加一个相同key的值。两者的共同点是都会返回新对象。这个不可变性我在生产环境里排查过不止一次绝大多数都是因为没接住返回值。2.3 动态参数与空值过滤实际项目里的查询条件往往是动态的用户可能只填了关键词没选状态。如果直接把所有字段丢给后端就会出现?keywordstatusnullpage1这种垃圾参数很多严格按照文档校验的后端会直接给你返回400。我通常会写一个工具方法做过滤function buildParams(filter: Recordstring, unknown): HttpParams { let params new HttpParams(); for (const key of Object.keys(filter)) { const value filter[key]; if (value null || value undefined || value ) { continue; } params params.set(key, String(value)); } return params; }这样处理完只有有意义的字段才会进URL。顺带说一句如果后端是用Spring Boot那样的框架前端的空字符串参数往往会被解析成而某些约束注解看到空字符串会直接拒绝请求这几年联调时我没少被这种问题坑。所以空值过滤不只是为了让URL好看更是在跟后端严谨校验逻辑对齐。2.4 路径参数和URL编码除了Query参数REST接口还经常把ID放进路径里比如GET /api/user/123。Angular里最常见的写法是用模板字符串getUserById(id: number) { return this.http.get(/api/user/${id}); }大多数情况下id是number这样写没毛病。但如果id是字符串类型而且可能带特殊字符我会建议显式做一次编码getUserById(id: string) { return this.http.get(/api/user/${encodeURIComponent(id)}); }别小看这个encodeURIComponent一些混合主键、带斜杠的业务编号如果不编码URL路径会被解析成多级后端根本匹配不到路由。另外如果你是从路由参数里拿id要留意ActivatedRoute的paramMap返回的值永远是字符串ngOnInit() { const id this.route.snapshot.paramMap.get(id) ?? ; this.userApiService.getUserById(id).subscribe(...); }字符串“123”和数字123在路径里最终效果一致但如果后端接口签名里接收的是数字类型转换成数字再传会更稳妥否则在一些严格校验下也会出问题。3. 响应处理从泛型到完整响应体参数发出去了后端返回了一大堆JSON接下来是怎么把Response变成前端能用的数据。这里我打算从三个角度展开泛型约定、完整响应对象、响应格式。3.1 泛型只是约定小心后端包装结构很多初学者容易把“写了泛型”等同于“结构正确”。比如我见过有人这么写this.http.getUser[](/api/users).subscribe(res { console.log(res[0].name); // undefined });后端实际返回的JSON长这样{ code: 0, message: ok, data: [ {id: 1, name: 张三}, {id: 2, name: 李四} ] }这里有个典型的误解HttpClient并不会根据泛型去“挑出”data字段它只会把整个JSON结构原封不动地交给订阅者。你的泛型就算写成User[]res也不是数组而是一个包含code、message、data的对象。正确做法是先用一个包装结构去对应真实响应interface ApiResponseT { code: number; message: string; data: T; } interface User { id: number; name: string; } getUsers() { return this.http.getApiResponseUser[](/api/users).pipe( map(res res.data) ); }这样上层组件拿到的是干净的User[]不用在每个业务组件里都去判断code。我在项目里习惯再封装一个request方法把ApiResponse的判断集中在一个地方然后所有API service都返回解包后的业务数据。这一层如果做好了组件和REST响应结构之间就是解耦的后端即使换个包装字段也只改一个公共方法。3.2 observe: response需要响应头和状态码时再用大多数GET请求只用body就够了但有些场景必须看完整响应。比如分页接口的总条数放在X-Total-Count响应头里再比如你想校验实际返回的statusText。这时可以用observe参数getUsersWithPagination() { return this.http.getUser[](/api/users, { observe: response }).subscribe(res { console.log(res.status); // 200 const total res.headers.get(X-Total-Count); console.log(total); console.log(res.body); // 真正的User数组 }); }observe有三种取值区别如下observe值订阅者拿到的类型典型场景bodyT直接取业务数据默认值responseHttpResponse需要状态码、响应头、完整bodyeventsHttpEvent下载进度、流式事件第一次用observe: response的同学容易犯一个错模板里还在用async结果ngIn循环一个HttpResponse对象页面上白屏。这时候你需要知道拿到的是响应对象不是body模板里要写users$ | async但如果你把users$赋值为整个ObservableHttpResponseUser[]模板中还得再取res.body别偷懒。3.3 observe: events下载进度其实没那么玄当你要下载一个比较大的文件想给用户显示进度条可以这样this.http.get(/api/export, { observe: events, reportProgress: true }).subscribe(event { if (event.type HttpEventType.DownloadProgress) { const percent Math.round(100 * event.loaded / (event.total ?? 1)); console.log(下载进度${percent}%); } else if (event.type HttpEventType.Response) { // 下载完成 } });核心是reportProgress: true它让HttpClient在传输过程中派发事件。不过说实话大部分后台管理系统的文件下载体量都不大进度条反而会增加前端复杂度而且XHR的进度事件在不同浏览器下颗粒度差异很大。如果你没有强需求我建议先用observe: body responseType: blob把文件拿回来用户感知差别不大。3.4 responseTypeJSON、文本、Blob和ArrayBuffer默认情况下HttpClient认为后端返回的是JSONresponseType是json。但你可能会遇到纯文本接口、文件下载、二进制流这时候要显式指定responseType// 下载文件 downloadTemplate() { return this.http.get(/api/template.xlsx, { responseType: blob }).subscribe(blob { const a document.createElement(a); const url URL.createObjectURL(blob); a.href url; a.download template.xlsx; a.click(); URL.revokeObjectURL(url); }); } // 纯文本接口 getRawText() { return this.http.get(/api/health, { responseType: text }).subscribe(text { console.log(text); }); }responseType的选择和最终拿到的类型是一一对应的responseType运行时类型常见场景json根据响应体解析的对象REST接口textstring健康检查、纯文本反馈blobBlob文件下载、上传回显arraybufferArrayBuffer音频、二进制协议数据还有一个容易被忽略的细节responseType一旦指定为非json再写get 编译期类型其实是冲突的因为运行时你拿到的就是一个字符串或Blob。我自己一般在这种场景不写泛型直接交给后续代码处理避免误导后来的人。4. 错误捕获从单接口catchError到全局拦截器请求和响应都通了接下来是Angular HTTP GET里最考验工程能力的一部分错误。一个接口报错前端用户看到的界面不能白屏要给出友好提示401的时候要自动跳登录网络断掉的时候要有“网络异常”而不是“Cannot read properties of undefined”。4.1 HttpErrorResponse里到底有什么当请求以非2xx状态返回或者网络层直接失败订阅中传入error回调的那个参数通常是一个HttpErrorResponse。用console.log把它打出来常见字段包括statusHTTP状态码网络层失败时为0statusText状态文本如Not Foundmessage字符串形式的错误描述error后端返回的错误体可能是对象也可能是字符串url实际请求的URL排错利器.catchError((err: HttpErrorResponse) { console.log(url:, err.url); console.log(status:, err.status); console.log(后端错误体:, err.error); return throwError(() err); })status等于0是最特殊的一类它表示HTTP请求根本没有完整往返。最常见的三种原因断网、CORS失败、请求被浏览器直接拦截。遇到status0先别急着看后端日志先看浏览器Console有没有CORS报错再看Network面板请求是否出现在blocked列表里。status在400-499之间的属于“请求本身或权限有问题”400多半是参数不对401是没登录或token过期403是权限不足404是URL拼错或资源不存在。status在500以上就是后端异常与其在前端猜不如让后端看日志。4.2 单个接口的catchError怎么写最常见的兜底是给service里的请求加上catchError把错误信息转成一个前端能看懂的提示然后再抛给组件继续处理getUserById(id: number) { return this.http.getApiResponseUser(/api/user/${id}).pipe( map(res res.data), catchError((err: HttpErrorResponse) { if (err.status 404) { return throwError(() new Error(用户不存在)); } if (err.status 0) { return throwError(() new Error(网络异常请检查网络连接)); } return throwError(() new Error(服务器开小差了请稍后重试)); }) ); }这里有两个细节值得说。第一RxJS 7之后throwError推荐写成throwError(() new Error(...))传一个工厂函数而不是直接传Error对象这样可以避免在Observable未订阅时就把Error创建出来。第二catchError必须返回一个Observable你可以用throwError继续向上抛也可以用of返回一个降级值。比如超时降级返回空列表在很多场景下比弹错更优雅。4.3 重试策略GET请求可以重但要讲究节奏GET请求设计上应该是幂等的所以网络抖动时重试是安全的。最简单的写法是getUsers() { return this.http.getUser[](/api/users).pipe( retry(2), catchError(...) ); }retry(2)意思是请求失败后最多再重试两次。直接retry的问题在于它马上重试连缓一口气的机会都没有网络抖一下也是抖。比较合理的做法是加延迟RxJS 7的retry操作符支持配置delay回调import { retry, delay, of, throwError } from rxjs; getUsers() { return this.http.getUser[](/api/users).pipe( retry({ count: 2, delay: (error: HttpErrorResponse) { if (error.status 0 || error.status 500) { return of(error).pipe(delay(1000)); } return throwError(() error); } }), catchError(err { console.log(重试后仍失败, err); return throwError(() err); }) ); }这个写法的核心逻辑是只有网络层失败或服务端5xx才值得重试如果后端明确返回400、401这种客户端错误重试一万次也是浪费时间。关于重试的注意事项GET可以重试但POST、PUT这种写操作一定别无条件重试否则可能产生重复订单之类的严重事故。市面上不少“Angular面试题”里会问retry和retryWhen有什么区别现在retryWhen已经标记为deprecated新项目直接用retry的配置对象就行。4.4 全局拦截器把错误处理从业务代码里搬出去如果每个service里都写一遍catchError项目一大会变得非常啰嗦而且容易各写各的。更优雅的做法是用HttpInterceptor做一个全局错误拦截。Angular 15之后的standalone版本推荐函数式拦截器import { HttpErrorResponse, HttpInterceptorFn } from angular/common/http; import { catchError, throwError } from rxjs; export const httpErrorInterceptor: HttpInterceptorFn (req, next) { return next(req).pipe( catchError((err: HttpErrorResponse) { if (err.status 401) { // 清token、跳登录 } else if (err.status 403) { // 提示无权限 } else if (err.status 0) { // 提示网络异常 } else if (err.status 500) { // 提示服务器异常 } return throwError(() err); }) ); };注册的时候用withInterceptorsimport { provideHttpClient, withInterceptors } from angular/common/http; export const appConfig: ApplicationConfig { providers: [ provideHttpClient( withInterceptors([httpErrorInterceptor]) ) ] };如果你还在用传统类式拦截器也没关系核心逻辑是一样的。这里想提醒一个容易踩坑的点拦截器先于service内的catchError执行也就是说如果拦截器里返回了of(null)这种降级值service里的catchError是收不到原始错误对象的。所以我的设计原则是全局拦截器只做兜底提示和跳转不吞错误个别接口的特殊处理比如404时展示“该用户已注销”放在service的catchError里处理。两层职责分开代码才不混乱。4.5 订阅泄漏与请求取消错误捕获还有一个容易被忽略的维度请求发出了但页面已经关了。组件销毁后如果Observable还在回调轻则控制台一堆ExpressionChangedAfterItHasBeenCheckedError重则内存泄漏因为闭包把组件实例整个占住了。手动subscribe的场景一定要做清理简单粗暴的方式是private destroy$ new Subjectvoid(); ngOnInit() { this.userApiService.getUsers().pipe( takeUntil(this.destroy$) ).subscribe(users this.users users); } ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); }如果你用的是Angular 16以上有更干净的写法import { takeUntilDestroyed } from angular/core/rxjs-interop; constructor() { this.userApiService.getUsers().pipe( takeUntilDestroyed() ).subscribe(users this.users users); }取消订阅并不会真正中断已发出的网络请求后端还是会收到并处理这次GET但前端不会再执行回调这个行为足够满足大部分场景。如果确实要在组件销毁时中断请求一般配合AbortController或者底层ajax请求的状态来控制不过GET通常不那么必要。一个更值得关注的场景是竞态问题用户快速翻页时上一次慢请求可能比下一次快请求后返回导致列表显示错误。对策是在service层用switchMap去切换请求只关心最后一次的结果this.page$ new BehaviorSubjectnumber(1); this.users$ this.page$.pipe( switchMap(page this.userApiService.getUsers(page, 20)) );5. 联调排查字段对不上、200却报错、CORS和跨域写了不少代码最后用我在真实联调中反复踩过的坑收尾。很多时候问题不在Angular代码本身而是前端请求和后端接口之间的配合出了偏差。5.1 先问后端要一个curl示例后端给完接口文档我习惯先让他们贴一个curl命令或者自己在终端里直接跑一遍curl -i http://localhost:8080/api/users?page1size20curl通了说明后端接口和参数格式没问题curl没通那问题在后端或网络层不用急着打开IDE调Angular。这个小习惯帮我筛掉了大量“看似是Angular的问题实际是后端返回格式没对齐”的深夜加班。5.2 从Network面板读具体请求前端代码出错时打开开发者工具的Network面板点中对应的XHR请求重点看三块Headers里的Request URL确认URL和Query String完全符合预期Query String Parameters确认HttpParams是否真的被附加上了Response确认后端返回的JSON结构和前端泛型期望一致很多“Angular没问题”的请求用这个方法一看就明白了要么URL里的参数被编码成了%20要么后端的响应包了两层data前端却只用了一层泛型。5.3 CORS和本地代理配置跨域问题在前后端分离项目里几乎躲不掉。现象是Network面板能看到请求发出状态也确实是200但浏览器Console报一段Access to XMLHttpRequest at http://localhost:8080/api/users from origin http://localhost:4200 has been blocked by CORS policy这种状态是“请求已经到达服务器但浏览器不允许前端读取响应”代码里根本拿不到数据整体表现类似网络错误。最简单的本地联调方案是开Angular的代理不直接跨域{ /api: { target: http://localhost:8080, changeOrigin: true, pathRewrite: { ^/api: } } }启动命令加上代理配置ng serve --proxy-config proxy.conf.json这样前端代码里请求/api/users浏览器看到的是localhost:4200同源请求开发阶段不需要后端处理CORS头。生产环境则一般由网关或Nginx转发同样能绕开跨域。5.4 状态码200却业务失败这一类最迷惑人。后端为了“不报错”把所有错误都包在HTTP 200里返回{ code: 500, message: 参数校验失败, data: null }前端看到的status是200所以HttpClient的error回调不会被触发不进catchError也不会进拦截器。但业务上确实失败了。处理这种问题要回到3.1节提到的ApiResponse包装结构在service里做一个业务码判断map((res: ApiResponseT) { if (res.code ! 0) { throw new Error(res.message); } return res.data; })用throw抛错后这个错误会走进RxJS的错误通道后续catchError和全局拦截器又能统一处理了。这也是为什么我坚持建议所有项目在service层就做一层解包和判断而不是把res直接丢给组件。5.5 快速排查清单最后放一张我在群里顺手整理的排查表现象优先检查常见解法status0网络层失败Console的CORS报错、是否断网配代理 / 后端加CORS头404URL路径是否拼错、代理pathRewrite对齐后端路由401token是否过期、请求头是否带认证刷新token并重放请求400Query参数是否有空串、类型不匹配使用buildParams过滤空值status200但数据不对后端是否包装了code/data用ApiResponse解包并判断业务码泛型字段全是undefined泛型结构与真实JSON不一致打开Network面板对照结构最后再分享一个我个人的小习惯。每次写完一个接口调用别急着写模板先在Network面板里拷出Request URL丢到终端用curl跑一遍确认后端接口本身是通的再回来调Angular这侧。这个方法帮我过滤掉了至少一半“看起来是前端问题其实是后端返回结构没对齐”的deadline危机。Angular的HttpClient在这方面其实很透明它不帮你做任何隐式转换参数的多少、结构的长相最终都反映在Network面板那一个小小的Request URL上。把这个URL和curl的输出对照清楚HTTP GET请求的联调难题基本就解决了一大半。
返回列表