🙋 数据请求慢导致整个页面白屏?Streaming 可以让页面逐步显示,大幅提升用户体验。
什么是 Streaming#
传统 SSR 需要等待所有数据准备好才能发送 HTML。Streaming 允许将页面分块发送,先显示已准备好的部分。
传统 SSR:请求 → [等待数据1] → [等待数据2] → [等待数据3] → 发送完整HTML │ ↓ 用户看到页面
Streaming:请求 → [发送骨架] → [数据1准备好,发送] → [数据2准备好,发送] │ │ │ ↓ ↓ ↓ 用户看到骨架 部分内容显示 更多内容显示loading.tsx#
基础用法#
app/└── dashboard/ ├── page.tsx └── loading.tsx # 自动包裹 Suspense// Next.js 15.x
export default function Loading() { return ( <div className="animate-pulse space-y-4"> <div className="h-8 bg-gray-200 rounded w-1/3"></div> <div className="h-4 bg-gray-200 rounded w-full"></div> <div className="h-4 bg-gray-200 rounded w-2/3"></div> </div> )}// Next.js 15.x
async function getSlowData() { await new Promise((resolve) => setTimeout(resolve, 2000)) return { message: '数据加载完成' }}
export default async function DashboardPage() { const data = await getSlowData()
return <div>{data.message}</div>}🎯 效果:用户立即看到 loading 状态,2 秒后显示实际内容。
骨架屏设计#
// Next.js 15.x
export default function ProductsLoading() { return ( <div className="grid grid-cols-4 gap-4"> {Array.from({ length: 8 }).map((_, i) => ( <div key={i} className="animate-pulse"> {/* 图片骨架 */} <div className="aspect-square bg-gray-200 rounded-lg mb-2" /> {/* 标题骨架 */} <div className="h-4 bg-gray-200 rounded w-3/4 mb-1" /> {/* 价格骨架 */} <div className="h-4 bg-gray-200 rounded w-1/2" /> </div> ))} </div> )}Suspense 边界#
手动控制加载边界#
// Next.js 15.ximport { Suspense } from 'react'
async function SlowStats() { await new Promise((r) => setTimeout(r, 2000)) return <div>统计数据</div>}
async function SlowChart() { await new Promise((r) => setTimeout(r, 3000)) return <div>图表</div>}
async function SlowTable() { await new Promise((r) => setTimeout(r, 1500)) return <div>表格</div>}
export default function DashboardPage() { return ( <div className="grid grid-cols-2 gap-4"> {/* 每个组件独立加载 */} <Suspense fallback={<div className="h-32 bg-gray-100 animate-pulse" />}> <SlowStats /> </Suspense>
<Suspense fallback={<div className="h-32 bg-gray-100 animate-pulse" />}> <SlowChart /> </Suspense>
<div className="col-span-2"> <Suspense fallback={<div className="h-64 bg-gray-100 animate-pulse" />}> <SlowTable /> </Suspense> </div> </div> )}🎯 效果:三个组件并行加载,各自显示各自的骨架,谁先完成谁先显示。
嵌套 Suspense#
// Next.js 15.ximport { Suspense } from 'react'
export default function Page() { return ( <Suspense fallback={<PageSkeleton />}> <Header /> <main> <Suspense fallback={<SidebarSkeleton />}> <Sidebar /> </Suspense> <Suspense fallback={<ContentSkeleton />}> <MainContent /> </Suspense> </main> </Suspense> )}
function PageSkeleton() { return <div>整页加载中...</div>}
function SidebarSkeleton() { return <div className="w-64 h-screen bg-gray-100 animate-pulse" />}
function ContentSkeleton() { return <div className="flex-1 h-screen bg-gray-50 animate-pulse" />}实战模式#
1. 即时导航反馈#
// Next.js 15.ximport { Suspense } from 'react'
async function getPost(slug: string) { const res = await fetch(`https://api.example.com/posts/${slug}`) return res.json()}
async function getComments(slug: string) { // 评论加载慢一些 await new Promise((r) => setTimeout(r, 1000)) const res = await fetch(`https://api.example.com/posts/${slug}/comments`) return res.json()}
async function PostContent({ slug }: { slug: string }) { const post = await getPost(slug) return ( <article> <h1 className="text-3xl font-bold">{post.title}</h1> <div dangerouslySetInnerHTML={{ __html: post.content }} /> </article> )}
async function Comments({ slug }: { slug: string }) { const comments = await getComments(slug) return ( <section> <h2 className="text-xl font-bold mb-4">评论 ({comments.length})</h2> <ul className="space-y-4"> {comments.map((comment: any) => ( <li key={comment.id} className="border-b pb-4"> <p className="font-medium">{comment.author}</p> <p>{comment.content}</p> </li> ))} </ul> </section> )}
export default async function BlogPost({ params,}: { params: Promise<{ slug: string }>}) { const { slug } = await params
return ( <div className="max-w-3xl mx-auto py-8"> {/* 文章内容 */} <Suspense fallback={<ArticleSkeleton />}> <PostContent slug={slug} /> </Suspense>
{/* 评论区独立加载 */} <div className="mt-12"> <Suspense fallback={<CommentsSkeleton />}> <Comments slug={slug} /> </Suspense> </div> </div> )}
function ArticleSkeleton() { return ( <div className="animate-pulse"> <div className="h-10 bg-gray-200 rounded w-3/4 mb-4" /> <div className="space-y-2"> <div className="h-4 bg-gray-200 rounded" /> <div className="h-4 bg-gray-200 rounded" /> <div className="h-4 bg-gray-200 rounded w-5/6" /> </div> </div> )}
function CommentsSkeleton() { return ( <div className="animate-pulse"> <div className="h-6 bg-gray-200 rounded w-32 mb-4" /> <div className="space-y-4"> {[1, 2, 3].map((i) => ( <div key={i} className="border-b pb-4"> <div className="h-4 bg-gray-200 rounded w-24 mb-2" /> <div className="h-4 bg-gray-200 rounded w-full" /> </div> ))} </div> </div> )}2. 列表与详情#
// Next.js 15.ximport { Suspense } from 'react'import Link from 'next/link'
async function UserList() { const res = await fetch('https://api.example.com/users') const users = await res.json()
return ( <ul className="divide-y"> {users.map((user: any) => ( <li key={user.id} className="py-4"> <Link href={`/users/${user.id}`} className="hover:text-blue-600"> {user.name} </Link> </li> ))} </ul> )}
async function UserStats() { await new Promise((r) => setTimeout(r, 1500)) const res = await fetch('https://api.example.com/users/stats') const stats = await res.json()
return ( <div className="bg-blue-50 p-4 rounded"> <p>总用户: {stats.total}</p> <p>活跃用户: {stats.active}</p> </div> )}
export default function UsersPage() { return ( <div className="grid grid-cols-3 gap-8"> <div className="col-span-2"> <h1 className="text-2xl font-bold mb-4">用户列表</h1> <Suspense fallback={<ListSkeleton />}> <UserList /> </Suspense> </div>
<div> <h2 className="text-xl font-bold mb-4">统计</h2> <Suspense fallback={<StatsSkeleton />}> <UserStats /> </Suspense> </div> </div> )}
function ListSkeleton() { return ( <div className="animate-pulse divide-y"> {[1, 2, 3, 4, 5].map((i) => ( <div key={i} className="h-12 flex items-center"> <div className="h-4 bg-gray-200 rounded w-1/3" /> </div> ))} </div> )}
function StatsSkeleton() { return <div className="animate-pulse bg-gray-100 p-4 rounded h-24" />}3. 渐进式表单#
// Next.js 15.ximport { Suspense } from 'react'
async function UserProfile() { const res = await fetch('https://api.example.com/me/profile') const profile = await res.json()
return ( <form className="space-y-4"> <div> <label className="block font-medium">姓名</label> <input defaultValue={profile.name} className="border rounded px-3 py-2 w-full" /> </div> <div> <label className="block font-medium">邮箱</label> <input defaultValue={profile.email} className="border rounded px-3 py-2 w-full" /> </div> <button className="bg-blue-600 text-white px-4 py-2 rounded">保存</button> </form> )}
async function Preferences() { await new Promise((r) => setTimeout(r, 1000)) const res = await fetch('https://api.example.com/me/preferences') const prefs = await res.json()
return ( <div className="space-y-4"> <label className="flex items-center gap-2"> <input type="checkbox" defaultChecked={prefs.notifications} /> 接收通知 </label> <label className="flex items-center gap-2"> <input type="checkbox" defaultChecked={prefs.newsletter} /> 订阅邮件 </label> </div> )}
export default function SettingsPage() { return ( <div className="max-w-xl space-y-8"> <section> <h2 className="text-xl font-bold mb-4">个人资料</h2> <Suspense fallback={<FormSkeleton />}> <UserProfile /> </Suspense> </section>
<section> <h2 className="text-xl font-bold mb-4">偏好设置</h2> <Suspense fallback={<PrefsSkeleton />}> <Preferences /> </Suspense> </section> </div> )}
function FormSkeleton() { return ( <div className="animate-pulse space-y-4"> <div> <div className="h-4 bg-gray-200 rounded w-16 mb-2" /> <div className="h-10 bg-gray-200 rounded" /> </div> <div> <div className="h-4 bg-gray-200 rounded w-16 mb-2" /> <div className="h-10 bg-gray-200 rounded" /> </div> <div className="h-10 bg-gray-200 rounded w-20" /> </div> )}
function PrefsSkeleton() { return ( <div className="animate-pulse space-y-4"> <div className="h-6 bg-gray-200 rounded w-32" /> <div className="h-6 bg-gray-200 rounded w-28" /> </div> )}骨架屏组件库#
// Next.js 15.x
interface SkeletonProps { className?: string}
export function Skeleton({ className }: SkeletonProps) { return <div className={`animate-pulse bg-gray-200 rounded ${className}`} />}
export function SkeletonText({ lines = 3 }: { lines?: number }) { return ( <div className="space-y-2"> {Array.from({ length: lines }).map((_, i) => ( <Skeleton key={i} className={`h-4 ${i === lines - 1 ? 'w-2/3' : 'w-full'}`} /> ))} </div> )}
export function SkeletonCard() { return ( <div className="border rounded-lg p-4 space-y-3"> <Skeleton className="h-48 w-full" /> <Skeleton className="h-4 w-3/4" /> <Skeleton className="h-4 w-1/2" /> </div> )}
export function SkeletonTable({ rows = 5 }: { rows?: number }) { return ( <div className="space-y-2"> <div className="flex gap-4 p-2 bg-gray-50"> <Skeleton className="h-4 w-1/4" /> <Skeleton className="h-4 w-1/4" /> <Skeleton className="h-4 w-1/4" /> <Skeleton className="h-4 w-1/4" /> </div> {Array.from({ length: rows }).map((_, i) => ( <div key={i} className="flex gap-4 p-2"> <Skeleton className="h-4 w-1/4" /> <Skeleton className="h-4 w-1/4" /> <Skeleton className="h-4 w-1/4" /> <Skeleton className="h-4 w-1/4" /> </div> ))} </div> )}常见问题#
🤔 Q: loading.tsx 和 Suspense 有什么区别?
loading.tsx是整个路由段的加载状态Suspense可以精细控制任意组件的加载边界
🤔 Q: 如何避免瀑布流请求?
将请求并行化:
// ❌ 瀑布流const user = await getUser()const posts = await getPosts()
// ✅ 并行const [user, posts] = await Promise.all([getUser(), getPosts()])🤔 Q: 骨架屏应该和实际内容一样吗?
是的,保持布局一致可以避免内容跳动,提供更好的用户体验。
下一篇将介绍静态与动态渲染,深入理解 SSG、SSR、ISR 的选择与配置。
-EOF-