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

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

### 匯入 HttpClientModule 模組的陷阱

使用 [HttpClient](https://angular.io/guide/http) 的第一步，就是匯入 `HttpClientModule` 模組，雖然這個步驟不容易出錯，但還是要提醒一下，`HttpClientModule` 必須要在 `BrowserModule` 之後出現。雖然不會有立即的錯誤出現，但日後很有可能會出現非預期的錯誤，所以建議還是遵循官網的建議，設定在 `BrowserModule` 之後！

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

### 注入 HttpClient 到各元件中

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

```typescript
constructor(private http: HttpClient) { }
```

### 透過 HTTP GET 取得遠端 JSON 資料

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

```typescript
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>` 型別的物件內容：

```typescript
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 就會提示錯誤了：

```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](https://www.typescriptlang.org/docs/handbook/type-inference.html))的關係，被自動推斷為 `string` 型別。不過在 HttpClient 類別中，光是 `get()` 方法就有 15 種多型(polymorphism)，定義了各種可以精準使用的參數型別，而這個 `options` 參數中可以使用 `observe` 並設定為 `body`、`events` 與 `response`，但因為使用多形的關係，一個多形就只會用一種型別。

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

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

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

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

這個問題的[解決方法](https://github.com/angular/angular/issues/18586)有很多，不過最簡單的解決方法，就是提示 TypeScript 使用我們認為「正確」的型別，用轉型的技巧解決，同時可以兼顧可讀性與正確性。範例程式如下：

```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/json` 或 `text/json` 才行，否則執行過程就算狀態碼是 `200` 也會被視為錯誤。

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

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

```typescript
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'` 的版本，這才是問題發生的主因！

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

```typescript
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;
  });
```

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

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

正確的寫法應該是：

```typescript
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` 內容類型。

```typescript
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/json` 或 `text/plain` 內容類型。

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

```typescript
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` 內容類型。

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

```typescript
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"` 才對。

所以正確的寫法應該是：

```typescript
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;
  });
```

### 結語

前陣子發表過的 [公司找不到資深前端工程師可以導入前端框架嗎？](/post/2019/01/09/Use-Frontend-Framework-when-no-senior-people-in-org.aspx) 文章，得到相當大的轉發與迴響，沒看過的朋友可以看看。

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

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

[![](https://stwillblogassets.blob.core.windows.net/files/images/external/stwillblogassets.blob.core.windows.net/18e42e4c4b5fe42c3c56-image_3173.png)](http://bit.ly/duotify_services)

### 相關連結

-   [Angular - HttpClient](https://angular.io/guide/http)

-   [API > @angular/common > @angular/common/http > HttpClient](https://angular.io/api/common/http/HttpClient)
-   [API > @angular/common > @angular/common/http > HttpErrorResponse](https://angular.io/api/common/http/HttpErrorResponse)

-   [Http ResponseType cannot be set · Issue #18586 · angular/angular](https://github.com/angular/angular/issues/18586)
-   [angular-httpclient-samples - StackBlitz](https://stackblitz.com/edit/angular-httpclient-samples?file=src%2Fapp%2Fapp.component.ts) (本篇文章的範例程式我都整理在這裡)
