昨天我們提到,Resource API 本身是一套通用的非同步資料模型,比較適合用在「讀取資料」這類情境,而不是新增、修改或刪除資料這種 Mutation 操作。
另外,Resource API 也不是專門為 HTTP Request 設計的。它比較在意的是「這筆非同步資料怎麼取得」,以及「什麼條件改變時需要重新取得資料」。
如果處理的剛好是 HTTP Request,又希望保留 Resource 的響應式資料管理方式,就可以使用 Angular 提供的 httpResource()。
httpResource() 建立在 HttpClient 之上,把 HTTP Request 帶進 Resource 的模型裡,因此除了能使用 Angular 原本的 HTTP 機制取得資料,也可以直接使用 Resource 提供的載入狀態、錯誤處理、重新載入,以及依賴 Signal 自動重新取得資料等功能。
我們沿用跟 Day 5 一樣的結構,這次改成 httpResource() 的方式來看看。
httpResource() 建立在 HttpClient 之上,所以平常 HttpClient 可以設定的內容,在這裡基本上也都可以使用,像是 Header、Query Params 或其他 Request Options。
和前面的 resource() 相比,httpResource() 的寫法會簡化不少。原本我們需要分別處理 params、loader,如果希望底層的 Request 能真的被取消,還要把 abortSignal 繼續傳下去;改用 httpResource() 後,這些 HTTP Request 相關的流程都已經整合完成,我們只需要透過一個 callback function 描述「這筆資料要怎麼取得」。
Component 的部分可以維持原本的結構:
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { UserService } from './user.service';
@Component({
selector: 'app-root',
imports: [FormsModule],
template: `
<input
type="number"
[(ngModel)]="userId"
/>
@if (userResource.isLoading()) {
<p>載入中...</p>
}
@if (userResource.error()) {
<p>取得使用者資料失敗</p>
}
@if (userResource.hasValue()) {
<p>
姓名:
{{ userResource.value().firstName }}
{{ userResource.value().lastName }}
</p>
<p>Email:{{ userResource.value().email }}</p>
}
`,
})
export class AppComponent {
private userService = inject(UserService);
userId = signal(1);
userResource = this.userService.getUser(this.userId);
}
Service 則改成直接回傳 httpResource():
import { Service, Signal } from '@angular/core';
import { httpResource } from '@angular/common/http';
export interface User {
id: number;
firstName: string;
lastName: string;
email: string;
}
@Service()
export class UserService {
getUser(userId: Signal<number>) {
return httpResource<User>(
() => `https://dummyjson.com/users/${userId()}?delay=2000`
);
}
}
我們傳進 Service 的是 userId 這個 Signal 本身,而不是先把值讀出來。
因此 httpResource() 的 callback function 裡仍然可以透過 userId() 讀取它,並建立對這個 Signal 的響應式依賴。當 userId 改變時,httpResource() 就會重新執行 callback function,並根據新的值取得資料。
相較於前面的 resource(),httpResource() 也直接處理了幾個 HTTP Request 常見的情境:
userId 改變時,會根據新的值重新取得資料。AbortController 或 abortSignal。HttpClient 回傳的 Observable 要被訂閱之後才會真正發出 Request;httpResource() 建立後就會開始載入資料,不需要另外呼叫 subscribe()。HttpClient 的 Request 設定:除了直接回傳 URL,也可以設定 Query Params、Header 或其他 Request Options。例如 Query Params 可以改成:
@Service()
export class UserService {
getUser(userId: Signal<number>) {
return httpResource<User>(() => ({
url: `https://dummyjson.com/users/${userId()}`,
method: 'GET',
params: {
delay: 2000,
},
}));
}
}
所以 httpResource() 並不是另一套 HTTP Client,而是把 HTTP Request 放進 Resource 的資料模型裡,讓 Request 和對應的資料狀態可以放在同一套流程中管理。
httpResource() 提供了一些可以直接使用的狀態,Template 只需要根據目前的狀態決定要顯示什麼內容。
比較常用的有:
hasValue():目前是否有可以使用的資料,通常會先確認有值,再讀取 value()。value():取得目前 Resource 裡的資料。isLoading():目前是否正在載入資料。error():取得載入過程中發生的錯誤。status():取得目前 Resource 所處的狀態。reload():使用目前的條件重新載入資料。因為 httpResource() 處理的是 HTTP Request,所以也多了一些 HTTP Response 相關的資訊:
statusCode():取得 HTTP Status Code。headers():取得 Response Headers。progress():取得 Request 的進度資訊,需要在 Request 中設定 reportProgress: true。這裡特別要注意 hasValue()。
如果 Resource 處於 Error 狀態,直接讀取 value() 可能會在 Runtime 拋出錯誤,因此通常會像前面的 Template 一樣,先透過 hasValue() 確認目前有可用資料,再去讀取 value()。
這樣就不需要自己另外建立 loading、error、data 這些狀態,而是直接使用 Resource 提供的狀態即可。
看到前面 httpResource() 搭配 Service 的寫法時,我第一個想到的是 Vue 的 Composable。
Composable 很常用來把一組響應式狀態和相關邏輯封裝起來,Component 只需要提供需要的條件,就可以直接使用處理好的資料與狀態。
Vue Composable 和 Angular Service 搭配 httpResource() 的定位並不完全相同,不過從「把響應式資料和取得資料的邏輯封裝起來」這個角度來看,確實有一些相似的地方。
Vue 官方在介紹 Composable 時,也有示範過把非同步資料請求封裝起來的方式。
沿用前面的使用者範例,我們也可以寫成一個 useGetUser()。
App.vue
<script setup lang="ts">
import { ref } from 'vue';
import { useGetUser } from './composables/useGetUser';
const userId = ref(1);
const {
data: user,
error,
isLoading,
} = useGetUser(userId);
</script>
<template>
<input
v-model.number="userId"
type="number"
/>
<p v-if="isLoading">
載入中...
</p>
<p v-else-if="error">
取得使用者資料失敗
</p>
<template v-else-if="user">
<p>
姓名:
{{ user.firstName }}
{{ user.lastName }}
</p>
<p>
Email:{{ user.email }}
</p>
</template>
</template>
useGetUser.ts
import {
ref,
toValue,
watchEffect,
onWatcherCleanup,
type MaybeRefOrGetter,
} from 'vue';
export interface User {
id: number;
firstName: string;
lastName: string;
email: string;
}
export function useGetUser(userId: MaybeRefOrGetter<number>) {
const data = ref<User | null>(null);
const error = ref<unknown>(null);
const isLoading = ref(false);
watchEffect(async () => {
const id = toValue(userId);
data.value = null;
error.value = null;
isLoading.value = true;
const controller = new AbortController();
let active = true;
onWatcherCleanup(() => {
active = false;
controller.abort();
});
try {
const response = await fetch(
`https://dummyjson.com/users/${id}?delay=2000`,
{
signal: controller.signal,
}
);
if (!response.ok) {
throw new Error('取得使用者資料失敗');
}
const result = await response.json();
if (active) {
data.value = result;
}
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
return;
}
if (active) {
error.value = e;
}
} finally {
if (active) {
isLoading.value = false;
}
}
});
return {
data,
error,
isLoading,
};
}
這部分可以參考 StackBlitz 範例
useGetUser() 把 data、error、isLoading 與 Request 流程封裝在一起,Component 只需要提供 userId,再使用 Composable 回傳的響應式狀態。
從這個角度來看,它和前面的 Angular 範例有些相似,兩邊都把資料請求與相關狀態從 Component 抽離,差別主要在於 Request 被重新觸發時的處理方式:
httpResource() 已經內建 Request cancellation,依賴改變時會處理前一筆尚未完成的 Request。onWatcherCleanup() 搭配 AbortController,在下一次 watchEffect() 執行前取消上一筆 Request。
Vue 這裡是透過 Composable 自己把資料、狀態和 Request 流程組合起來;httpResource() 則已經把這些行為整合進 Resource 裡。
前面的 Vue 範例還有一個滿有趣的地方,就是 toValue()。
有了 toValue() 之後,Composable 不一定只能接收響應式來源,就算傳進來的是一般純值,也可以正常取得資料。
例如:
useGetUser(1);
或者傳入 Ref:
const userId = ref(1);
useGetUser(userId);
兩種方式都可以透過 toValue() 取得真正的值。
Angular 本身沒有完全對應 Vue toValue() 的 API,不過如果這裡只希望同時支援「一般值」和 Signal,其實可以自己做一個簡單版本。
先定義一個 MaybeSignal,讓參數可以接受一般值或 Signal:
type MaybeSignal<T> = T | Signal<T>;
再透過 isSignal() 判斷傳進來的是不是 Signal:
function toValue<T>(
value: MaybeSignal<T>
): T {
return isSignal(value)
? (value as Signal<T>)()
: value;
}
接著原本的 getUser() 就可以改成:
import {
isSignal,
Service,
Signal,
} from '@angular/core';
import { httpResource } from '@angular/common/http';
type MaybeSignal<T> = T | Signal<T>;
function toValue<T>(
value: MaybeSignal<T>
): T {
return isSignal(value)
? (value as Signal<T>)()
: value;
}
export interface User {
id: number;
firstName: string;
lastName: string;
email: string;
}
@Service()
export class UserService {
getUser(
userId: MaybeSignal<number>
) {
return httpResource<User>(
() =>
`https://dummyjson.com/users/${toValue(userId)}?delay=2000`
);
}
}
這樣 getUser() 就可以同時接受一般純值:
this.userService.getUser(1);
也可以接受 Signal:
userId = signal(1);
this.userService.getUser(this.userId);
兩種方式的差別主要在於有沒有建立響應式依賴:
Signal:toValue() 會在 httpResource() 的 callback function 裡讀取 Signal,因此這個 Signal 會成為目前 Resource 的依賴,值改變後就會重新取得資料。所以這裡的 toValue() 只是把「純值」和 Signal 統一成相同的取值方式。真正決定 httpResource() 之後會不會重新執行的,還是 callback function 執行時有沒有讀取到 Signal。
這部分可以參考 StackBlitz 範例
httpResource() 讓 Service 回傳的不再只是一個等待訂閱後才發出請求的 Observable,而是一份由 Resource 管理、會隨著 Signal 依賴變化而更新的資料狀態。
使用時需要注意,httpResource() 必須在 Injection Context 中建立;而且每執行一次 httpResource() 都會建立一份新的 Resource。如果希望多個地方共用同一份資料狀態,就需要讓這份 Resource 只建立一次,再提供給其他地方使用。
httpResource() 的用法、自動取消與建立後立即請求。hasValue() 的用途。toValue() 時判斷是否為 Signal。