2026 年,你还不懂 Nex...

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(默认)

tsx 复制代码
// 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(显式声明)

tsx 复制代码
'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
tsx 复制代码
// ✅ 正确:通过 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

tsx 复制代码
// 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>
  );
}

关键组件实现

tsx 复制代码
// 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

makefile 复制代码
TTFB: 1450ms
FCP: 1520ms
LCP: 1680ms

Streaming SSR

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

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

tsx 复制代码
// 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>
  );
}

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

tsx 复制代码
// 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 数据

tsx 复制代码
// ❌ 错误: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 边界

tsx 复制代码
// ❌ 错误:整个页面一个 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 中

tsx 复制代码
// ❌ 错误:标题和描述在 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,是时候重构了。

相关推荐
城南陌上2 天前
Next.js 博客终极 SEO 优化指南:从 Sitemap 到动态 OG 图片(end)
next.js
城南陌上2 天前
提升博客体验:给 Next.js 站点添加 RSS 订阅与毫秒级全局搜索(7)
next.js
hxy06013 天前
Nextjs实现Cookie、Localstorage、SessionStorage通用的方法
next.js
helloweilei8 天前
一文搞懂Nextjs中的Proxy
前端·next.js
小肚肚肚肚肚哦10 天前
Nextjs ISR 企业落地实战
next.js
Dilettante25813 天前
React Server Components 全链路解析:Next.js 构建产物、导航流程与 Payload 格式
前端·next.js
HashTang13 天前
从 Next.js 完全迁移到 vinext 的实战踩坑指南
ai编程·全栈·next.js
小岛前端13 天前
Cloudflare 掀桌子了,Next.js 迎来重大变化,尤雨溪都说酷!
前端·vite·next.js
孟陬13 天前
Tanstack Start 的天才创新之处——基于实际使用体验
react.js·visual studio code·next.js