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
  • 可以直接访问数据库、文件系统
  • 不能使用 useState、useEffect 等 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,是时候重构了。

相关推荐
zach5 小时前
RSC(React Server Components)详解
next.js
To_OC2 天前
同样是让 AI 写全栈,为什么别人一句话就够了
前端·前端框架·next.js
FungLeo6 天前
成为全栈·Next.js 网站前台篇·响应式、可访问性与错误状态:内容站不能只验桌面首页
react.js·响应式设计·next.js·移动端适配·成为全栈·web 可访问性
FungLeo7 天前
成为全栈·Next.js 网站前台篇·站点设置如何驱动页头、页脚与 SEO
react·isr·next.js·metadata·成为全栈·站点配置
FungLeo9 天前
成为全栈·Next.js 网站前台篇·同源 BFF 代理:API、附件、Cookie 与跨站写入保护
react·csrf·next.js·bff·成为全栈·route handler
FungLeo9 天前
成为全栈·Next.js 网站前台篇·评论系统:叠楼、回复、删除与内容审核如何落到前台
react.js·内容审核·next.js·评论系统·树形数据·成为全栈
cindershade12 天前
一条请求的接力赛:用运行时责任重画 RSC 边界
性能优化·next.js
zach13 天前
前端落地实战:Next.js 项目 Nginx + PM2 生产部署全流程(附踩坑总结)
前端·pm2·next.js
zach13 天前
Vue/React SPA 打包部署后,子路由刷新 401 未登录问题彻底解决
前端·nginx·next.js
FungLeo15 天前
成为全栈·Next.js 网站前台篇·数据获取与缓存:60 秒再验证背后到底发生了什么
react·isr·next.js·成为全栈·react cache·opennext