Next.js串流SSR實戰:從Suspense到漸進式渲染的5種生產模式

前端工程

傳統SSR的TTFB為什麼越來越慢

你的電商首頁用了傳統SSR,首屏TTFB從800ms漲到了3秒——因為資料獲取是瀑布流式的:先等使用者資訊,再等商品列表,再等推薦資料,最後等評論。整棵元件樹必須等最慢的那個請求完成才能回傳第一個位元組。使用者盯著白屏,LCP直接爆表。

Next.js 15的Streaming SSR透過Suspense邊界將HTML分塊串流傳輸,先傳送靜態殼,動態部分就緒後追加。本文將帶你完成Suspense邊界佈局→串流HTML與loading.tsx→漸進式水合→並行資料獲取→生產級錯誤邊界的5種生產模式,讓TTFB降低60%以上。


Streaming SSR核心概念

概念 說明
Streaming SSR 串流服端渲染,HTML分塊傳輸,無需等待全部資料就緒
Suspense React並行特性,宣告非同步載入邊界,配合Streaming實現分塊渲染
Progressive Rendering 漸進式渲染,頁面內容逐步呈現,使用者先看到可用內容
Hydration 水合,服端HTML與客戶端JS關聯,使頁面可互動
React Server Components (RSC) 服端元件,零客戶端JS,天然支援Streaming
App Router Next.js 15路由系統,原生支援Streaming SSR和RSC

傳統SSR vs Streaming SSR

傳統SSR:
請求到達 → 等待所有資料 → 生成完整HTML → 一次性傳送 → TTFB = 最慢請求耗時

Streaming SSR:
請求到達 → 立即傳送靜態殼 → Suspense分塊 → 各塊資料就緒後追加 → TTFB ≈ 0

問題分析:傳統SSR的5大挑戰

  1. 瀑布流資料獲取:元件巢狀導致串行fetch,TTFB = fetchA + fetchB + fetchC,線性增長
  2. 阻塞式渲染:整棵樹必須等最慢節點完成,快速資料被慢速資料拖累
  3. 水合瓶頸:大頁面一次性水合,主執行緒長時間阻塞,INP指標惡化
  4. 錯誤雪崩:一個資料來源失敗導致整頁500,無降級能力
  5. 快取粒度粗:整頁快取無法區分靜態/動態部分,頻繁失效導致命中率低

分步實操:5種生產模式

模式1:Suspense邊界佈局與串流渲染

// app/page.tsx
import { Suspense } from 'react';

export default function DashboardPage() {
  return (
    <div className="min-h-screen bg-gray-50">
      <DashboardHeader />
      <main className="container mx-auto px-4 py-6">
        <Suspense fallback={<StatsCardsSkeleton />}>
          <StatsCards />
        </Suspense>
        <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6">
          <Suspense fallback={<RevenueChartSkeleton />}>
            <RevenueChart />
          </Suspense>
          <Suspense fallback={<RecentOrdersSkeleton />}>
            <RecentOrders />
          </Suspense>
        </div>
        <Suspense fallback={<ActivityFeedSkeleton />}>
          <ActivityFeed />
        </Suspense>
      </main>
    </div>
  );
}
// app/components/StatsCards.tsx
async function StatsCards() {
  const stats = await fetchDashboardStats();

  return (
    <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
      <StatCard title="總收入" value={`NT$${stats.revenue.toLocaleString()}`} change={stats.revenueChange} />
      <StatCard title="訂單數" value={stats.orders.toLocaleString()} change={stats.ordersChange} />
      <StatCard title="使用者數" value={stats.users.toLocaleString()} change={stats.usersChange} />
      <StatCard title="轉換率" value={`${stats.conversionRate}%`} change={stats.conversionChange} />
    </div>
  );
}

function StatCard({ title, value, change }: { title: string; value: string; change: number }) {
  const isPositive = change >= 0;
  return (
    <div className="bg-white rounded-lg shadow-sm p-6">
      <p className="text-sm text-gray-500">{title}</p>
      <p className="text-2xl font-bold mt-1">{value}</p>
      <p className={`text-sm mt-2 ${isPositive ? 'text-green-600' : 'text-red-600'}`}>
        {isPositive ? '↑' : '↓'} {Math.abs(change)}%
      </p>
    </div>
  );
}
// app/components/skeletons.tsx
export function StatsCardsSkeleton() {
  return (
    <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
      {Array.from({ length: 4 }).map((_, i) => (
        <div key={i} className="bg-white rounded-lg shadow-sm p-6 animate-pulse">
          <div className="bg-gray-200 h-4 rounded w-1/2" />
          <div className="bg-gray-200 h-8 rounded w-3/4 mt-3" />
          <div className="bg-gray-200 h-4 rounded w-1/3 mt-3" />
        </div>
      ))}
    </div>
  );
}

export function RevenueChartSkeleton() {
  return (
    <div className="bg-white rounded-lg shadow-sm p-6 animate-pulse">
      <div className="bg-gray-200 h-6 rounded w-1/3" />
      <div className="bg-gray-200 h-64 rounded mt-4" />
    </div>
  );
}

export function RecentOrdersSkeleton() {
  return (
    <div className="bg-white rounded-lg shadow-sm p-6 animate-pulse">
      <div className="bg-gray-200 h-6 rounded w-1/3" />
      {Array.from({ length: 5 }).map((_, i) => (
        <div key={i} className="flex gap-4 mt-4">
          <div className="bg-gray-200 h-4 rounded w-1/4" />
          <div className="bg-gray-200 h-4 rounded w-1/4" />
          <div className="bg-gray-200 h-4 rounded w-1/6" />
          <div className="bg-gray-200 h-4 rounded w-1/6" />
        </div>
      ))}
    </div>
  );
}

export function ActivityFeedSkeleton() {
  return (
    <div className="bg-white rounded-lg shadow-sm p-6 animate-pulse">
      <div className="bg-gray-200 h-6 rounded w-1/4" />
      {Array.from({ length: 8 }).map((_, i) => (
        <div key={i} className="flex gap-3 mt-4">
          <div className="bg-gray-200 h-10 w-10 rounded-full" />
          <div className="flex-1">
            <div className="bg-gray-200 h-4 rounded w-1/2" />
            <div className="bg-gray-200 h-3 rounded w-3/4 mt-2" />
          </div>
        </div>
      ))}
    </div>
  );
}

模式2:串流HTML與loading.tsx降級

// app/products/loading.tsx
export default function ProductsLoading() {
  return (
    <div className="min-h-screen bg-gray-50">
      <div className="container mx-auto px-4 py-8">
        <div className="animate-pulse">
          <div className="bg-gray-200 h-10 rounded w-1/4" />
          <div className="bg-gray-200 h-6 rounded w-1/2 mt-4" />
        </div>
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mt-8">
          {Array.from({ length: 8 }).map((_, i) => (
            <div key={i} className="bg-white rounded-lg shadow-sm overflow-hidden animate-pulse">
              <div className="bg-gray-200 h-48" />
              <div className="p-4">
                <div className="bg-gray-200 h-5 rounded w-3/4" />
                <div className="bg-gray-200 h-4 rounded w-1/2 mt-2" />
                <div className="bg-gray-200 h-8 rounded w-1/3 mt-3" />
              </div>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}
// app/products/page.tsx
import { Suspense } from 'react';
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: '商品列表 - 我的商城',
  description: '瀏覽我們的精選商品',
};

export default function ProductsPage() {
  return (
    <div className="min-h-screen bg-gray-50">
      <div className="container mx-auto px-4 py-8">
        <h1 className="text-3xl font-bold">商品列表</h1>
        <p className="text-gray-600 mt-2">瀏覽我們的精選商品</p>
        <div className="mt-8">
          <Suspense fallback={<ProductGridSkeleton />}>
            <ProductGrid />
          </Suspense>
        </div>
        <div className="mt-12">
          <Suspense fallback={<CategoryNavSkeleton />}>
            <CategoryNav />
          </Suspense>
        </div>
      </div>
    </div>
  );
}
// app/products/[id]/page.tsx
import { Suspense } from 'react';
import { notFound } from 'next/navigation';

interface ProductPageProps {
  params: Promise<{ id: string }>;
}

export default async function ProductDetailPage({ params }: ProductPageProps) {
  const { id } = await params;
  const product = await getProduct(id);

  if (!product) {
    notFound();
  }

  return (
    <div className="min-h-screen bg-white">
      <div className="container mx-auto px-4 py-8">
        <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
          <ProductGallery images={product.images} />
          <ProductInfo product={product} />
        </div>
        <div className="mt-12">
          <Suspense fallback={<ReviewsSkeleton />}>
            <ProductReviews productId={product.id} />
          </Suspense>
        </div>
        <div className="mt-12">
          <Suspense fallback={<RelatedProductsSkeleton />}>
            <RelatedProducts category={product.category} excludeId={product.id} />
          </Suspense>
        </div>
      </div>
    </div>
  );
}

模式3:漸進式水合與動態匯入

// app/components/InteractiveMap.tsx
'use client';

import { useState, useEffect } from 'react';

interface MapProps {
  center: { lat: number; lng: number };
  markers: Array<{ lat: number; lng: number; label: string }>;
}

export function InteractiveMap({ center, markers }: MapProps) {
  const [MapLib, setMapLib] = useState<React.ComponentType<MapProps> | null>(null);
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true);
          observer.disconnect();
        }
      },
      { rootMargin: '200px' }
    );

    const el = document.getElementById('map-container');
    if (el) observer.observe(el);

    return () => observer.disconnect();
  }, []);

  useEffect(() => {
    if (!isVisible) return;

    import('./MapRenderer').then((mod) => {
      setMapLib(() => mod.MapRenderer);
    });
  }, [isVisible]);

  if (!isVisible || !MapLib) {
    return (
      <div id="map-container" className="bg-gray-100 rounded-lg h-96 flex items-center justify-center">
        <p className="text-gray-400">地圖載入中...</p>
      </div>
    );
  }

  return <MapLib center={center} markers={markers} />;
}
// app/components/HeavyChart.tsx
import dynamic from 'next/dynamic';

const Chart = dynamic(() => import('./ChartRenderer'), {
  loading: () => (
    <div className="bg-gray-100 rounded-lg h-80 animate-pulse flex items-center justify-center">
      <span className="text-gray-400">圖表載入中...</span>
    </div>
  ),
  ssr: false,
});

export function HeavyChart({ data }: { data: ChartData[] }) {
  return (
    <div className="bg-white rounded-lg shadow-sm p-6">
      <h3 className="text-lg font-semibold mb-4">資料分析</h3>
      <Chart data={data} />
    </div>
  );
}
// app/components/CommentSection.tsx
import { Suspense } from 'react';

export function CommentSection({ postId }: { postId: string }) {
  return (
    <section className="mt-8">
      <h2 className="text-2xl font-bold mb-4">評論區</h2>
      <Suspense fallback={<CommentFormSkeleton />}>
        <CommentForm postId={postId} />
      </Suspense>
      <Suspense fallback={<CommentListSkeleton />}>
        <CommentList postId={postId} />
      </Suspense>
    </section>
  );
}

// 客戶端互動元件:延遲載入
'use client';
import { useState, useTransition } from 'react';

function CommentForm({ postId }: { postId: string }) {
  const [content, setContent] = useState('');
  const [isPending, startTransition] = useTransition();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    startTransition(async () => {
      await submitComment(postId, content);
      setContent('');
    });
  };

  return (
    <form onSubmit={handleSubmit} className="mb-6">
      <textarea
        value={content}
        onChange={(e) => setContent(e.target.value)}
        className="w-full border rounded-lg p-3 focus:ring-2 focus:ring-blue-500"
        rows={3}
        placeholder="寫下你的評論..."
      />
      <button
        type="submit"
        disabled={isPending || !content.trim()}
        className="mt-2 px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50"
      >
        {isPending ? '提交中...' : '發表評論'}
      </button>
    </form>
  );
}

模式4:並行資料獲取與Promise.all

// app/dashboard/page.tsx
import { Suspense } from 'react';

export default async function DashboardPage() {
  return (
    <div className="min-h-screen bg-gray-50 p-6">
      <Suspense fallback={<OverviewSkeleton />}>
        <DashboardOverview />
      </Suspense>
    </div>
  );
}

async function DashboardOverview() {
  const [users, orders, revenue] = await Promise.all([
    fetchUsers(),
    fetchOrders(),
    fetchRevenue(),
  ]);

  return (
    <div>
      <div className="grid grid-cols-3 gap-6">
        <UserStats users={users} />
        <OrderStats orders={orders} />
        <RevenueStats revenue={revenue} />
      </div>
      <div className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
        <Suspense fallback={<ChartSkeleton />}>
          <TrendChart data={revenue} />
        </Suspense>
        <Suspense fallback={<TableSkeleton />}>
          <RecentActivity orders={orders} />
        </Suspense>
      </div>
    </div>
  );
}
// lib/data-fetching.ts
import { cache } from 'react';

interface FetchOptions {
  revalidate?: number;
  tags?: string[];
}

function createFetchOptions(options?: FetchOptions): RequestInit {
  return {
    next: {
      revalidate: options?.revalidate ?? 60,
      tags: options?.tags ?? [],
    },
  };
}

export const fetchUsers = cache(async () => {
  const res = await fetch('https://api.example.com/users', createFetchOptions({
    revalidate: 300,
    tags: ['users'],
  }));
  if (!res.ok) throw new Error('Failed to fetch users');
  return res.json() as Promise<User[]>;
});

export const fetchOrders = cache(async () => {
  const res = await fetch('https://api.example.com/orders', createFetchOptions({
    revalidate: 60,
    tags: ['orders'],
  }));
  if (!res.ok) throw new Error('Failed to fetch orders');
  return res.json() as Promise<Order[]>;
});

export const fetchRevenue = cache(async () => {
  const res = await fetch('https://api.example.com/revenue', createFetchOptions({
    revalidate: 300,
    tags: ['revenue'],
  }));
  if (!res.ok) throw new Error('Failed to fetch revenue');
  return res.json() as Promise<RevenueData[]>;
});

export const fetchProduct = cache(async (id: string) => {
  const res = await fetch(`https://api.example.com/products/${id}`, createFetchOptions({
    revalidate: 600,
    tags: [`product-${id}`],
  }));
  if (!res.ok) return null;
  return res.json() as Promise<Product>;
});

export const fetchDashboardStats = cache(async () => {
  const res = await fetch('https://api.example.com/dashboard/stats', createFetchOptions({
    revalidate: 30,
    tags: ['dashboard', 'stats'],
  }));
  if (!res.ok) throw new Error('Failed to fetch dashboard stats');
  return res.json() as Promise<DashboardStats>;
});
// app/products/page.tsx — 並行獲取 + Suspense分離
import { Suspense } from 'react';

export default function ProductsPage() {
  return (
    <div>
      <Suspense fallback={<FeaturedProductsSkeleton />}>
        <FeaturedProducts />
      </Suspense>
      <Suspense fallback={<NewArrivalsSkeleton />}>
        <NewArrivals />
      </Suspense>
      <Suspense fallback={<SaleProductsSkeleton />}>
        <SaleProducts />
      </Suspense>
    </div>
  );
}

async function FeaturedProducts() {
  const products = await fetch('https://api.example.com/products/featured', {
    next: { revalidate: 600, tags: ['featured'] },
  }).then(r => r.json());
  return <ProductGrid products={products} />;
}

async function NewArrivals() {
  const products = await fetch('https://api.example.com/products/new', {
    next: { revalidate: 300, tags: ['new-arrivals'] },
  }).then(r => r.json());
  return <ProductGrid products={products} />;
}

async function SaleProducts() {
  const products = await fetch('https://api.example.com/products/sale', {
    next: { revalidate: 60, tags: ['sale'] },
  }).then(r => r.json());
  return <ProductGrid products={products} />;
}

模式5:生產級Streaming SSR與錯誤邊界

// app/components/ErrorBoundary.tsx
'use client';

import { Component, type ReactNode } from 'react';

interface ErrorBoundaryProps {
  fallback?: ReactNode;
  children: ReactNode;
}

interface ErrorBoundaryState {
  hasError: boolean;
  error: Error | null;
}

export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
  constructor(props: ErrorBoundaryProps) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { hasError: true, error };
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || (
        <div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
          <h3 className="text-red-800 font-semibold">載入失敗</h3>
          <p className="text-red-600 text-sm mt-2">{this.state.error?.message}</p>
          <button
            onClick={() => this.setState({ hasError: false, error: null })}
            className="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg text-sm"
          >
            重試
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}
// app/error.tsx — 路由級錯誤邊界
'use client';

import { useEffect } from 'react';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    console.error('[Streaming SSR Error]', error.digest, error.message);
  }, [error]);

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="text-center">
        <h2 className="text-2xl font-bold text-gray-800">頁面載入出錯</h2>
        <p className="text-gray-600 mt-2">錯誤代碼:{error.digest || 'UNKNOWN'}</p>
        <button
          onClick={reset}
          className="mt-6 px-6 py-3 bg-blue-600 text-white rounded-lg"
        >
          重新載入
        </button>
      </div>
    </div>
  );
}
// app/page.tsx — 生產級組合
import { Suspense } from 'react';
import { ErrorBoundary } from './components/ErrorBoundary';

export default function HomePage() {
  return (
    <div className="min-h-screen">
      <Header />
      <main>
        <HeroSection />
        <ErrorBoundary fallback={<ProductGridFallback />}>
          <Suspense fallback={<ProductGridSkeleton />}>
            <ProductGrid />
          </Suspense>
        </ErrorBoundary>
        <ErrorBoundary fallback={<RecommendationsFallback />}>
          <Suspense fallback={<RecommendationsSkeleton />}>
            <Recommendations />
          </Suspense>
        </ErrorBoundary>
        <ErrorBoundary fallback={<NewsletterFallback />}>
          <Suspense fallback={<NewsletterSkeleton />}>
            <Newsletter />
          </Suspense>
        </ErrorBoundary>
      </main>
      <Footer />
    </div>
  );
}
// app/components/fallbacks.tsx
export function ProductGridFallback() {
  return (
    <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6 text-center">
      <p className="text-yellow-700">商品資料暫時無法載入,請稍後再試</p>
      <a href="/products" className="text-blue-600 underline mt-2 inline-block">
        瀏覽全部商品
      </a>
    </div>
  );
}

export function RecommendationsFallback() {
  return (
    <div className="text-center py-8 text-gray-500">
      <p>個人化推薦暫時不可用</p>
    </div>
  );
}

export function NewsletterFallback() {
  return (
    <div className="text-center py-4 text-gray-400">
      <p>訂閱功能暫時不可用</p>
    </div>
  );
}

避坑指南

坑1:Suspense邊界粒度過細

// ❌ 錯誤:每個小元件單獨Suspense,導致多次網路往返
<Suspense fallback={<Skeleton />}><UserName /></Suspense>
<Suspense fallback={<Skeleton />}><UserEmail /></Suspense>
<Suspense fallback={<Skeleton />}><UserAvatar /></Suspense>
<Suspense fallback={<Skeleton />}><UserBio /></Suspense>
// 4次串流往返,TTFB反而更差

// ✅ 正確:相關元件共享Suspense邊界
<Suspense fallback={<UserProfileSkeleton />}>
  <UserProfile /> {/* 內部包含UserName, UserEmail, UserAvatar, UserBio */}
</Suspense>

坑2:async元件未正確處理錯誤

// ❌ 錯誤:async Server Component沒有錯誤處理
async function ProductList() {
  const products = await fetchProducts(); // 如果fetch失敗,整頁500
  return <div>{products.map(p => <Card key={p.id} />)}</div>;
}

// ✅ 正確:配合ErrorBoundary + Suspense使用
<ErrorBoundary fallback={<ProductListFallback />}>
  <Suspense fallback={<ProductListSkeleton />}>
    <ProductList />
  </Suspense>
</ErrorBoundary>

// ✅ 更優:元件內部try-catch
async function ProductList() {
  try {
    const products = await fetchProducts();
    return <div>{products.map(p => <Card key={p.id} />)}</div>;
  } catch (error) {
    console.error('ProductList fetch failed:', error);
    return <ProductListFallback />;
  }
}

坑3:loading.tsx與Suspense同時使用導致雙重骨架屏

// ❌ 錯誤:loading.tsx和Suspense fallback同時生效
// app/products/loading.tsx — 路由切換時顯示
export default function Loading() {
  return <FullPageSkeleton />;
}

// app/products/page.tsx — 頁面內部也有Suspense
export default function ProductsPage() {
  return (
    <Suspense fallback={<ProductGridSkeleton />}>
      <ProductGrid />
    </Suspense>
  );
}
// 使用者看到:FullPageSkeleton → ProductGridSkeleton → 真實內容(兩次閃爍)

// ✅ 正確:loading.tsx只處理路由級,頁面內部用Suspense
// 刪除loading.tsx,在layout.tsx中使用Suspense
export default function ProductsLayout({ children }: { children: React.ReactNode }) {
  return (
    <div>
      <ProductsNav />
      <Suspense fallback={<ProductsPageSkeleton />}>
        {children}
      </Suspense>
    </div>
  );
}

坑4:動態匯入未設定ssr: false導致水合錯誤

// ❌ 錯誤:依賴瀏覽器API的元件未停用SSR
import dynamic from 'next/dynamic';

const Map = dynamic(() => import('./Map'), {
  loading: () => <div>載入中...</div>,
  // 缺少 ssr: false!Map內部用了window/document,SSR會報錯
});

// ✅ 正確:瀏覽器專屬元件停用SSR
const Map = dynamic(() => import('./Map'), {
  loading: () => <MapSkeleton />,
  ssr: false,
});

坑5:Promise.all中一個失敗導致全部失敗

// ❌ 錯誤:Promise.all任一失敗則全部失敗
const [users, orders, revenue] = await Promise.all([
  fetchUsers(),    // 如果這個失敗,orders和revenue也拿不到
  fetchOrders(),
  fetchRevenue(),
]);

// ✅ 正確:使用Promise.allSettled + Suspense分離
const results = await Promise.allSettled([
  fetchUsers(),
  fetchOrders(),
  fetchRevenue(),
]);

const users = results[0].status === 'fulfilled' ? results[0].value : [];
const orders = results[1].status === 'fulfilled' ? results[1].value : [];
const revenue = results[2].status === 'fulfilled' ? results[2].value : [];

// ✅ 更優:每個資料來源獨立Suspense邊界,完全隔離
<Suspense fallback={<UsersSkeleton />}>
  <UsersSection />
</Suspense>
<Suspense fallback={<OrdersSkeleton />}>
  <OrdersSection />
</Suspense>
<Suspense fallback={<RevenueSkeleton />}>
  <RevenueSection />
</Suspense>

報錯排查

序號 報錯資訊 原因 解決方法
1 Hydration failed because the server rendered HTML didn't match 服端與客戶端渲染內容不一致 檢查Date/Random/瀏覽器API,使用suppressHydrationWarning
2 Functions cannot be passed directly to Client Components Server Component向Client Component傳遞了函式 將邏輯移入Client Component或透過Server Action傳遞
3 Route "/xxx" used cookies() but is statically generated 靜態頁面中呼叫了動態API 新增export const dynamic = 'force-dynamic'
4 useSearchParams() should be wrapped in Suspense useSearchParams未包裹Suspense 在外層新增Suspense boundary
5 Maximum call stack size exceeded Suspense巢狀導致無限迴圈 檢查async元件是否在Suspense fallback中遞迴引用
6 Text content did not match 串流HTML與水合內容不匹配 確保Suspense fallback與實際內容結構一致
7 Cannot read properties of null (reading 'useContext') 客戶端Hook在Server Component中使用 新增'use client'指令或拆分元件
8 Module not found: Can't resolve 'fs' 客戶端元件引用了Node.js模組 將Node.js邏輯移到Server Component或Route Handler
9 Streaming chunk error: invalid byte sequence 串流傳輸中編碼問題 確保API回傳UTF-8編碼,檢查中介代理設定
10 Abort signal timeout 資料獲取超時 增加超時時間或使用React.cache避免重複請求

進階最佳化

1. Partial Prerendering(PPR)實現靜態殼+動態內容

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  experimental: {
    ppr: 'incremental',
  },
};

export default nextConfig;
// app/page.tsx — PPR模式
import { Suspense } from 'react';

export const experimental_ppr = true;

export default function HomePage() {
  return (
    <div className="min-h-screen">
      {/* 靜態殼 —— 建構時渲染,CDN快取,TTFB ≈ 0 */}
      <Header />
      <HeroBanner />
      <StaticCategories />

      {/* 動態內容 —— 請求時串流渲染 */}
      <Suspense fallback={<PersonalizedFeedSkeleton />}>
        <PersonalizedFeed />
      </Suspense>

      {/* 靜態內容 —— 建構時渲染 */}
      <Footer />
    </div>
  );
}

async function PersonalizedFeed() {
  const user = await getCurrentUser();
  const feed = await fetchPersonalizedFeed(user.id);
  return <FeedGrid items={feed} />;
}

2. React Cache與請求去重

// lib/cached-fetch.ts
import { cache } from 'react';

export const getProduct = cache(async (id: string): Promise<Product | null> => {
  const res = await fetch(`https://api.example.com/products/${id}`, {
    next: { revalidate: 600, tags: [`product-${id}`] },
  });
  if (!res.ok) return null;
  return res.json();
});

// 多個元件呼叫getProduct('123'),只發一次請求
async function ProductHeader({ id }: { id: string }) {
  const product = await getProduct(id);
  if (!product) return null;
  return <h1 className="text-2xl font-bold">{product.name}</h1>;
}

async function ProductPrice({ id }: { id: string }) {
  const product = await getProduct(id);
  if (!product) return null;
  return <span className="text-lg text-red-600">NT$${product.price}</span>;
}

async function ProductStock({ id }: { id: string }) {
  const product = await getProduct(id);
  if (!product) return null;
  return <span className={product.inStock ? 'text-green-600' : 'text-red-600'}>
    {product.inStock ? '有貨' : '缺貨'}
  </span>;
}

3. 串流傳輸效能監控

// app/components/StreamingReporter.tsx
'use client';

import { useEffect } from 'react';

interface StreamingMetrics {
  ttfb: number;
  firstChunk: number;
  lastChunk: number;
  totalChunks: number;
}

export function StreamingReporter() {
  useEffect(() => {
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.entryType === 'navigation') {
          const nav = entry as PerformanceNavigationTiming;
          const metrics: StreamingMetrics = {
            ttfb: nav.responseStart - nav.requestStart,
            firstChunk: nav.responseStart - nav.fetchStart,
            lastChunk: nav.responseEnd - nav.fetchStart,
            totalChunks: Math.round(nav.transferSize / 16384),
          };

          if (metrics.ttfb > 1000) {
            console.warn('[Streaming SSR] TTFB慢:', metrics);
          }

          if (typeof window !== 'undefined' && window.gtag) {
            window.gtag('event', 'streaming_ssr_metrics', {
              ttfb: metrics.ttfb,
              first_chunk: metrics.firstChunk,
              last_chunk: metrics.lastChunk,
            });
          }
        }
      }
    });

    observer.observe({ entryTypes: ['navigation'] });

    return () => observer.disconnect();
  }, []);

  return null;
}

對比分析

維度 傳統SSR Streaming SSR ISR CSR SSG
TTFB ⚠️高(等最慢資料) ✅低(先發靜態殼) ✅極低(CDN) ⚠️中(空HTML) ✅極低(CDN)
首屏渲染 ⚠️一次性 ✅漸進式 ✅即時 ❌慢(等JS) ✅即時
即時資料 ✅支援 ✅支援 ⚠️延遲 ✅支援 ❌不支援
SEO ✅完整HTML ✅完整HTML ✅完整HTML ❌空HTML ✅完整HTML
伺服器負載 ⚠️高 ⚠️中 ✅低 ✅極低 ✅極低
錯誤隔離 ❌整頁失敗 ✅分塊降級 ❌整頁失敗 ✅元件級 ❌建構失敗
快取粒度 ⚠️整頁 ✅分塊 ✅整頁 ⚠️API級 ✅整頁
適用場景 即時動態頁 複雜混合頁 內容站 後台管理 靜態內容站

總結:Streaming SSR不是簡單的技術升級,而是渲染範式的轉變——從「等所有資料就緒再回應」到「先給使用者能看到的內容」。2026年的生產實踐路徑:先用Suspense邊界拆分關鍵內容塊(TTFB降低60%+)→再用loading.tsx實現路由級降級→然後透過動態匯入實現漸進式水合(INP改善40%+)→接著用Promise.all+獨立Suspense實現並行獲取→最後用ErrorBoundary實現生產級容錯。核心原則:能串流就不阻塞,能並行就不串行,能降級就不500


線上工具推薦

本站提供瀏覽器本地工具,免註冊即可試用 →

#Next.js#Streaming SSR#React#Suspense#性能优化#2026#App Router