React Suspense

Suspense 讓你可以在子元件尚未準備好時顯示備用內容(fallback),例如載入中的提示。它是 React 處理非同步載入的核心機制。

基本用法

import { Suspense } from 'react'

function App() {
  return (
    <Suspense fallback={<p>載入中...</p>}>
      <AsyncComponent />
    </Suspense>
  )
}

AsyncComponent 尚未準備好時(例如正在等待資料載入),會顯示 fallback 的內容。

Suspense 的運作原理

當被 Suspense 包裹的子元件「暫停」時,Suspense 會顯示 fallback。子元件可以透過以下方式觸發暫停:

  1. 使用 use Hook 讀取 Promise
  2. 使用支援 Suspense 的資料獲取函式庫
  3. 使用 React.lazy() 動態載入元件

搭配 use Hook 載入資料

import { use, Suspense } from 'react'

async function fetchAlbums(artistId) {
  const response = await fetch(`/api/artists/${artistId}/albums`)
  return response.json()
}

function Albums({ artistId }) {
  const albums = use(fetchAlbums(artistId))

  return (
    <ul>
      {albums.map((album) => (
        <li key={album.id}>{album.title}</li>
      ))}
    </ul>
  )
}

function ArtistPage({ artistId }) {
  return (
    <div>
      <h1>專輯列表</h1>
      <Suspense fallback={<p>載入專輯中...</p>}>
        <Albums artistId={artistId} />
      </Suspense>
    </div>
  )
}

動態載入元件 (React.lazy)

React.lazy 讓你可以動態載入元件,搭配 Suspense 使用:

import { Suspense, lazy } from 'react'

// 動態載入元件(只有在需要時才會載入)
const HeavyComponent = lazy(() => import('./HeavyComponent'))
const AdminPanel = lazy(() => import('./AdminPanel'))

function App() {
  return (
    <div>
      <h1>我的應用程式</h1>

      <Suspense fallback={<p>載入中...</p>}>
        <HeavyComponent />
      </Suspense>
    </div>
  )
}

這種技術稱為程式碼分割(Code Splitting),可以減少初始載入的 JavaScript 大小。

巢狀 Suspense

你可以使用多層 Suspense 來控制不同區塊的載入狀態:

function App() {
  return (
    <Suspense fallback={<p>載入頁面...</p>}>
      <Header />

      <main>
        <Suspense fallback={<p>載入使用者資訊...</p>}>
          <UserProfile />
        </Suspense>

        <Suspense fallback={<p>載入文章...</p>}>
          <Articles />
        </Suspense>
      </main>
    </Suspense>
  )
}

好處:

  • 不同區塊可以獨立載入
  • 先載入完成的區塊會先顯示
  • 使用者可以更快看到部分內容

Skeleton 載入畫面

比起單純的「載入中...」文字,使用 Skeleton(骨架屏)可以提供更好的使用者體驗:

function ArticleSkeleton() {
  return (
    <div className="article-skeleton">
      <div className="skeleton-title" />
      <div className="skeleton-line" />
      <div className="skeleton-line" />
      <div className="skeleton-line short" />
    </div>
  )
}

function ArticleList() {
  return (
    <Suspense fallback={<ArticleSkeleton />}>
      <Articles />
    </Suspense>
  )
}

CSS 範例:

.skeleton-title,
.skeleton-line {
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  border-radius: 4px;
}

.skeleton-title {
  height: 24px;
  width: 60%;
  margin-bottom: 16px;
}

.skeleton-line {
  height: 16px;
  margin-bottom: 8px;
}

.skeleton-line.short {
  width: 40%;
}

@keyframes shimmer {
  0% {
    background-position: -200% 0;
  }
  100% {
    background-position: 200% 0;
  }
}

平行載入 vs 串列載入

平行載入(推薦)

多個資源同時開始載入:

function UserPage({ userId }) {
  // 同時開始載入(平行)
  const userPromise = fetchUser(userId)
  const postsPromise = fetchPosts(userId)

  return (
    <div>
      <Suspense fallback={<p>載入使用者...</p>}>
        <UserInfo userPromise={userPromise} />
      </Suspense>

      <Suspense fallback={<p>載入文章...</p>}>
        <UserPosts postsPromise={postsPromise} />
      </Suspense>
    </div>
  )
}

串列載入(瀑布式)

一個載入完成後才開始下一個,應該避免:

// ❌ 不好:串列載入(瀑布式)
function UserPosts({ userId }) {
  // 要等 UserInfo 載入完成後才會開始載入文章
  const posts = use(fetchPosts(userId))
  // ...
}

function UserInfo({ userId }) {
  const user = use(fetchUser(userId))

  return (
    <div>
      <h1>{user.name}</h1>
      {/* 等 user 載入完成後才渲染 UserPosts */}
      <Suspense fallback={<p>載入文章...</p>}>
        <UserPosts userId={userId} />
      </Suspense>
    </div>
  )
}

搭配錯誤邊界

使用 Error Boundary 處理載入失敗的情況:

import { Suspense } from 'react'
import { ErrorBoundary } from 'react-error-boundary'

function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div className="error">
      <p>發生錯誤:{error.message}</p>
      <button onClick={resetErrorBoundary}>重試</button>
    </div>
  )
}

function App() {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback}>
      <Suspense fallback={<p>載入中...</p>}>
        <DataComponent />
      </Suspense>
    </ErrorBoundary>
  )
}

實際範例:儀表板頁面

import { Suspense, lazy } from 'react'

// 動態載入圖表元件
const SalesChart = lazy(() => import('./SalesChart'))
const TrafficChart = lazy(() => import('./TrafficChart'))

function DashboardSkeleton() {
  return (
    <div className="dashboard-skeleton">
      <div className="skeleton-card" />
      <div className="skeleton-card" />
      <div className="skeleton-chart" />
    </div>
  )
}

function StatsSkeleton() {
  return (
    <div className="stats-skeleton">
      <div className="skeleton-stat" />
      <div className="skeleton-stat" />
      <div className="skeleton-stat" />
    </div>
  )
}

function Dashboard() {
  const statsPromise = fetchDashboardStats()
  const salesPromise = fetchSalesData()
  const trafficPromise = fetchTrafficData()

  return (
    <div className="dashboard">
      <h1>儀表板</h1>

      {/* 統計數據 */}
      <Suspense fallback={<StatsSkeleton />}>
        <StatsCards statsPromise={statsPromise} />
      </Suspense>

      <div className="charts-grid">
        {/* 銷售圖表 */}
        <Suspense fallback={<p>載入銷售圖表...</p>}>
          <SalesChart dataPromise={salesPromise} />
        </Suspense>

        {/* 流量圖表 */}
        <Suspense fallback={<p>載入流量圖表...</p>}>
          <TrafficChart dataPromise={trafficPromise} />
        </Suspense>
      </div>
    </div>
  )
}