本文 AI 產出,尚未審核

Vue 3 – 非同步與資料請求:Loading / Error Template 實作指南


簡介

在單頁應用(SPA)裡,非同步資料請求是最常見的需求。使用者在等待 API 回傳資料的過程中,如果沒有適當的提示,會產生「卡住」或「當機」的感受,進而降低使用者體驗。相對地,當請求失敗時,若未提供清楚的錯誤資訊,使用者也無法得知問題根源,甚至直接放棄操作。

因此,在 Vue 3 中建立 LoadingError 的模板(template)不只是 UI 美觀的需求,更是提升應用韌性與可用性的關鍵。本文將從概念說明、實作範例、常見陷阱與最佳實踐,帶你一步步完成一套可重用的 loading / error 處理機制,讓你的 Vue 專案在面對非同步請求時更具自信。


核心概念

1. 何謂 Loading / Error Template

  • Loading Template:在資料尚未取得前,顯示「載入中」的占位 UI(如 spinner、骨架屏等)。
  • Error Template:當請求失敗或回傳錯誤狀態碼時,顯示錯誤訊息與可能的重試按鈕。

這兩個模板通常會根據 狀態(loading、success、error)切換顯示內容。Vue 3 推薦使用 Composition API 搭配 reactiveref 來管理這些狀態,並透過 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 }
}
  • loadingerrordatareactive 狀態,外部組件只要 解構 後使用,即可直接驅動 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 元素未設定 rolearia-live,螢幕閱讀器無法感知。 如範例所示,為 LoadingSpinnerrole="status"aria-live="polite",為 ErrorMessagerole="alert"

最佳實踐小結

  1. 分離關注點:資料取得邏輯放在 composable,UI 表現放在可重用的元件。
  2. 保持 UI 狀態單一來源loadingerrordata 皆為 ref,避免在多處同時修改。
  3. 提供重試機制:使用者在網路不穩時能自行重新發送請求,提高容錯率。
  4. 加入無障礙屬性:提升所有使用者的體驗。
  5. 統一樣式:使用共用的 Loading / Error 元件,減少樣式碎片化。

實際應用場景

場景 為何需要 Loading / Error Template
列表頁(例如商品列表) 初始載入大量資料時,使用骨架屏或 spinner 減少視覺空白;API 失敗時提供「重新整理」按鈕。
表單提交 提交期間顯示 loading,若伺服器回傳驗證錯誤則顯示錯誤訊息,讓使用者即時修正。
動態路由 / 詳細頁 進入詳細頁前先取得資料,若失敗則顯示「找不到此項目」或「請稍後再試」的錯誤畫面。
儀表板(Dashboard) 多個 widget 同時發起請求,每個 widget 各自顯示 loading / error,避免整個頁面被單一失敗卡住。
SSR / SSG 在伺服器端預先取得資料,若失敗仍要在客戶端呈現 fallback UI,確保 SEO 與使用者體驗一致。

總結

Loading / Error Template 是 Vue 3 應用中不可或缺的 使用者體驗基礎。透過 Composition APIref 管理狀態、可重用元件 抽象 UI、以及 的 fallback 機制,我們可以在任意非同步請求中快速構建出 一致、易維護且具無障礙支援 的載入與錯誤處理流程。

本文提供的 useFetchLoadingSpinnerErrorMessage 三個核心範例,已足以應付大多數列表、表單與詳細頁的需求。未來若面臨更複雜的情境(如多重併發請求、全局錯誤統計),只要遵循「分離關注點」與「單一來源真相」的原則,即可在現有架構上平滑擴展。

快把這套 loading / error 實作模式套用到你的專案中,讓使用者在等待與錯誤時都能感受到貼心的回饋,從而提升整體產品的專業度與使用率吧!