Vue3 – 非同步與資料請求
主題:Suspense 元件
簡介
在單頁應用程式 (SPA) 中,資料的取得往往是非同步的。傳統上,我們會在 created、mounted 或 setup 生命週期裡發送 AJAX 請求,然後根據資料是否已就緒顯示 Loading、錯誤或內容。這樣的寫法雖然可行,但 會讓組件的模板與狀態管理混雜在一起,降低可讀性與可維護性。
Vue 3 在 2020 年推出的 <Suspense> 元件,提供了一種「等待子樹完成非同步操作再渲染」的全新模式。它讓開發者可以把「載入中」與「錯誤」的 UI 抽離出來,讓主要的業務邏輯保持乾淨,同時也支援 Concurrent Rendering 的未來方向。
本文將從概念出發,逐步說明 <Suspense> 的使用方式、常見陷阱與最佳實踐,並提供多個實務範例,幫助你在 Vue3 專案中更優雅地處理非同步與資料請求。
核心概念
1. Suspense 的基本原理
<Suspense> 會監聽其子樹中所有 異步組件(使用 defineAsyncComponent 或 setup 內返回 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>
說明:
UserDetail在setup中直接使用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 時,setup會throw錯誤,<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本身就支援loadingComponent、errorComponent、delay、timeout等選項,和<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。 |
其他最佳實踐
- 保持 fallback 輕量:使用簡單的 CSS spinner 或文字,避免額外的網路請求。
- 結合 Pinia:將資料請求寫成可重用的 composable,返回同一個 Promise,讓多個
<Suspense>能共享結果。 - 設定合理的
delay:避免瞬間閃爍的 loading UI,通常 200‑300ms 為佳。 - 提供重試機制:在
errorslot 中加入retry按鈕或自動重試,以提升容錯性。 - 測試與監控:使用 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 生態系中奠定堅實的基礎。
祝開發順利,玩得開心! 🎉