Next.js: 流式处理与 Streaming
最后更新:2026-08-26
流式渲染让用户不再盯着白屏等待——页面一边生成一边展示,首字节时间(TTFB)降低 60%。
1. 你将学到
- Suspense 边界拆分策略(页面 → 骨架屏 → 内容)
loading.tsx文件约定与自动 Suspense 包裹- 流式加载顺序与嵌套 Suspense fallback 设计
- React 19
use()Hook 在组件中消费 Promise - AI SDK
streamText实现打字机效果文本生成 - 流式渲染与 PPR 静态壳的组合模式
2. 一个前端架构师的真实故事
(1) 痛点:请求瀑布流让页面加载慢 3 倍
Charlie 是 TaskFlow 团队的前端架构师。他发现 Dashboard 页面加载需要 4.2 秒:
"页面包含 5 个数据卡片、1 个活动列表和 1 个统计图表。所有数据在页面组件里串行
await,一个慢 API 拖垮整个页面——用户盯着空白屏幕干等。"
| 问题 | 耗时 | 原因 |
|---|---|---|
| 用户列表 (200ms) + 项目数 (300ms) | 500ms | 串行等待 |
| 统计图表 (800ms) | 800ms | 后端计算慢 |
| 活动流 (400ms) | 400ms | 跨服务查询 |
| 合计 TTFP | 1,700ms | 全部串行 |
(2) Streaming + Suspense 的解法
把页面拆成多个 Suspense 边界,每个数据块独立流式加载。
TSX
export default function DashboardPage() {
return (
<div>
<h1>总览</h1>
<Suspense fallback={<SkeletonCards />}>
<UserCards />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<AnalyticsChart />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<ActivityFeed />
</Suspense>
</div>
)
}
(3) 收益
| 维度 | 优化前(串行) | 优化后(流式) |
|---|---|---|
| 首字节时间 | 1,700ms | 50ms(静态壳) |
| 首屏可交互 | 4.2s | 1.1s |
| 大块阻塞 | 全部 | ✅ 无 |
| 用户体验 | 白屏 4s | 骨架屏 → 逐块填充 |
3. Suspense 边界拆分策略
(1) 页面的三种加载层
graph TB
A[页面] --> B[层1: 静态壳<br/>layout + header<br/>即时展示]
A --> C[层2: 骨架屏<br/>loading.tsx<br/>~200ms]
A --> D[层3: 内容<br/>Suspense 边界<br/>逐块流式到达]
B --> E[用户看到页面结构]
C --> F[用户看到占位动画]
D --> G[内容依次填充]
style B fill:#d4edda
style C fill:#fff3cd
style D fill:#cce5ff
| 层 | 机制 | 展示时间 | 用户感知 |
|---|---|---|---|
| 静态壳 | Layout | 即时 | 页面框架 |
| 骨架屏 | loading.tsx |
~200ms | 加载动画 |
| 内容 | Suspense + fallback | 逐块到达 | 渐进填充 |
(2) 串行 vs 并行数据加载
TSX
// ❌ 串行瀑布流 — 慢
export default async function SlowPage() {
const users = await fetch('https://api.example.com/users').then(r => r.json())
const projects = await fetch('https://api.example.com/projects').then(r => r.json())
const analytics = await fetch('https://api.example.com/analytics').then(r => r.json())
return <Dashboard users={users} projects={projects} analytics={analytics} />
}
// ✅ 并行 Suspense — 快
export default function FastPage() {
return (
<div>
<Suspense fallback={<SkeletonCards />}><UserCards /></Suspense>
<Suspense fallback={<SkeletonCards />}><ProjectCards /></Suspense>
<Suspense fallback={<ChartSkeleton />}><AnalyticsChart /></Suspense>
</div>
)
}
▶ 示例:Suspense 组件实现
TSX
// components/UserCards.tsx — 独立的 Suspense 边界
export default async function UserCards() {
// 模拟慢查询
const users = await new Promise<{ name: string; email: string }[]>((resolve) =>
setTimeout(() => resolve([
{ name: 'Alice', email: 'alice@taskflow.io' },
{ name: 'Bob', email: 'bob@taskflow.io' },
{ name: 'Charlie', email: 'charlie@taskflow.io' }
]), 2000)
)
return (
<div style={{ display: 'flex', gap: '1rem' }}>
{users.map((u) => (
<div key={u.email} style={{ border: '1px solid #ccc', padding: '1rem', borderRadius: 8 }}>
<h3>{u.name}</h3>
<p>{u.email}</p>
</div>
))}
</div>
)
}
TSX
// components/SkeletonCards.tsx — 骨架屏 fallback
export default function SkeletonCards() {
return (
<div style={{ display: 'flex', gap: '1rem' }}>
{[1, 2, 3].map((i) => (
<div key={i} style={{
width: 200, height: 100, borderRadius: 8,
background: 'linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)',
backgroundSize: '200% 100%',
animation: 'shimmer 1.5s infinite'
}} />
))}
</div>
)
}
4. loading.tsx 与自动 Suspense
(1) 文件约定
| 文件 | 作用 | Suspense 范围 |
|---|---|---|
app/dashboard/loading.tsx |
包裹整个页面路由 | page.tsx 所有内容 |
app/dashboard/settings/loading.tsx |
仅 settings 段 | settings/page.tsx |
graph TB
A[app/dashboard/] --> B[layout.tsx<br/>根布局]
A --> C[loading.tsx<br/>页面级 Suspense]
A --> D[page.tsx<br/>页面内容]
D --> E{页面内部}
E --> F[<Suspense><SlowWidget/></Suspense>]
E --> G[<Suspense><AnotherWidget/></Suspense>]
C --> H[展示 loading.tsx fallback]
D --> I[展示 page.tsx 内容]
F --> J[独立流式加载]
style C fill:#fff3cd
style F fill:#cce5ff
style G fill:#cce5ff
(2) loading.tsx 设计
TSX
// app/dashboard/loading.tsx — 页面级骨架屏
export default function DashboardLoading() {
return (
<div style={{ padding: '2rem' }}>
<div style={{ height: 32, width: 200, background: '#eee', borderRadius: 4, marginBottom: '2rem' }} />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem' }}>
{[1, 2, 3].map((i) => (
<div key={i} style={{ height: 120, background: '#f5f5f5', borderRadius: 8 }} />
))}
</div>
<div style={{ height: 300, background: '#f5f5f5', borderRadius: 8, marginTop: '2rem' }} />
</div>
)
}
▶ 示例:嵌套 loading.tsx 层级
TEXT
📖 仅展示
app/dashboard/ ← loading.tsx 覆盖整页
├── layout.tsx ← 导航栏(即时展示)
├── loading.tsx ← 页面骨架屏
├── page.tsx ← 仪表盘内容
├── projects/ ← 子路由
│ ├── loading.tsx ← 仅项目列表的骨架屏
│ └── page.tsx ← 项目列表
└── settings/
└── loading.tsx ← 设置的骨架屏
TSX
// app/dashboard/projects/loading.tsx — 仅项目列表的加载态
export default function ProjectsLoading() {
return (
<div>
{[1, 2, 3, 4].map((i) => (
<div key={i} style={{
height: 64, marginBottom: 8, borderRadius: 6,
background: 'linear-gradient(90deg, #e8e8e8 0%, #f5f5f5 50%, #e8e8e8 100%)',
backgroundSize: '200% 100%',
animation: 'shimmer 1.5s ease-in-out infinite'
}} />
))}
</div>
)
}
5. 嵌套 Suspense 与 Fallback 设计
(1) 嵌套策略
graph TB
A[页面] --> B[外层 Suspense<br/>fallback: 页面骨架]
A --> C[内层 Suspense 1<br/>fallback: 卡片骨架]
A --> D[内层 Suspense 2<br/>fallback: 图表骨架]
D --> E[更深层 Suspense<br/>fallback: 微骨架]
B -->|立即展示| F[页面骨架]
C -->|~500ms| G[用户卡片]
D -->|~800ms| H[统计图表]
E -->|~1200ms| I[图表详情]
style B fill:#f8d7da
style C fill:#fff3cd
style D fill:#cce5ff
style E fill:#d4edda
| 嵌套层级 | 建议 Fallback | 展示时间 | 信息密度 |
|---|---|---|---|
| 外层(页面) | 大面积占位块 | 即时 | 低(结构) |
| 中层(组件) | 组件形状骨架 | ~500ms | 中(轮廓) |
| 内层(细节) | 小占位 + 微动画 | ~1200ms | 高(内容) |
▶ 示例:三级嵌套 Suspense
TSX
// app/analytics/page.tsx — 嵌套 Suspense 实战
import { Suspense } from 'react'
function SummarySkeleton() { return <div style={{ height: 100, background: '#eee' }} /> }
function ChartSkeleton() { return <div style={{ height: 300, background: '#f5f5f5' }} /> }
function DetailSkeleton() { return <div style={{ height: 60, background: '#fafafa' }} /> }
export default function AnalyticsPage() {
return (
<div>
<h1>分析报告</h1>
{/* 外层:概要卡片 */}
<Suspense fallback={<SummarySkeleton />}>
<SummaryCards />
</Suspense>
{/* 中层:图表 */}
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
{/* 内层:详情列表 */}
<Suspense fallback={<DetailSkeleton />}>
<TopProjects />
</Suspense>
</div>
)
}
async function SummaryCards() {
await new Promise((r) => setTimeout(r, 500))
return <div>本月收入: $120,000 • 用户数: 15,230 • 项目: 342</div>
}
async function RevenueChart() {
await new Promise((r) => setTimeout(r, 1000))
return <div style={{ height: 300, background: '#e8f4f8' }}>[图表: 月度收入趋势]</div>
}
async function TopProjects() {
await new Promise((r) => setTimeout(r, 1500))
return <div>Top 项目: TaskFlow (45%), WebApp (30%), Mobile (25%)</div>
}
6. React 19 use() Hook 读取 Promise
(1) use() vs await 对比
| 特性 | await(Server Component) |
use()(Client Component) |
|---|---|---|
| 使用位置 | 仅 Server Component | Client Component(含 'use client') |
| 阻塞行为 | 阻塞组件渲染 | 抛出 Promise → Suspense 捕获 |
| 类型签名 | const data = await promise |
const data = use(promise) |
| 重新请求 | 自动(RSC 重新执行) | 需手动触发 |
TSX
// ✅ Server Component: await
async function ServerProfile({ id }: { id: string }) {
const user = await fetch(`https://api.example.com/users/${id}`).then(r => r.json())
return <div>{user.name}</div>
}
// ✅ Client Component: use()
'use client'
import { use } from 'react'
function ClientProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise)
return <div>{user.name}</div>
}
(2) use() 在 Client Component 中的流式用法
TSX
// components/StreamingProfile.tsx — use() + Suspense
'use client'
import { use } from 'react'
interface User { name: string; email: string; bio: string }
function UserProfile({ promise }: { promise: Promise<User> }) {
const user = use(promise)
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
<p>{user.bio}</p>
</div>
)
}
// 在页面中使用
import { Suspense } from 'react'
export default function ProfilePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params) // params 也是 Promise
const userPromise = fetch(`https://api.example.com/users/${id}`).then(r => r.json())
return (
<Suspense fallback={<div>加载用户资料...</div>}>
<UserProfile promise={userPromise} />
</Suspense>
)
}
💡 提示: Next.js 16 中
params 和 searchParams 都是 Promise,必须用 await(RSC)或 use()(Client)解包。
▶ 示例:use() 实现无限滚动流
TSX
// components/InfinitePosts.tsx
'use client'
import { use, useState, useTransition } from 'react'
interface Post { id: number; title: string }
async function fetchPosts(page: number): Promise<Post[]> {
const res = await fetch(`/api/posts?page=${page}&limit=10`)
return res.json()
}
export default function InfinitePosts({ initialPromise }: { initialPromise: Promise<Post[]> }) {
const [page, setPage] = useState(1)
const [postsPromise, setPostsPromise] = useState(initialPromise)
const [isPending, startTransition] = useTransition()
const posts = use(postsPromise)
const loadMore = () => {
startTransition(() => {
setPage((p) => p + 1)
setPostsPromise(fetchPosts(page + 1))
})
}
return (
<div>
{posts.map((post) => <div key={post.id}>{post.title}</div>)}
<button onClick={loadMore} disabled={isPending}>
{isPending ? '加载中...' : '加载更多'}
</button>
</div>
)
}
7. AI SDK StreamText 集成
(1) streamText 流式架构
graph LR
A[用户消息] --> B[Route Handler<br/>POST /api/chat]
B --> C[AI SDK streamText]
C --> D[LLM Provider<br/>OpenAI / Anthropic]
D -->|流式 Token| E[ReadableStream]
E --> F[Client Component<br/>useChat Hook]
F --> G[打字机效果]
style B fill:#cce5ff
style C fill:#d4edda
style F fill:#fff3cd
| 组件 | 作用 | 安装 |
|---|---|---|
ai 核心库 |
streamText 函数 |
npm install ai |
@ai-sdk/openai |
OpenAI Provider | npm install @ai-sdk/openai |
useChat |
Client Hook | 内置在 ai 包中 |
(2) 服务端流式路由
TS
// app/api/chat/route.ts — AI 流式聊天 API
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'
export async function POST(req: Request) {
const { messages } = await req.json()
const result = streamText({
model: openai('gpt-4o'),
system: '你是 TaskFlow 的 AI 助手,回答项目管理相关的问题。',
messages
})
return result.toDataStreamResponse()
}
▶ 示例:Client 端打字机效果
TSX
// components/ChatBox.tsx
'use client'
import { useChat } from 'ai/react'
export default function ChatBox() {
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat()
return (
<div style={{ maxWidth: 600, margin: '0 auto' }}>
<div style={{ height: 400, overflowY: 'auto', border: '1px solid #ccc', padding: '1rem' }}>
{messages.map((m) => (
<div key={m.id} style={{
textAlign: m.role === 'user' ? 'right' : 'left',
marginBottom: '1rem'
}}>
<strong>{m.role === 'user' ? 'You' : 'AI'}:</strong>
<p>{m.content}</p>
</div>
))}
{isLoading && <p>AI 正在输入...</p>}
</div>
<form onSubmit={handleSubmit} style={{ display: 'flex', marginTop: '1rem' }}>
<input
value={input}
onChange={handleInputChange}
placeholder="输入你的问题..."
style={{ flex: 1, padding: '0.5rem' }}
/>
<button type="submit" disabled={isLoading} style={{ padding: '0.5rem 1rem' }}>
发送
</button>
</form>
</div>
)
}
🔥 易错:
useChat 默认 POST /api/chat。如需自定义 API 端点,传入 api 选项:useChat({ api: '/api/ai/chat' })。
8. 完整示例:AI 分析 Dashboard + 流式数据
TSX
// app/dashboard/page.tsx — 流式 Dashboard + AI 分析
import { Suspense } from 'react'
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
// 骨架屏组件
function MetricSkeleton() {
return <div style={{ height: 100, background: '#f0f0f0', borderRadius: 8 }} />
}
function ChartSkeleton() {
return <div style={{ height: 300, background: '#f5f5f5', borderRadius: 8 }} />
}
// 慢数据组件
async function TeamMetrics() {
const metrics = await new Promise<{ members: number; projects: number; tasks: number }>(
(resolve) => setTimeout(() => resolve({ members: 12, projects: 45, tasks: 230 }), 1500)
)
return (
<div style={{ display: 'flex', gap: '1rem' }}>
<div>👥 {metrics.members} 成员</div>
<div>📁 {metrics.projects} 项目</div>
<div>✅ {metrics.tasks} 任务</div>
</div>
)
}
async function ActivityChart() {
const data = await new Promise<number[]>((r) => setTimeout(() => r([30, 45, 78, 92, 55, 88, 120]), 2000))
return (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '0.5rem', height: 200 }}>
{data.map((v, i) => (
<div key={i} style={{ height: v, width: 40, background: '#4f46e5', borderRadius: '4px 4px 0 0' }} />
))}
</div>
)
}
export default async function DashboardPage() {
const session = await auth()
if (!session) redirect('/login')
return (
<div>
<h1>TaskFlow 总览</h1>
<p>欢迎回来,{session.user!.name}</p>
<Suspense fallback={<MetricSkeleton />}>
<TeamMetrics />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<ActivityChart />
</Suspense>
</div>
)
}
TS
// app/api/chat/route.ts — AI 分析助手
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'
export async function POST(req: Request) {
const { messages } = await req.json()
const result = streamText({
model: openai('gpt-4o-mini'),
system: '你是一个项目管理 AI 助手。根据提供的项目数据,给出分析和建议。回答要简洁。',
messages
})
return result.toDataStreamResponse()
}
TSX
// components/ChatPanel.tsx
'use client'
import { useChat } from 'ai/react'
export default function ChatPanel() {
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat()
return (
<div style={{ position: 'fixed', bottom: 0, right: 20, width: 380, border: '1px solid #ccc', borderRadius: '8px 8px 0 0' }}>
<div style={{ padding: '0.5rem 1rem', background: '#4f46e5', color: '#fff', borderRadius: '8px 8px 0 0' }}>
AI 分析助手
</div>
<div style={{ height: 300, overflowY: 'auto', padding: '0.5rem' }}>
{messages.map((m) => (
<div key={m.id} style={{ marginBottom: '0.5rem' }}>
<strong>{m.role === 'user' ? '我' : 'AI'}:</strong>
<p style={{ margin: 0 }}>{m.content}</p>
</div>
))}
</div>
<form onSubmit={handleSubmit} style={{ display: 'flex', borderTop: '1px solid #eee' }}>
<input value={input} onChange={handleInputChange} placeholder="问关于项目的问题..." style={{ flex: 1, padding: '0.5rem', border: 'none' }} />
<button type="submit" disabled={isLoading} style={{ padding: '0.5rem 1rem', background: '#4f46e5', color: '#fff', border: 'none' }}>发送</button>
</form>
</div>
)
}
💻 效果描述: 页面先展示骨架屏 → 0.5s 后指标卡片填充 → 1s 后柱状图出现 → AI 面板在右侧随时对话,内容逐字流式展示。
❓ 常见问题
Q Suspense 和 loading.tsx 有什么不同?
A
loading.tsx 是文件约定,自动为整页创建 Suspense 边界,实现简单。<Suspense> 组件用于页面内部精细控制,可以包裹任意多个独立数据块并行流式加载。Q 流式渲染会影响 SEO 吗?
A 不会。搜索引擎爬虫(Googlebot)等待最终 HTML 完成后再索引,流式内容是渐进式填充而非延迟注入。Next.js 的 RSC Payload 保证爬虫看到完整内容。
Q
use() Hook 什么时候比 await 更好?A
use() 可以在 Client Component 中使用,让组件自身声明对 Promise 的依赖,由最近的 Suspense 边界处理加载态。适用于需要在前端触发异步操作的场景(如点击加载更多)。Q AI SDK streamText 和直接调用 OpenAI API 有什么区别?
A
streamText 自动处理 SSE(Server-Sent Events)协议、背压控制、Token 计数和错误重试。直接调用 OpenAI API 需要手动处理 ReadableStream 和响应格式。Q 流式渲染和 PPR(Partial Prerendering)如何配合?
A PPR 的静态壳(static shell)就是外层布局 + 静态内容,内部动态部分用
<Suspense> 包裹。PPR 预先生成静态部分,动态部分流式渲染——两者完美互补。📖 小节
- Suspense 边界将页面拆分为独立流式块,避免串行请求阻塞整页
loading.tsx文件约定自动为路由页创建 Suspense,fallback 展示骨架屏- 嵌套 Suspense 策略:外层大骨架(即时)→ 中层轮廓(~500ms)→ 内层细节(~1200ms)
- React 19
use()Hook 在 Client Component 中消费 Promise,配合 Suspense 使用 - AI SDK
streamText+useChat实现打字机效果的流式 AI 对话 - 流式渲染与 PPR 静态壳组合:预渲染静态部分 + 流式加载动态部分
📝 作业
-
基础题(⭐):在一个页面中实现 3 个 Suspense 边界,分别加载用户列表、项目统计和活动日志,每个边界有不同的延迟(500ms / 1000ms / 1500ms)。
-
进阶题(⭐⭐):使用 AI SDK 的
streamText创建一个翻译助手 API Route,客户端用useChat实现逐字展示翻译结果的打字机效果。 -
挑战题(⭐⭐⭐):实现一个多级嵌套的 Dashboard 页面:外层
loading.tsx展示全页骨架屏,页面内部再嵌套 3 层 Suspense(统计卡 → 图表 → 详细列表),最后一层使用use()Hook 实现点击加载更多数据。