本文 AI 產出,尚未審核

Vue3 – 非同步與資料請求

主題:Suspense 元件


簡介

在單頁應用程式 (SPA) 中,資料的取得往往是非同步的。傳統上,我們會在 createdmountedsetup 生命週期裡發送 AJAX 請求,然後根據資料是否已就緒顯示 Loading、錯誤或內容。這樣的寫法雖然可行,但 會讓組件的模板與狀態管理混雜在一起,降低可讀性與可維護性。

Vue 3 在 2020 年推出的 <Suspense> 元件,提供了一種「等待子樹完成非同步操作再渲染」的全新模式。它讓開發者可以把「載入中」與「錯誤」的 UI 抽離出來,讓主要的業務邏輯保持乾淨,同時也支援 Concurrent Rendering 的未來方向。

本文將從概念出發,逐步說明 <Suspense> 的使用方式、常見陷阱與最佳實踐,並提供多個實務範例,幫助你在 Vue3 專案中更優雅地處理非同步與資料請求。


核心概念

1. Suspense 的基本原理

<Suspense> 會監聽其子樹中所有 異步組件(使用 defineAsyncComponentsetup 內返回 Promise 的組件)以及 setup 中的 async 函式。當子樹內的任何一個異步操作尚未完成時,<Suspense> 會渲染其 fallback(備援)內容;所有異步完成後,fallback 會被真正的子樹取代。

關鍵點

  • <Suspense> 只關注子樹,不會捕捉全局的 Promise。
  • fallback 必須是同步渲染的內容,通常是一個 Loading Spinner。
  • 若子樹的 Promise 被 reject,<Suspense> 會觸發 error 事件,可自行顯示錯誤 UI。

2. 建立異步組件

最常見的做法是使用 defineAsyncComponent

import { defineAsyncComponent } from 'vue'

const UserProfile = defineAsyncComponent(() =>
  import('./components/UserProfile.vue')
)

這樣的組件在首次渲染時會返回一個 pending Promise<Suspense> 會自動偵測並顯示 fallback。

3. 在 setup 中返回 Promise

Vue 3 允許 setup 直接回傳一個 Promise,作為「資料載入」的信號。範例如下:

export default {
  async setup() {
    const { data } = await fetchUser()
    return { user: data }   // 只有在 Promise resolve 後才會渲染模板
  }
}

只要這個組件被包在 <Suspense> 內,await 之前的 UI 會被 fallback 取代。

4. fallback 與 error-slot

<Suspense>
  <template #default>
    <UserProfile :id="userId" />
  </template>

  <template #fallback>
    <LoadingSpinner />
  </template>

  <template #error="{ error, retry }">
    <ErrorBox :msg="error.message" @retry="retry" />
  </template>
</Suspense>
  • #default:真正的子樹(成功時顯示)。
  • #fallback:載入中 UI。
  • #error(可選):錯誤時的 UI,提供 error 物件與 retry 函式。

5. 多層 Suspense

在大型應用中,常會將 資料層UI 層 分別包在不同的 <Suspense>,形成嵌套。內層的 fallback 只在該層失敗時顯示,外層則負責整體的 loading 狀態,提升使用者體驗。


程式碼範例

以下提供五個實用範例,示範在不同情境下如何使用 <Suspense>

範例 1:最簡單的 Loading UI

<!-- App.vue -->
<template>
  <Suspense>
    <template #default>
      <AsyncHello />
    </template>

    <template #fallback>
      <p>載入中…</p>
    </template>
  </Suspense>
</template>

<script>
import { defineAsyncComponent } from 'vue'

const AsyncHello = defineAsyncComponent(() =>
  // 假設這個檔案較大,會有明顯的載入延遲
  import('./components/HelloWorld.vue')
)

export default {
  components: { AsyncHello }
}
</script>

說明AsyncHello 為異步組件,尚未載入時顯示「載入中…」的文字。

範例 2:setup 中的 async 資料請求

<!-- UserDetail.vue -->
<template>
  <div>
    <h2>{{ user.name }}</h2>
    <p>電子郵件:{{ user.email }}</p>
  </div>
</template>

<script>
export default {
  async setup() {
    // 假設 fetchUser 會回傳 Promise<User>
    const response = await fetch('/api/user/123')
    const user = await response.json()
    return { user }   // 只有在資料取得後才渲染模板
  }
}
</script>
<!-- 包在 Suspense 中 -->
<Suspense>
  <template #default>
    <UserDetail />
  </template>

  <template #fallback>
    <LoadingSpinner />
  </template>
</Suspense>

說明UserDetailsetup 中直接使用 await,Vue 會把整個組件視為異步,<Suspense> 會顯示 LoadingSpinner 直到資料取得完畢。

範例 3:錯誤處理與重試

<!-- ProductInfo.vue -->
<template>
  <div>
    <h3>{{ product.title }}</h3>
    <p>{{ product.description }}</p>
  </div>
</template>

<script>
export default {
  async setup() {
    const res = await fetch('/api/product/456')
    if (!res.ok) throw new Error('商品資料取得失敗')
    const product = await res.json()
    return { product }
  }
}
</script>
<!-- 包在 Suspense,使用 error slot -->
<Suspense>
  <template #default>
    <ProductInfo />
  </template>

  <template #fallback>
    <LoadingSpinner />
  </template>

  <template #error="{ error, retry }">
    <div class="error-box">
      <p>發生錯誤:{{ error.message }}</p>
      <button @click="retry">重新載入</button>
    </div>
  </template>
</Suspense>

說明:當 fetch 回傳非 2xx 時,setupthrow 錯誤,<Suspense> 捕捉後顯示錯誤 UI,並提供 retry 讓使用者重新嘗試。

範例 4:嵌套 Suspense(資料 + UI)

<!-- Dashboard.vue -->
<template>
  <Suspense>
    <template #default>
      <UserStats :userId="uid" />
    </template>
    <template #fallback>
      <p>載入使用者統計資料中…</p>
    </template>
  </Suspense>
</template>

<script>
import UserStats from './UserStats.vue'   // 內部仍使用 Suspense

export default {
  props: { uid: String }
}
</script>
<!-- UserStats.vue -->
<template>
  <Suspense>
    <template #default>
      <Chart :data="stats" />
    </template>

    <template #fallback>
      <Spinner />
    </template>
  </Suspense>
</template>

<script>
export default {
  async setup(props) {
    const res = await fetch(`/api/stats/${props.uid}`)
    const stats = await res.json()
    return { stats }
  }
}
</script>

說明:外層 <Suspense> 負責整體的「使用者資料」載入,內層則只關注圖表的渲染。這樣的分層讓每個子樹的 loading 時間更精確,提升使用者感知的流暢度。

範例 5:使用 defineAsyncComponent 搭配延遲與超時

import { defineAsyncComponent } from 'vue'

const LazyMap = defineAsyncComponent({
  // 1. 載入函式
  loader: () => import('./components/Map.vue'),

  // 2. 載入前的延遲(ms),避免閃爍
  delay: 200,

  // 3. 超時時間,超過則拋出錯誤
  timeout: 5000,

  // 4. 錯誤時的備援元件
  errorComponent: {
    template: '<p>地圖載入失敗,請稍後再試。</p>'
  },

  // 5. 載入中的備援元件
  loadingComponent: {
    template: '<p>地圖載入中…</p>'
  }
})
<Suspense>
  <template #default>
    <LazyMap />
  </template>

  <template #fallback>
    <p>整體頁面載入中…</p>
  </template>
</Suspense>

說明defineAsyncComponent 本身就支援 loadingComponenterrorComponentdelaytimeout 等選項,和 <Suspense> 可以互補使用,提供更細緻的控制。


常見陷阱與最佳實踐

陷阱 說明 解決方案 / 最佳實踐
Fallback 本身也含有異步操作 若 fallback 中仍使用異步組件,會導致無限迴圈或 UI 卡住。 確保 fallback 為 純同步 的 UI(如純文字、CSS spinner)。
過度包裝 把每個小組件都包在 <Suspense>,會產生過多的 loading UI,降低使用者體驗。 只在 資料邊界(如 API 請求)或 大型模組(如圖表、地圖)使用 <Suspense>
錯誤未捕捉 setup 中的 throw 若未在 <Suspense>error slot 處理,會冒泡到全局錯誤處理,導致應用崩潰。 使用 error slot 或在 defineAsyncComponent 中提供 errorComponent
SSR 與 Suspense 在 Server‑Side Rendering 時,<Suspense> 的 fallback 只會在伺服器端渲染完成後被取代,若未設置 ssr:false 可能會影響首屏渲染。 在 SSR 環境下,使用 v-if 控制渲染或在 defineAsyncComponent 中設 suspensible: false
重複請求 多個 <Suspense> 包住相同的異步組件,可能會觸發多次相同的 API 請求。 把資料請求抽離到 store (Pinia/Vuex)composable,在組件中共享同一個 Promise。

其他最佳實踐

  1. 保持 fallback 輕量:使用簡單的 CSS spinner 或文字,避免額外的網路請求。
  2. 結合 Pinia:將資料請求寫成可重用的 composable,返回同一個 Promise,讓多個 <Suspense> 能共享結果。
  3. 設定合理的 delay:避免瞬間閃爍的 loading UI,通常 200‑300ms 為佳。
  4. 提供重試機制:在 error slot 中加入 retry 按鈕或自動重試,以提升容錯性。
  5. 測試與監控:使用 Vue Devtools 觀察 Suspense 的狀態,並在 CI 中加入對異步錯誤的測試。

實際應用場景

場景 為何使用 Suspense 實作要點
商品詳情頁(需先載入商品資料、再載入評論) 商品資料與評論分別為不同 API,使用兩層 <Suspense> 可同時顯示商品資訊的 loading 與評論的 loading,提升感知速度。 - 商品資訊 <Suspense> 包住 ProductInfo
- 評論 <Suspense> 包住 CommentsList
- 兩者共享同一個 productId
儀表板 (Dashboard) 中的圖表 大型圖表(如 ECharts、Highcharts)載入成本高,使用 <Suspense> 把圖表模組延遲載入,讓其他面板先呈現。 - 圖表組件使用 defineAsyncComponent
- 父層 <Suspense> 提供統一的 loading UI
- 若圖表失敗,顯示自訂錯誤訊息。
多語系切換 語系檔案通常是 JSON,透過 setup async 取得後再渲染 UI,避免在切換語系時出現閃爍或未翻譯的文字。 - i18n composable 返回 Promise
- 包在 <Suspense>,fallback 為全域 spinner。
SSR 首屏渲染 在 Nuxt3 或 ViteSSR 中,使用 <Suspense> 可以讓伺服器先渲染靜態框架,等資料就緒再注入,提升 SEO 與首屏速度。 - 設定 suspensible: false 於不需要等待的組件
- 在 app.vue 中加入全局 <Suspense> 包住 router-view
即時聊天訊息列表 訊息列表需要先取得歷史訊息,之後再開啟 WebSocket。使用 <Suspense> 把歷史載入與 UI 分離,使用者看到 loading 時不會看到空白區塊。 - ChatHistory 使用 async setup
- <Suspense> fallback 為「載入中…」
- 歷史載入完成後立即啟動 socket.onMessage

總結

<Suspense> 是 Vue 3 為 非同步渲染 所提供的核心工具。它讓我們能:

  • 將 Loading 與錯誤 UI 與業務邏輯分離,保持組件模板乾淨。
  • 支援 async setup,直接在組件裡寫 await,不必額外寫 state flag。
  • 提供錯誤捕捉與重試機制,提升使用者容錯體驗。
  • 在大型應用中實現層次化的 loading,避免一次性顯示整頁的 Loading Spinner。

在實務開發中,建議 先辨識資料邊界(API 請求、懶載入模組),再以 <Suspense> 包裝,搭配 簡潔的 fallback錯誤 slot,即可獲得更流暢、更可維護的使用者介面。未來 Vue 仍在持續優化 Concurrent Rendering,熟悉 <Suspense> 的使用將為你在 Vue 生態系中奠定堅實的基礎。

祝開發順利,玩得開心! 🎉