Vue 3 – 非同步與資料請求:Loading / Error Template 實作指南
簡介
在單頁應用(SPA)裡,非同步資料請求是最常見的需求。使用者在等待 API 回傳資料的過程中,如果沒有適當的提示,會產生「卡住」或「當機」的感受,進而降低使用者體驗。相對地,當請求失敗時,若未提供清楚的錯誤資訊,使用者也無法得知問題根源,甚至直接放棄操作。
因此,在 Vue 3 中建立 Loading 與 Error 的模板(template)不只是 UI 美觀的需求,更是提升應用韌性與可用性的關鍵。本文將從概念說明、實作範例、常見陷阱與最佳實踐,帶你一步步完成一套可重用的 loading / error 處理機制,讓你的 Vue 專案在面對非同步請求時更具自信。
核心概念
1. 何謂 Loading / Error Template
- Loading Template:在資料尚未取得前,顯示「載入中」的占位 UI(如 spinner、骨架屏等)。
- Error Template:當請求失敗或回傳錯誤狀態碼時,顯示錯誤訊息與可能的重試按鈕。
這兩個模板通常會根據 狀態(loading、success、error)切換顯示內容。Vue 3 推薦使用 Composition API 搭配 reactive 或 ref 來管理這些狀態,並透過 v-if / v-else 或
2. 基本的狀態管理
import { ref } from 'vue'
export function useFetch(url) {
const data = ref(null) // 成功取得的資料
const loading = ref(false) // 載入中旗標
const error = ref(null) // 錯誤訊息
const fetchData = async () => {
loading.value = true
error.value = null
try {
const response = await fetch(url)
if (!response.ok) {
// 伺服器回傳非 2xx 狀態碼
throw new Error(`Error ${response.status}: ${response.statusText}`)
}
data.value = await response.json()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
// 初始呼叫
fetchData()
return { data, loading, error, refetch: fetchData }
}
loading、error與data為 reactive 狀態,外部組件只要 解構 後使用,即可直接驅動 UI。refetch讓錯誤發生後可以重新嘗試。
3. 使用 <Suspense> 搭配異步組件
Vue 3 的 <Suspense> 允許你在異步組件載入期間顯示 fallback(即 loading template),而在錯誤時則會觸發 errorCaptured 鉤子。以下是一個簡易的範例:
<template>
<Suspense>
<template #default>
<UserProfile :userId="userId" />
</template>
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
</template>
<script setup>
import { defineAsyncComponent } from 'vue'
const UserProfile = defineAsyncComponent(() =>
import('./UserProfile.vue')
)
const userId = 123
</script>
#fallback內放置的 LoadingSpinner 就是我們的 loading template。- 若
UserProfile內部拋出錯誤,可在UserProfile中使用errorCaptured捕捉,或在外層使用onErrorCaptured處理。
4. 建立可重用的 Loading / Error 組件
為了避免在每個頁面都重複寫相同的 markup,我們可以封裝兩個通用的 UI 元件:
(1) LoadingSpinner.vue
<template>
<div class="loading-spinner" role="status" aria-live="polite">
<svg class="spinner" viewBox="0 0 50 50">
<circle class="path" cx="25" cy="25" r="20" fill="none" stroke-width="5"/>
</svg>
<slot>載入中,請稍候…</slot>
</div>
</template>
<script setup>
// 無需額外邏輯,僅提供樣式與可自訂文字
</script>
<style scoped>
.loading-spinner {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.spinner {
animation: rotate 2s linear infinite;
width: 2rem;
height: 2rem;
}
.path {
stroke: #42b983;
stroke-linecap: round;
}
@keyframes rotate {
100% { transform: rotate(360deg); }
}
</style>
(2) ErrorMessage.vue
<template>
<div class="error-message" role="alert">
<p>❌ {{ message }}</p>
<button v-if="retry" @click="retry" class="retry-btn">
再試一次
</button>
<slot></slot>
</div>
</template>
<script setup>
defineProps({
/** 錯誤文字 */
message: { type: String, required: true },
/** 重試回呼,若未傳入則不顯示按鈕 */
retry: { type: Function, default: null }
})
</script>
<style scoped>
.error-message {
background: #ffe5e5;
color: #b00020;
padding: 1rem;
border-radius: 4px;
}
.retry-btn {
margin-top: 0.5rem;
background: #b00020;
color: #fff;
border: none;
padding: 0.4rem 0.8rem;
cursor: pointer;
}
</style>
這兩個元件可以在任何需要 loading / error 的地方直接引用,維持 UI 一致性與可維護性。
5. 完整範例:結合 useFetch 與共用 UI
<template>
<section class="post-list">
<!-- Loading -->
<LoadingSpinner v-if="loading" />
<!-- Error -->
<ErrorMessage
v-else-if="error"
:message="error"
:retry="refetch"
/>
<!-- Success -->
<ul v-else>
<li v-for="post in posts" :key="post.id">
<h3>{{ post.title }}</h3>
<p>{{ post.body }}</p>
</li>
</ul>
</section>
</template>
<script setup>
import { useFetch } from '@/composables/useFetch'
import LoadingSpinner from '@/components/LoadingSpinner.vue'
import ErrorMessage from '@/components/ErrorMessage.vue'
const { data: posts, loading, error, refetch } = useFetch(
'https://jsonplaceholder.typicode.com/posts?_limit=5'
)
</script>
<style scoped>
.post-list ul {
list-style: none;
padding: 0;
}
.post-list li {
margin-bottom: 1.5rem;
border-bottom: 1px solid #ddd;
padding-bottom: 1rem;
}
</style>
- 三段式(loading → error → success)是最常見的 UI 流程。
- 只要
useFetch的回傳值改成其他 API,這段程式碼即可直接復用。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
| 忘記重置 error | 在再次發起請求前未把 error 設為 null,導致 UI 永遠卡在錯誤畫面。 |
在 fetchData 開頭先 error.value = null。 |
| Loading 狀態競爭 | 多次快速觸發 refetch,舊的請求仍在回傳,會把 loading 設為 false 於較早的請求完成後,造成 UI 閃爍。 |
使用 AbortController 取消前一次請求,或在 fetchId 變數上做防抖。 |
| 過度依賴全域 Store | 把每個 API 的 loading / error 都放在 Vuex/Pinia 中,導致狀態混亂且難以追蹤。 | 盡量使用 局部 composable(如 useFetch)管理狀態,只有跨頁面共享的需求才放 Store。 |
| 錯誤訊息過於技術化 | 直接顯示 err.message(如 “NetworkError when attempting to fetch resource.”)會讓使用者困惑。 |
在 useFetch 中將錯誤訊息映射為友善文字,或在 ErrorMessage 中提供 自訂 slot 讓呼叫端自行說明。 |
| 缺少無障礙支援 (a11y) | Loading / Error 元素未設定 role、aria-live,螢幕閱讀器無法感知。 |
如範例所示,為 LoadingSpinner 加 role="status"、aria-live="polite",為 ErrorMessage 加 role="alert"。 |
最佳實踐小結
- 分離關注點:資料取得邏輯放在 composable,UI 表現放在可重用的元件。
- 保持 UI 狀態單一來源:
loading、error、data皆為ref,避免在多處同時修改。 - 提供重試機制:使用者在網路不穩時能自行重新發送請求,提高容錯率。
- 加入無障礙屬性:提升所有使用者的體驗。
- 統一樣式:使用共用的 Loading / Error 元件,減少樣式碎片化。
實際應用場景
| 場景 | 為何需要 Loading / Error Template |
|---|---|
| 列表頁(例如商品列表) | 初始載入大量資料時,使用骨架屏或 spinner 減少視覺空白;API 失敗時提供「重新整理」按鈕。 |
| 表單提交 | 提交期間顯示 loading,若伺服器回傳驗證錯誤則顯示錯誤訊息,讓使用者即時修正。 |
| 動態路由 / 詳細頁 | 進入詳細頁前先取得資料,若失敗則顯示「找不到此項目」或「請稍後再試」的錯誤畫面。 |
| 儀表板(Dashboard) | 多個 widget 同時發起請求,每個 widget 各自顯示 loading / error,避免整個頁面被單一失敗卡住。 |
| SSR / SSG | 在伺服器端預先取得資料,若失敗仍要在客戶端呈現 fallback UI,確保 SEO 與使用者體驗一致。 |
總結
Loading / Error Template 是 Vue 3 應用中不可或缺的 使用者體驗基礎。透過 Composition API 的 ref 管理狀態、可重用元件 抽象 UI、以及
本文提供的 useFetch、LoadingSpinner、ErrorMessage 三個核心範例,已足以應付大多數列表、表單與詳細頁的需求。未來若面臨更複雜的情境(如多重併發請求、全局錯誤統計),只要遵循「分離關注點」與「單一來源真相」的原則,即可在現有架構上平滑擴展。
快把這套 loading / error 實作模式套用到你的專案中,讓使用者在等待與錯誤時都能感受到貼心的回饋,從而提升整體產品的專業度與使用率吧!