Next.js: 流式处理与 Streaming

最后更新:2026-08-26

流式渲染让用户不再盯着白屏等待——页面一边生成一边展示,首字节时间(TTFB)降低 60%。

1. 你将学到


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) 页面的三种加载层

100%
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
100%
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) 嵌套策略

100%
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 中 paramssearchParams 都是 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 流式架构

100%
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 预先生成静态部分,动态部分流式渲染——两者完美互补。

📖 小节


📝 作业

  1. 基础题(⭐):在一个页面中实现 3 个 Suspense 边界,分别加载用户列表、项目统计和活动日志,每个边界有不同的延迟(500ms / 1000ms / 1500ms)。

  2. 进阶题(⭐⭐):使用 AI SDK 的 streamText 创建一个翻译助手 API Route,客户端用 useChat 实现逐字展示翻译结果的打字机效果。

  3. 挑战题(⭐⭐⭐):实现一个多级嵌套的 Dashboard 页面:外层 loading.tsx 展示全页骨架屏,页面内部再嵌套 3 层 Suspense(统计卡 → 图表 → 详细列表),最后一层使用 use() Hook 实现点击加载更多数据。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏