在 Angular 使用 HttpClient 的各種 TypeScript 地雷與陷阱 | The Will Will Web

The Will Will Web

記載著 Will 在網路世界的學習心得與技術分享

在 Angular 使用 HttpClient 的各種 TypeScript 地雷與陷阱

AngularHttpClient 可以說相當簡單易用,不過在實務上開發時又好像不是那麼順。因為 TypeScript 的強型別特性,導致有時候程式雖然可以跑,但在 VSCode 卻會顯示錯誤。今天抽空整理了些常見的地雷與陷阱,幫助大家理解問題發生的原因,未來也可以避免類似的問題再度發生。

匯入 HttpClientModule 模組的陷阱

使用 HttpClient 的第一步,就是匯入 HttpClientModule 模組,雖然這個步驟不容易出錯,但還是要提醒一下,HttpClientModule 必須要在 BrowserModule 之後出現。雖然不會有立即的錯誤出現,但日後很有可能會出現非預期的錯誤,所以建議還是遵循官網的建議,設定在 BrowserModule 之後!

@NgModule({
  imports: [
    BrowserModule,
    // import HttpClientModule after BrowserModule.
    HttpClientModule,
  ],
  declarations: [
    AppComponent,
  ],
  bootstrap: [ AppComponent ]
})
export class AppModule {}

注入 HttpClient 到各元件中

注入 HttpClient 到各元件中,要放在建構式中,這部分通常沒有甚麼懸念,也不太會出問題。

constructor(private http: HttpClient) { }

透過 HTTP GET 取得遠端 JSON 資料

如果只是單純取得遠端的 JSON 內容,建議用泛型的方式撰寫,如下範例:

this.http.get<any>(this.url).subscribe(data => {
  this.data = data;
});

如果可以的話,建議不要用 any 型別,而是替回傳的資料建立相對應的 interface 介面。

執行的過程中,透過 subscribe 傳入的 callback 會傳入 JSON 反序列化後的資料,你不用特別透過 JSON.parse() 再次解析 HTTP 回傳的內容。

透過 HTTP GET 取得遠端 JSON 資料的完整 HTTP 回應

有時候你想取得的不僅僅是 JSON 內容而已,可能還會想得到 HTTP 狀態碼、HTTP 回應標頭之類的資訊,就要特別加入 options 參數。如下範例可以讓你在 subscribe 的 callback 取得 HttpResponse<any> 型別的物件內容:

this.http.get<any>(this.url, { observe: 'response' }).subscribe(res => {
  let response: HttpResponse<any> = res;
  let status: number = res.status;
  let statusText: string = res.statusText;
  let headers: HttpHeaders = res.headers;
  this.data = res.body;
});

但是可能有人會想這樣寫,把 options 參數獨立出來。但這樣寫 TypeScript 就會提示錯誤了:

let options = {
  observe: 'response'
};

this.http.get<any>(this.url, options).subscribe(res => {
  let response: HttpResponse<any> = res;
  let status: number = res.status;
  let statusText: string = res.statusText;
  let headers: HttpHeaders = res.headers;
  this.data = res.body;
});

錯誤訊息是:

Argument of type '{ observe: string; }' is not assignable to parameter of type '{ headers?: HttpHeaders | { [header: string]: string | string[]; }; observe?: "body"; params?: HttpParams | { [param: string]: string | string[]; }; reportProgress?: boolean; responseType?: "json"; withCredentials?: boolean; }'.
  Types of property 'observe' are incompatible.
    Type 'string' is not assignable to type '"body"'.

不過,你其實沒寫錯,只是 TypeScript 天生的強型別特性,導致了型別不一致的錯誤,雖然他們都是「字串」型態,但是型別是不一致的。

對 TypeScript 來說,這裡的 options.observe 屬性因為型別自動推導(Type Inference)的關係,被自動推斷為 string 型別。不過在 HttpClient 類別中,光是 get() 方法就有 15 種多型(polymorphism),定義了各種可以精準使用的參數型別,而這個 options 參數中可以使用 observe 並設定為 bodyeventsresponse,但因為使用多形的關係,一個多形就只會用一種型別。

以下 observe: 'body' 是其中一個多型,也是預設值:

this.http.get<any>(this.url, { observe: 'body' }).subscribe(res => {
  this.data = res;
});

以下 observe: 'response' 是另一種多型,使用時必須特別指派,也就是說此時的 observe 可以設定的值只有 'response' 而已,型別就是只有一種字串,那就是 'response'

this.http.get<any>(this.url, { observe: 'response' }).subscribe(res => {
  let response: HttpResponse<any> = res;
  this.data = res.body;
});

這個問題的解決方法有很多,不過最簡單的解決方法,就是提示 TypeScript 使用我們認為「正確」的型別,用轉型的技巧解決,同時可以兼顧可讀性與正確性。範例程式如下:

let options = {
  observe: 'response' as 'response'
};

this.http.get<any>(this.url, options).subscribe(res => {
  let response: HttpResponse<any> = res;
  this.data = res.body; // 這裡一樣會對 JSON 進行自動反序列化
});

是的,感覺很怪對吧!把字串型別的 'body' 轉型成 'body' 型別的 'body' 而已!

請注意:你必須確保後端 Web API 回傳的資料類型為 application/jsontext/json 才行,否則執行過程就算狀態碼是 200 也會被視為錯誤。

透過 HTTP GET 取得遠端 API 資料的原始內容

有時候我們會希望得到遠端 Web API 回傳的原始內容,也就是不要讓 HttpClient 自動幫我做 JSON.parse() 動作。此時你還需要在 options 參數多加上一個 responseType 屬性,並指定為 text 才行。

let options = {
  observe: 'response' as 'response',
  responseType: 'text'
}

this.http.get<any>(this.url, options)
  .subscribe((res) => {
    let response: HttpResponse<any> = res;
    this.data = res.body;
  });

但問題又來了,TypeScript 的錯誤訊息如下:

Argument of type '{ observe: "response"; responseType: string; }' is not assignable to parameter of type '{ headers?: HttpHeaders | { [header: string]: string | string[]; }; observe?: "body"; params?: HttpParams | { [param: string]: string | string[]; }; reportProgress?: boolean; responseType?: "json"; withCredentials?: boolean; }'.
  Types of property 'observe' are incompatible.
    Type '"response"' is not assignable to type '"body"'.

詭異的是,我們的上個例子,才說要用 'response' as 'response' 來解決型別錯誤的問題。現在只是多加一個 responseType 之後,程式又壞了!如果是 TypeScript 的初學者,看到這個錯誤訊息真的會投降。因為看起來是型別問題,但事實上是 TypeScript 找不到適合的多形可用,所以 VSCode 會自動偵測另一個可能的 http.get() 多形來提示。

目前所有的 15 個 http.get() 多形中,並沒有一個多形是用了 泛型 (http.get<T>()) 且設定為 observe: 'response'responseType: 'text' 的版本,這才是問題發生的主因!

所以比較正確的寫法,應該是用非泛型的版本,程式碼如下:

let options = {
  observe: 'response' as 'response',
  responseType: 'text' as 'text'
}

this.http.get(this.url, options)
  .subscribe((res) => {
    let response: HttpResponse<any> = res;
    this.data = res.body;
  });

以下寫法看似正確,但一樣是選用了不存在的多形導致

this.http.get<any>(this.url, {observe: 'response', responseType: 'text'})
  .subscribe((res) => {
    let response: HttpResponse<any> = res;
    this.data = res.body;
  });

正確的寫法應該是:

this.http.get(this.url, {observe: 'response', responseType: 'text'})
  .subscribe((res) => {
    let response: HttpResponse<any> = res;
    this.data = res.body;
  });

透過 HTTP POST 將「物件」傳送到後端 Web API

透過 http.post<T>() 發出 HTTP 要求,第二個參數必須要能傳入要放進 Request Body 的內容。預設的情況下,如果 body 是「物件型別」,HttpClient 會自動將該物件透過 JSON 序列化,並且送出 Content-Type: application/json 標頭,且預設會接受後端回應 application/json 內容類型。

let body = { a: 1 };
this.http.post<any>(this.url, body, options).subscribe(res => {
  this.data = res;
});

透過 HTTP POST 將「字串」傳送到後端 Web API

如果 http.post<T>() 的第二個參數(body)傳入的是「字串型別」原始內容,HttpClient 會不會自動將該值透過 JSON 序列化,也意味著不會額外做 JSON.stringify() 動作。但送出 HTTP 要求時,這種情況會送出 Content-Type: text/plain 標頭 (純文字),且預設會接受後端回應 application/jsontext/plain 內容類型。

以下這個就是個有問題的使用方式:

let body = 'test';
this.http.post<any>(this.url, body, options).subscribe(res => {
  this.data = res;
});

對寫 ASP.NET Web API 或 ASP.NET Core Web API 的人來說,這就是一個無效的 HTTP 要求,因為 ASP.NET Web API 預設只能接受 application/json 傳入的內容,如果前端傳來的是 text/plain 內容類型,就會立刻得到 415 Unsupported Media Type 的錯誤回應,因為 ASP.NET Web API 預設不支援 text/plain 內容類型。

此時,你可能會很直覺的把程式碼改成如下 (這是錯誤的寫法):

let headers = new HttpHeaders({
  'Content-Type': 'text/json'
});
let options = {
  headers
};

let body = 'test';
this.http.post<any>(this.url, body, options)
  .subscribe((data) => {
    this.data = data;
  });

上面這個例子,body 變數的內容是 test 字串,但 HttpClient 的 http.post<any>() 不會幫你執行 JSON.stringify() 的動作。也就是說,你在執行 HTTP 發出要求的過程中,所送出的 test 並不是一個合法的 JSON 字串,合法的字串應該要傳入 "test" 才對。

所以正確的寫法應該是:

let headers = new HttpHeaders({
  'Content-Type': 'text/json'
});
let options = {
  headers
};

let body = JSON.stringify('test');
this.http.post<any>(this.url, body, options)
  .subscribe((data) => {
    this.data = data;
  });

結語

前陣子發表過的 公司找不到資深前端工程師可以導入前端框架嗎? 文章,得到相當大的轉發與迴響,沒看過的朋友可以看看。

我們在學習 Web 技術的時候,真的太多、太雜了,根本就不太可能有人可以完全掌握每個細節。但在日常開發工作中,龐大的工作壓力與結案時程,真的很難讓所有人都能專心進修技藝。所以我才說,有時候讓專業的來,找個外部顧問幫忙定期 Code Review、討論程式架構、指導正確的觀念與寫法,其投資報酬率是相當高的!

寫 Angular 的開發人員,其實大多都沒受過什麼 TypeScript 訓練,大部分時候在寫 Code 也不會遇到什麼太大的障礙,反倒是無形之中從 TypeScript 與 VSCode 獲得許多效益,有時候清楚的錯誤提示,真的可以提早發現問題、提早解決。像本篇文章所提的種種陷阱,就是少見的難題,而且錯誤訊息不明顯、不直覺,這時就是最好的進修時機啦! 🙂

相關連結