Skip to content

Next.js 流式渲染 🌊

🎯 概述

流式渲染(Streaming)允许服务器逐步发送 HTML 内容到客户端,而不是等待整个页面渲染完成。这大大改善了首屏加载时间和用户体验。

📦 什么是流式渲染?

工作原理

  1. 服务器开始渲染页面
  2. 立即发送已渲染的部分
  3. 继续渲染剩余内容
  4. 逐步发送到客户端
  5. 客户端逐步显示内容

优势

  • ⚡ 更快的首字节时间 (TTFB)
  • 👀 更快的首次内容绘制 (FCP)
  • 🎯 渐进式页面加载
  • 💪 更好的用户体验
  • 📱 适合慢速网络

🚀 使用 Suspense

基本用法

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

async function SlowComponent() {
  await new Promise(resolve => setTimeout(resolve, 3000))
  return <div>慢速内容</div>
}

export default function Page() {
  return (
    <div>
      <h1>快速内容</h1>
      <Suspense fallback={<div>加载中...</div>}>
        <SlowComponent />
      </Suspense>
    </div>
  )
}

多个 Suspense 边界

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

async function UserInfo() {
  const user = await fetchUser()
  return <div>{user.name}</div>
}

async function Posts() {
  const posts = await fetchPosts()
  return <div>{posts.length} 篇文章</div>
}

async function Analytics() {
  const data = await fetchAnalytics()
  return <div>{data.views} 次浏览</div>
}

export default function Dashboard() {
  return (
    <div>
      <h1>仪表板</h1>
      
      <Suspense fallback={<div>加载用户信息...</div>}>
        <UserInfo />
      </Suspense>
      
      <Suspense fallback={<div>加载文章...</div>}>
        <Posts />
      </Suspense>
      
      <Suspense fallback={<div>加载统计...</div>}>
        <Analytics />
      </Suspense>
    </div>
  )
}

📝 嵌套 Suspense

层级加载

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

async function BlogList() {
  const posts = await fetchPosts()
  
  return (
    <div>
      {posts.map(post => (
        <Suspense key={post.id} fallback={<PostSkeleton />}>
          <BlogPost id={post.id} />
        </Suspense>
      ))}
    </div>
  )
}

async function BlogPost({ id }) {
  const post = await fetchPost(id)
  return (
    <article>
      <h2>{post.title}</h2>
      <Suspense fallback={<div>加载评论...</div>}>
        <Comments postId={id} />
      </Suspense>
    </article>
  )
}

async function Comments({ postId }) {
  const comments = await fetchComments(postId)
  return <div>{comments.length} 条评论</div>
}

export default function BlogPage() {
  return (
    <Suspense fallback={<div>加载博客列表...</div>}>
      <BlogList />
    </Suspense>
  )
}

🎨 加载骨架屏

创建骨架组件

tsx
// components/Skeleton.tsx
export function PostSkeleton() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-3/4 mb-4"></div>
      <div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
      <div className="h-4 bg-gray-200 rounded w-5/6"></div>
    </div>
  )
}

export function UserSkeleton() {
  return (
    <div className="animate-pulse flex items-center">
      <div className="h-12 w-12 bg-gray-200 rounded-full"></div>
      <div className="ml-4">
        <div className="h-4 bg-gray-200 rounded w-24 mb-2"></div>
        <div className="h-3 bg-gray-200 rounded w-32"></div>
      </div>
    </div>
  )
}

使用骨架屏

tsx
// app/profile/page.tsx
import { Suspense } from 'react'
import { UserSkeleton, PostSkeleton } from '@/components/Skeleton'

async function UserProfile() {
  const user = await fetchUser()
  return (
    <div>
      <img src={user.avatar} alt={user.name} />
      <h2>{user.name}</h2>
      <p>{user.bio}</p>
    </div>
  )
}

async function UserPosts() {
  const posts = await fetchUserPosts()
  return (
    <div>
      {posts.map(post => (
        <article key={post.id}>{post.title}</article>
      ))}
    </div>
  )
}

export default function ProfilePage() {
  return (
    <div>
      <Suspense fallback={<UserSkeleton />}>
        <UserProfile />
      </Suspense>
      
      <Suspense fallback={<PostSkeleton />}>
        <UserPosts />
      </Suspense>
    </div>
  )
}

🔄 loading.tsx 文件

路由级加载状态

tsx
// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="flex items-center justify-center min-h-screen">
      <div className="animate-spin rounded-full h-32 w-32 border-b-2 border-gray-900"></div>
    </div>
  )
}

// app/dashboard/page.tsx
async function getData() {
  const res = await fetch('https://api.example.com/data')
  return res.json()
}

export default async function Dashboard() {
  const data = await getData()
  return <div>{data.title}</div>
}

嵌套加载状态

tsx
// app/blog/loading.tsx
export default function BlogLoading() {
  return <div>加载博客...</div>
}

// app/blog/[slug]/loading.tsx
export default function PostLoading() {
  return <div>加载文章...</div>
}

📊 实战示例

电商产品页面

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

async function ProductInfo({ id }) {
  const product = await fetchProduct(id)
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.price}元</p>
      <p>{product.description}</p>
    </div>
  )
}

async function ProductImages({ id }) {
  const images = await fetchProductImages(id)
  return (
    <div className="grid grid-cols-2 gap-4">
      {images.map(img => (
        <img key={img.id} src={img.url} alt="" />
      ))}
    </div>
  )
}

async function ProductReviews({ id }) {
  const reviews = await fetchReviews(id)
  return (
    <div>
      <h2>用户评价</h2>
      {reviews.map(review => (
        <div key={review.id}>
          <p>{review.rating} 星</p>
          <p>{review.comment}</p>
        </div>
      ))}
    </div>
  )
}

async function RelatedProducts({ id }) {
  const products = await fetchRelatedProducts(id)
  return (
    <div>
      <h2>相关产品</h2>
      <div className="grid grid-cols-4 gap-4">
        {products.map(p => (
          <div key={p.id}>{p.name}</div>
        ))}
      </div>
    </div>
  )
}

export default function ProductPage({ params }) {
  return (
    <div>
      <Suspense fallback={<ProductInfoSkeleton />}>
        <ProductInfo id={params.id} />
      </Suspense>
      
      <Suspense fallback={<ImagesSkeleton />}>
        <ProductImages id={params.id} />
      </Suspense>
      
      <Suspense fallback={<div>加载评价...</div>}>
        <ProductReviews id={params.id} />
      </Suspense>
      
      <Suspense fallback={<div>加载相关产品...</div>}>
        <RelatedProducts id={params.id} />
      </Suspense>
    </div>
  )
}

社交媒体动态

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

async function FeedItem({ id }) {
  const post = await fetchPost(id)
  
  return (
    <article>
      <h3>{post.title}</h3>
      <p>{post.content}</p>
      <Suspense fallback={<div>加载评论...</div>}>
        <Comments postId={id} />
      </Suspense>
    </article>
  )
}

async function Comments({ postId }) {
  const comments = await fetchComments(postId)
  return (
    <div>
      {comments.map(comment => (
        <div key={comment.id}>{comment.text}</div>
      ))}
    </div>
  )
}

async function Feed() {
  const postIds = await fetchFeedIds()
  
  return (
    <div>
      {postIds.map(id => (
        <Suspense key={id} fallback={<FeedItemSkeleton />}>
          <FeedItem id={id} />
        </Suspense>
      ))}
    </div>
  )
}

export default function FeedPage() {
  return (
    <div>
      <h1>动态</h1>
      <Suspense fallback={<div>加载动态...</div>}>
        <Feed />
      </Suspense>
    </div>
  )
}

📚 最佳实践

1. 合理划分 Suspense 边界

tsx
// ✅ 推荐:独立的 Suspense 边界
<div>
  <Suspense fallback={<HeaderSkeleton />}>
    <Header />
  </Suspense>
  <Suspense fallback={<ContentSkeleton />}>
    <Content />
  </Suspense>
  <Suspense fallback={<SidebarSkeleton />}>
    <Sidebar />
  </Suspense>
</div>

// ❌ 不推荐:单个 Suspense 包裹所有内容
<Suspense fallback={<PageSkeleton />}>
  <Header />
  <Content />
  <Sidebar />
</Suspense>

2. 优先加载关键内容

tsx
export default function Page() {
  return (
    <div>
      {/* 关键内容:立即渲染 */}
      <h1>页面标题</h1>
      <p>重要描述</p>
      
      {/* 次要内容:延迟加载 */}
      <Suspense fallback={<Skeleton />}>
        <SecondaryContent />
      </Suspense>
    </div>
  )
}

3. 提供有意义的加载状态

tsx
// ✅ 推荐:具体的加载提示
<Suspense fallback={<div>正在加载用户评论...</div>}>
  <Comments />
</Suspense>

// ❌ 不推荐:通用的加载提示
<Suspense fallback={<div>加载中...</div>}>
  <Comments />
</Suspense>

4. 使用骨架屏而非加载动画

tsx
// ✅ 推荐:骨架屏
<Suspense fallback={<ArticleSkeleton />}>
  <Article />
</Suspense>

// ❌ 不推荐:简单的加载动画
<Suspense fallback={<Spinner />}>
  <Article />
</Suspense>

🔗 相关资源


下一步:学习 Next.js 状态管理