Organize fetch state in vue

在前端請求資料是一場災難,但事情不必這麼複雜! feat. Vue

背景

索取並顯示資料對前端工程師來說是家常便飯的事,但隨著背後延伸的狀態越來越多,就會讓整個專案極度混亂,因此想要藉由這篇文章點出現有問題並提出一些可行的方案,紀錄尋找更高效解法的過程。

雖然文章中使用的是 Vue Composition API 不過重點不在於使用的框架而是背後的概念。像是 React 的脈絡也是類似的,如果你想從 React 的角度切入推薦這篇文章:为什么你不应该在 React 中直接使用 useEffect 从 API 获取数据🔗

Lv.1:從發送一個簡單的請求開始

目前有一個簡單的產品 API🔗,需求是把資料索取下來並且顯示在畫面上,這裡使用 JS 原生的 fetch API🔗 + Async / Await🔗 來處理非同步請求,並且透過狀態來驅動畫面。

<script setup>
import { ref } from 'vue';
const product = ref({});
async function getProduct() {
const productResponse = await fetch('https://dummyjson.com/products/1');
if (!productResponse.ok) {
console.error(productResponse);
return;
}
const productJSON = await productResponse.json();
product.value = productJSON;
}
getProduct();
</script>
<template>
<div>
<img :src="product.thumbnail" />
<h2>{{ product.title }}</h2>
<p>{{ product.description }}</p>
</div>
</template>

1️⃣ 檢討

需求達成了,但你發現這麼做並沒有辦法顯示請求出錯或是加載的狀態,用戶無法知道任何進展!因此下一版本來嘗試新增更多狀態來處理這個問題。

Lv.2:新增加載與錯誤狀態

在這個版本使用更多狀態像是 isLoadingerrorMessage 來記錄請求的狀態,並且透過這些狀態來驅動 UI 給用戶更多提示。

<script setup>
import { ref } from 'vue';
const { productUrl } = defineProps({
productUrl: {
type: String,
default: 'https://dummyjson.com/products/1',
},
});
const product = ref({});
const isLoading = ref(true);
const errorMessage = ref('');
getProduct(productUrl);
async function getProduct(productUrl) {
isLoading.value = true;
const productResponse = await fetch(productUrl);
if (!productResponse.ok) {
console.error(productResponse);
errorMessage.value = (await productResponse.json()).message;
return;
}
const productJSON = await productResponse.json();
product.value = productJSON;
isLoading.value = false;
}
</script>
<template>
<div id="app">
<div class="mx-auto max-w-fit bg-white p-4 text-black">
<div class="bg-red-300 p-4" v-if="errorMessage">{{ errorMessage }}</div>
<div v-else-if="!isLoading">
<img :src="product.thumbnail" />
<h2>{{ product.title }}</h2>
<p>{{ product.description }}</p>
</div>
<div v-else>Loading...</div>
</div>
</div>
</template>

2️⃣ 檢討

到這裡用戶使用體驗已經接近完善了,但開發體驗卻不盡人意,因為當規模擴大或需求變更時,元件終將會塞滿各式各樣的狀態,光是理解這些狀態避免切換錯誤就是一件很累人且容易出錯的事。除此之外加載過程的版面偏移🔗造成的閃爍除了影響用戶體驗之外,重新計算頁面布局也會造成性能上的問題,下一版本來嘗試製作 UI 並解決這個問題。

Lv.3:添加 UI 與加載骨架

這次版本除了給資料撰寫基本樣式之外也新增了骨架 UI,其實就是更貼近實際結果更華麗的 Loader 而已,這麼做的好處是可以解決先前遇到的版面偏移。

<template>
<div v-if="errorMessage">{{ errorMessage }}</div>
<div v-else-if="!isLoading">
<ProductCard :product="product" />
</div>
<div v-else>
<SkeletonCard />
</div>
</template>

3️⃣ 檢討

到這個步驟,用戶體驗已非常完美,但在狀態管理方面則可以考慮以下幾種方案來增進開發體驗。

Lv.3-1:透過組合式函數(Composable)包裝邏輯

反思處理這些狀態的過程,發現其實這些狀態都是為了處理資料的請求,因此可以透過包裝相關邏輯來處理這些狀態,讓元件只需要關注資料本身即可。舉例來說製作一個 useFetch 並且輸入請求 URL 並且輸出資料、錯誤訊息、請求狀態……等,不用再替每個請求開開關關狀態。

<script setup>
const { data, error } = useFetch('https://dummyjson.com/products/1');
</script>
<template>
<div v-if="error">{{ error.message }}</div>
<div v-else-if="product">
<ProductCard :product="product" />
</div>
<div v-else>
<SkeletonCard />
</div>
</template>

細節實作可以參考官方案例🔗VueUse 的實作🔗或是 Nuxt 實作🔗,在這之上可以擴充更多功能像是快取、重試、渲染模式切換……等進階功能。

Lv.3-2:使用實驗性 <Suspense> 元件

<Suspense> 實際上就是一個「Vue 的預設元件用於處理異步載入的元件」,可以在異步元件加載完成之前顯示預設內容,<Suspense>會接受兩個插槽🔗分別是 defaultfallback,它們用途也很明顯:default 用於放入異步元件,fallback 則是放入預設元件(加載告示之類的訊息)。

<template>
<!-- 預設組件不需要引入,可以直接在 Template 當中使用 -->
<Suspense>
<template #default>
<!-- 放入異步元件 -->
<AsyncProductCard />
</template>
<template #fallback>
<SkeletonCard />
</template>
</Suspense>
</template>

所謂的異步元件其實就是兩種可能:1. async setup() 或是 top level await🔗

<!-- async setup() -->
<script>
export default {
async setup() {},
};
</script>
<!-- top level await -->
<script setup>
await xxx();
</script>

所以這樣我們可以直白的製作一個異步元件 AsyncProductCard(如下),並且透過上一層的 <Suspense> 幫助我們在元件加載完成之前自動顯示預設內容。

<script setup>
import ProductCard from '../ProductCard.vue';
import { ref } from 'vue';
const product = ref({});
async function getProduct() {
const productResponse = await fetch('https://dummyjson.com/products/1');
if (!productResponse.ok) {
console.error(productResponse);
// 在請求失敗時拋出錯誤讓上一層元件處理
throw Error('索取產品失敗');
}
const productJSON = await productResponse.json();
product.value = productJSON;
}
await getProduct();
</script>
<template>
<ProductCard :product="product" />
</template>

至於錯誤處理可以使用 Vue3 的 onErrorCaptured🔗 生命週期,這個生命週期可以捕捉子元件的錯誤,並且可以在上層元件中處理錯誤,如此一來請求錯誤也可以顯示反饋給用戶了。

<script setup>
import { ref } from 'vue';
const error = ref(null);
onErrorCaptured((err) => {
error.value = err.message;
});
</script>
<template>
<div v-if="error" class="bg-red-300">Err: {{ error }}</div>
</template>

總結

結果總覽,5 種索取的範例

文章循序漸進的展示如何在 Vue 中實踐改良索取資料的用戶與開發體驗,有將過程記錄在 Vue-Fetch-Demo🔗 這個檔案之中,歡迎自由索取練習。

延伸閱讀