跳至主要內容

Vue 3 Suspense 與非同步元件深度解析

Vue 3 Suspense 與非同步元件深度解析

非同步資料載入是前端開發中的家常便飯。過去我們習慣用 v-if 搭配 loading 狀態變數來處理,但 Vue 3 的 <Suspense> 元件提供了一種更優雅、更宣告式的解決方案。這篇文章將深入探討 Suspense 的運作原理與實際應用場景。

什麼是 Suspense?

<Suspense> 是 Vue 3 內建的一個特殊元件,它可以在非同步元件載入完成之前顯示備用(fallback)內容。這個概念源自 React,但 Vue 的實作方式有些不同。

基本用法

首先,建立一個使用 async setup() 的元件:

<!-- UserProfile.vue -->
<script setup>
const props = defineProps(['userId']);

// async setup - 這會讓元件成為非同步元件
const response = await fetch(`/api/users/${props.userId}`);
const user = await response.json();
</script>

<template>
  <div class="profile">
    <img :src="user.avatar" :alt="user.name" />
    <h2>{{ user.name }}</h2>
    <p>{{ user.bio }}</p>
  </div>
</template>

接著在父元件中使用 <Suspense>

<!-- App.vue -->
<template>
  <Suspense>
    <!-- 預設插槽:非同步元件 -->
    <template #default>
      <UserProfile :user-id="userId" />
    </template>

    <!-- fallback 插槽:載入中顯示的內容 -->
    <template #fallback>
      <div class="skeleton">
        <div class="skeleton-avatar"></div>
        <div class="skeleton-text"></div>
      </div>
    </template>
  </Suspense>
</template>

錯誤處理

<Suspense> 本身不處理錯誤,需要搭配 onErrorCaptured<ErrorBoundary> 元件:

<!-- ErrorBoundary.vue -->
<script setup>
import { ref, onErrorCaptured } from 'vue';

const error = ref(null);
const hasError = ref(false);

onErrorCaptured((err) => {
  error.value = err;
  hasError.value = true;
  return false; // 阻止錯誤繼續向上傳播
});
</script>

<template>
  <div v-if="hasError" class="error-boundary">
    <h3>載入失敗</h3>
    <p>{{ error?.message }}</p>
    <button @click="hasError = false">重試</button>
  </div>
  <slot v-else />
</template>
<!-- 組合使用 -->
<template>
  <ErrorBoundary>
    <Suspense>
      <template #default>
        <UserProfile :user-id="userId" />
      </template>
      <template #fallback>
        <LoadingSpinner />
      </template>
    </Suspense>
  </ErrorBoundary>
</template>

搭配 Vue Router 的路由層級 Suspense

<!-- layouts/Default.vue -->
<script setup>
import { ref } from 'vue';

const isPending = ref(false);
</script>

<template>
  <header>...</header>
  <main>
    <RouterView v-slot="{ Component }">
      <Suspense
        @pending="isPending = true"
        @resolve="isPending = false"
        @fallback="isPending = true"
      >
        <component :is="Component" />
        <template #fallback>
          <PageSkeleton />
        </template>
      </Suspense>
    </RouterView>
  </main>
</template>

搭配 Pinia 的資料預載

// stores/userStore.js
import { defineStore } from 'pinia';

export const useUserStore = defineStore('user', () => {
  const user = ref(null);
  const isLoaded = ref(false);

  async function fetchUser(id) {
    if (isLoaded.value) return;
    const response = await fetch(`/api/users/${id}`);
    user.value = await response.json();
    isLoaded.value = true;
  }

  return { user, isLoaded, fetchUser };
});
<!-- UserDashboard.vue -->
<script setup>
import { useUserStore } from '@/stores/userStore';

const props = defineProps(['userId']);
const store = useUserStore();

// await 會觸發 Suspense
await store.fetchUser(props.userId);
</script>

<template>
  <div>{{ store.user?.name }}</div>
</template>

Suspense 事件

<Suspense
  @pending="onPending"     <!-- 開始等待非同步依賴時 -->
  @resolve="onResolve"     <!-- 非同步依賴完成時 -->
  @fallback="onFallback"   <!-- fallback 內容顯示時 -->
>
  ...
</Suspense>

嵌套 Suspense

Vue 3.3+ 支援 suspensible prop,允許子元件的非同步操作由最近的父 Suspense 控制:

<Suspense>
  <template #default>
    <ParentComponent />
    <!-- ParentComponent 內部也有 Suspense -->
    <!-- 設定 suspensible 讓父層 Suspense 統一管理 -->
  </template>
  <template #fallback>
    <GlobalLoadingSpinner />
  </template>
</Suspense>

實際案例:Dashboard 頁面

<!-- DashboardPage.vue -->
<script setup>
import { ref } from 'vue';
import StatsWidget from './widgets/StatsWidget.vue';
import ChartWidget from './widgets/ChartWidget.vue';
import RecentActivity from './widgets/RecentActivity.vue';

const loadError = ref(null);
</script>

<template>
  <div class="dashboard">
    <h1>儀表板</h1>

    <!-- 統計數字區塊 -->
    <ErrorBoundary>
      <Suspense>
        <StatsWidget />
        <template #fallback>
          <StatsSkeleton />
        </template>
      </Suspense>
    </ErrorBoundary>

    <!-- 圖表區塊 -->
    <ErrorBoundary>
      <Suspense>
        <ChartWidget />
        <template #fallback>
          <ChartSkeleton />
        </template>
      </Suspense>
    </ErrorBoundary>

    <!-- 最近活動 -->
    <ErrorBoundary>
      <Suspense>
        <RecentActivity />
        <template #fallback>
          <ActivitySkeleton />
        </template>
      </Suspense>
    </ErrorBoundary>
  </div>
</template>

與傳統 loading 狀態的比較

特性 傳統 v-if + isLoading Suspense
程式碼結構 命令式 宣告式
非同步感知 手動管理 自動偵測
巢狀載入 複雜 簡潔
錯誤處理 需自行實作 配合 ErrorBoundary
SSR 支援 完整 部分支援

注意事項

  1. <Suspense> 目前仍是實驗性 API,介面可能在未來版本中調整
  2. 只有 setup() 頂層的 await 才會被 Suspense 感知,巢狀函數中的 await 不會
  3. 配合 SSR 使用時需要特別注意 hydration 的處理

總結

<Suspense> 讓非同步資料載入的程式碼更加優雅,尤其適合需要同時載入多個非同步區塊的頁面。搭配 ErrorBoundary 和 Vue Router,可以打造出媲美原生 App 的流暢載入體驗。雖然它目前仍是實驗性 API,但已相當穩定,值得在正式專案中嘗試。

分享這篇文章