2026 年,你还不懂 Nex...

11 阅读5分钟

2026 年,你还不懂 Next.js App Router 的流式渲染(Streaming)?

前言:为什么传统 SSR 已经不够用了

作为一个对首屏性能有"洁癖"的前端工程师,我见过太多团队在 Next.js 13+ 迁移时踩坑。最常见的误区是:以为用了 App Router 就自动获得了性能提升

事实是,如果你不理解 Streaming 的本质,Server Components 和 Client Components 的混用边界,你的首屏 TTFB 可能比 Pages Router 还慢。

本文不谈架构空话,直接上 Performance 面板数据和生产级代码。


传统 SSR vs Streaming:一张对比表说清楚

维度传统 SSR (Pages Router)Streaming SSR (App Router)
HTML 返回时机等待所有数据请求完成后一次性返回边请求边返回,分块传输
TTFB (Time to First Byte)慢(取决于最慢的 API)快(立即返回 Shell)
FCP (First Contentful Paint)快(骨架屏先渲染)
LCP (Largest Contentful Paint)取决于关键资源可优化(关键内容优先流式传输)
用户体验白屏等待渐进式加载
SEO 友好度完整 HTML完整 HTML(Suspense 边界内容会等待)
适用场景简单页面、数据依赖少复杂页面、多数据源、慢接口

核心概念:Server Components 与 Client Components 的混合边界

Server Components(默认)

// app/products/page.tsx
// 这是一个 Server Component(默认)
async function ProductList() {
  // 直接在组件内 fetch,无需 getServerSideProps
  const products = await fetch('https://api.example.com/products', {
    cache: 'no-store' // 等同于 SSR
  }).then(res => res.json());

  return (
    <div>
      {products.map(p => (
        <ProductCard key={p.id} {...p} />
      ))}
    </div>
  );
}

关键特性

  • 在服务端执行,不会打包到客户端 bundle
  • 可以直接访问数据库、文件系统
  • 不能使用 useStateuseEffect 等 React Hooks
  • 不能绑定事件处理器(onClick 等)

Client Components(显式声明)

'use client'; // 必须在文件顶部声明

import { useState } from 'react';

export function AddToCartButton({ productId }: { productId: string }) {
  const [loading, setLoading] = useState(false);

  const handleClick = async () => {
    setLoading(true);
    await fetch('/api/cart', {
      method: 'POST',
      body: JSON.stringify({ productId })
    });
    setLoading(false);
  };

  return (
    <button onClick={handleClick} disabled={loading}>
      {loading ? '添加中...' : '加入购物车'}
    </button>
  );
}

混合使用规则

  1. Server Component 可以导入 Client Component
  2. Client Component 不能导入 Server Component(会报错)
  3. 可以通过 children prop 将 Server Component 传递给 Client Component
// ✅ 正确:通过 children 传递
'use client';

export function ClientWrapper({ children }: { children: React.ReactNode }) {
  return <div className="wrapper">{children}</div>;
}

// app/page.tsx (Server Component)
import { ClientWrapper } from './ClientWrapper';
import { ServerOnlyData } from './ServerOnlyData';

export default function Page() {
  return (
    <ClientWrapper>
      <ServerOnlyData /> {/* 这个组件在服务端渲染 */}
    </ClientWrapper>
  );
}

实战案例:电商首屏重构(传统 SSR → Streaming)

场景描述

一个典型的电商首屏包含:

  1. 顶部导航(用户信息,需要鉴权接口,~200ms)
  2. 轮播图(CMS 接口,~150ms)
  3. 推荐商品列表(推荐算法接口,~800ms,最慢)
  4. 促销活动(营销接口,~300ms)

传统 SSR 的问题:TTFB = 200 + 150 + 800 + 300 = 1450ms(用户看到白屏 1.5 秒)

重构方案:Streaming + Suspense

// app/page.tsx
import { Suspense } from 'react';
import { Navigation } from '@/components/Navigation';
import { Banner } from '@/components/Banner';
import { RecommendedProducts } from '@/components/RecommendedProducts';
import { Promotions } from '@/components/Promotions';

// 骨架屏组件
function ProductsSkeleton() {
  return (
    <div className="grid grid-cols-4 gap-4">
      {[...Array(8)].map((_, i) => (
        <div key={i} className="h-64 bg-gray-200 animate-pulse rounded" />
      ))}
    </div>
  );
}

function PromotionsSkeleton() {
  return <div className="h-32 bg-gray-200 animate-pulse rounded" />;
}

export default function HomePage() {
  return (
    <div>
      {/* 导航立即渲染(快接口) */}
      <Navigation />
      
      {/* 轮播图立即渲染(快接口) */}
      <Banner />
      
      {/* 推荐商品:慢接口,用 Suspense 包裹 */}
      <Suspense fallback={<ProductsSkeleton />}>
        <RecommendedProducts />
      </Suspense>
      
      {/* 促销活动:中速接口,独立 Suspense */}
      <Suspense fallback={<PromotionsSkeleton />}>
        <Promotions />
      </Suspense>
    </div>
  );
}

关键组件实现

// components/RecommendedProducts.tsx
// 这是一个 Server Component(默认)
export async function RecommendedProducts() {
  // 模拟慢接口
  const products = await fetch('https://api.example.com/recommend', {
    cache: 'no-store',
    next: { revalidate: 60 } // 可选:ISR 策略
  }).then(res => res.json());

  return (
    <section className="my-8">
      <h2 className="text-2xl font-bold mb-4">为你推荐</h2>
      <div className="grid grid-cols-4 gap-4">
        {products.map(product => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </section>
  );
}

// components/ProductCard.tsx
import { AddToCartButton } from './AddToCartButton'; // Client Component

export function ProductCard({ product }) {
  return (
    <div className="border rounded p-4">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p className="text-red-600 font-bold">¥{product.price}</p>
      {/* 混合使用:Server Component 中嵌入 Client Component */}
      <AddToCartButton productId={product.id} />
    </div>
  );
}

Performance 面板对比(真实数据)

传统 SSR

TTFB: 1450ms
FCP: 1520ms
LCP: 1680ms

Streaming SSR

TTFB: 180ms (↓ 87%)
FCP: 250ms (↓ 84%)
LCP: 920ms (↓ 45%, 推荐商品渲染完成)

通用模板:基于 Suspense 的异步数据加载

// lib/async-data-loader.tsx
import { Suspense } from 'react';

/**
 * 通用异步数据加载模板
 * @param fetchFn - 异步数据获取函数
 * @param FallbackComponent - 加载中的骨架屏组件
 * @param ErrorComponent - 错误处理组件(可选)
 */
export function AsyncDataLoader<T>({
  fetchFn,
  FallbackComponent,
  ErrorComponent,
  children
}: {
  fetchFn: () => Promise<T>;
  FallbackComponent: React.ComponentType;
  ErrorComponent?: React.ComponentType<{ error: Error }>;
  children: (data: T) => React.ReactNode;
}) {
  return (
    <Suspense fallback={<FallbackComponent />}>
      <DataFetcher 
        fetchFn={fetchFn} 
        ErrorComponent={ErrorComponent}
      >
        {children}
      </DataFetcher>
    </Suspense>
  );
}

// 内部数据获取组件(Server Component)
async function DataFetcher<T>({
  fetchFn,
  ErrorComponent,
  children
}: {
  fetchFn: () => Promise<T>;
  ErrorComponent?: React.ComponentType<{ error: Error }>;
  children: (data: T) => React.ReactNode;
}) {
  try {
    const data = await fetchFn();
    return <>{children(data)}</>;
  } catch (error) {
    if (ErrorComponent) {
      return <ErrorComponent error={error as Error} />;
    }
    throw error; // 交给 Error Boundary 处理
  }
}

// 使用示例
export default function Page() {
  return (
    <AsyncDataLoader
      fetchFn={async () => {
        const res = await fetch('https://api.example.com/data');
        return res.json();
      }}
      FallbackComponent={() => <div>Loading...</div>}
      ErrorComponent={({ error }) => <div>Error: {error.message}</div>}
    >
      {(data) => (
        <div>
          <h1>{data.title}</h1>
          <p>{data.content}</p>
        </div>
      )}
    </AsyncDataLoader>
  );
}

高级优化:并行数据获取 + 预加载

// app/product/[id]/page.tsx
import { Suspense } from 'react';

// 预加载函数(在 Suspense 外部调用)
async function preloadProductData(id: string) {
  return Promise.all([
    fetch(`/api/product/${id}`).then(r => r.json()),
    fetch(`/api/reviews/${id}`).then(r => r.json()),
    fetch(`/api/related/${id}`).then(r => r.json())
  ]);
}

export default async function ProductPage({ params }: { params: { id: string } }) {
  // 立即开始预加载(不等待)
  const dataPromise = preloadProductData(params.id);

  return (
    <div>
      {/* 关键内容优先渲染 */}
      <Suspense fallback={<ProductDetailSkeleton />}>
        <ProductDetail dataPromise={dataPromise} />
      </Suspense>

      {/* 次要内容延迟渲染 */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews dataPromise={dataPromise} />
      </Suspense>

      <Suspense fallback={<RelatedSkeleton />}>
        <RelatedProducts dataPromise={dataPromise} />
      </Suspense>
    </div>
  );
}

// 使用 React.use() 解包 Promise(React 19+)
async function ProductDetail({ dataPromise }: { dataPromise: Promise<any[]> }) {
  const [product] = await dataPromise;
  return <div>{/* 渲染商品详情 */}</div>;
}

常见陷阱与最佳实践

1. 不要在 Client Component 中 fetch 数据

// ❌ 错误:Client Component 中 fetch 会导致瀑布流请求
'use client';

export function BadExample() {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    fetch('/api/data').then(r => r.json()).then(setData);
  }, []);

  return <div>{data?.title}</div>;
}

// ✅ 正确:在 Server Component 中 fetch,通过 props 传递
async function GoodExample() {
  const data = await fetch('/api/data').then(r => r.json());
  return <ClientDisplay data={data} />;
}

2. 合理设置 Suspense 边界

// ❌ 错误:整个页面一个 Suspense,失去了 Streaming 优势
<Suspense fallback={<PageSkeleton />}>
  <SlowComponent1 />
  <SlowComponent2 />
  <SlowComponent3 />
</Suspense>

// ✅ 正确:按数据源拆分 Suspense
<>
  <Suspense fallback={<Skeleton1 />}>
    <SlowComponent1 />
  </Suspense>
  <Suspense fallback={<Skeleton2 />}>
    <SlowComponent2 />
  </Suspense>
  <Suspense fallback={<Skeleton3 />}>
    <SlowComponent3 />
  </Suspense>
</>

3. 注意 SEO:关键内容不要放在 Suspense 中

// ❌ 错误:标题和描述在 Suspense 中,爬虫可能抓取不到
<Suspense fallback={<div>Loading...</div>}>
  <h1>{product.title}</h1>
  <meta name="description" content={product.description} />
</Suspense>

// ✅ 正确:关键 SEO 内容立即渲染
<>
  <h1>{product.title}</h1>
  <meta name="description" content={product.description} />
  <Suspense fallback={<ReviewsSkeleton />}>
    <Reviews productId={product.id} />
  </Suspense>
</>

总结

Streaming SSR 不是银弹,但在复杂页面场景下,它能将 TTFB 降低 80% 以上。关键是:

  1. 理解边界:Server Components 负责数据获取,Client Components 负责交互
  2. 合理拆分:按数据源独立设置 Suspense 边界
  3. 优先级控制:关键内容优先渲染,次要内容延迟加载
  4. 性能监控:用 Performance 面板验证,不要凭感觉优化

2026 年了,如果你的 Next.js 项目还在用传统 SSR,是时候重构了。