Skip to content

Streaming 与 Suspense

🙋 数据请求慢导致整个页面白屏?Streaming 可以让页面逐步显示,大幅提升用户体验。

什么是 Streaming#

传统 SSR 需要等待所有数据准备好才能发送 HTML。Streaming 允许将页面分块发送,先显示已准备好的部分。

传统 SSR:
请求 → [等待数据1] → [等待数据2] → [等待数据3] → 发送完整HTML
用户看到页面
Streaming:
请求 → [发送骨架] → [数据1准备好,发送] → [数据2准备好,发送]
│ │ │
↓ ↓ ↓
用户看到骨架 部分内容显示 更多内容显示

loading.tsx#

基础用法#

app/
└── dashboard/
├── page.tsx
└── loading.tsx # 自动包裹 Suspense
app/dashboard/loading.tsx
// 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>
)
}
app/dashboard/page.tsx
// 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 秒后显示实际内容。

骨架屏设计#

app/products/loading.tsx
// 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 边界#

手动控制加载边界#

app/dashboard/page.tsx
// Next.js 15.x
import { 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#

app/page.tsx
// Next.js 15.x
import { 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. 即时导航反馈#

app/blog/[slug]/page.tsx
// Next.js 15.x
import { 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. 列表与详情#

app/users/page.tsx
// Next.js 15.x
import { 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. 渐进式表单#

app/settings/page.tsx
// Next.js 15.x
import { 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>
)
}

骨架屏组件库#

components/Skeleton.tsx
// 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 有什么区别?

🤔 Q: 如何避免瀑布流请求?

将请求并行化:

// ❌ 瀑布流
const user = await getUser()
const posts = await getPosts()
// ✅ 并行
const [user, posts] = await Promise.all([getUser(), getPosts()])

🤔 Q: 骨架屏应该和实际内容一样吗?

是的,保持布局一致可以避免内容跳动,提供更好的用户体验。


下一篇将介绍静态与动态渲染,深入理解 SSG、SSR、ISR 的选择与配置。

-EOF-