Chapter 04

数据获取与缓存

掌握 Next.js 15 全新缓存语义,用 Suspense 构建流畅的流式 UI

4.1 Server Component 数据获取

在 App Router 中,数据获取的推荐方式是直接在 Server Component 中使用 async/await。这消除了 Pages Router 中需要 getServerSidePropsgetStaticProps 特殊函数的复杂性,让数据获取代码与组件 UI 紧密结合,提升了代码的可读性和维护性。

服务端数据获取的优势在于:数据库密钥、API Token 等敏感信息完全在服务端处理,永远不会暴露给客户端;同时可以直接查询数据库而无需额外的网络请求往返,减少了延迟。

// app/posts/page.tsx — 直接 async 获取数据
async function getPosts() {
  // 这段代码只在服务端运行
  const res = await fetch('https://api.example.com/posts')
  if (!res.ok) throw new Error('Failed to fetch')
  return res.json()
}

export default async function PostsPage() {
  const posts = await getPosts() // 直接 await,无 useEffect

  return (
    <ul>
      {posts.map((post: Post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}
TSX

4.2 Next.js 15 的缓存语义变化

Next.js 15 对缓存行为做了重要调整:fetch 请求默认不再缓存(从 force-cache 改为 no-store)。这意味着每次请求都会重新获取数据,行为更接近开发者的直觉预期。如果需要缓存,需要显式声明。

// 不同的缓存策略

// 1. 不缓存(Next.js 15 默认)
const data = await fetch(url, { cache: 'no-store' })

// 2. 永久缓存(静态数据)
const config = await fetch(url, { cache: 'force-cache' })

// 3. 按时间重新验证(ISR,60秒)
const posts = await fetch(url, {
  next: { revalidate: 60 }
})

// 4. 按标签缓存(可手动批量失效)
const posts = await fetch(url, {
  next: { tags: ['posts'] }
})

// 在 Server Action 中手动失效
'use server'
import { revalidateTag, revalidatePath } from 'next/cache'

export async function updatePost(id: string, data: FormData) {
  await db.post.update(...)
  revalidateTag('posts')       // 使所有 posts 标签的缓存失效
  revalidatePath('/blog')     // 使 /blog 页面的缓存失效
}
TS

4.3 unstable_cache — 缓存任意异步函数

unstable_cache 是 Next.js 提供的服务端缓存 API,可以缓存任意异步函数的返回值,不仅限于 fetch 请求。当使用 ORM(如 Prisma)直接查询数据库时,这个 API 尤为重要。

⚠️

命名前缀 "unstable_"虽然带有 "unstable" 前缀,但此 API 已在生产环境广泛使用,前缀表示 API 签名可能在未来版本变化。Next.js 15 同时引入了更完善的 use cache 实验性指令作为替代方案。

import { unstable_cache } from 'next/cache'
import { db } from '@/lib/db'

// 缓存数据库查询结果
const getCachedUser = unstable_cache(
  async (id: string) => {
    // 这个函数的返回值会被缓存
    return db.user.findUnique({ where: { id } })
  },
  ['user'],       // 缓存键前缀(数组)
  {
    revalidate: 3600,              // 1小时后失效
    tags: ['users', `user-${id}`], // 缓存标签
  }
)

// 在 Server Component 中使用
export default async function UserProfile({ params }) {
  const { id } = await params
  const user = await getCachedUser(id)
  return <div>{user?.name}</div>
}
TS

4.4 loading.tsx 与 Suspense 边界

Next.js 的 loading.tsx 文件是对 React Suspense 的约定式封装。当 page.tsx 中的异步数据正在加载时,Next.js 会自动将 loading.tsx 的内容显示给用户,无需手动编写 <Suspense>

这背后的机制是流式传输(Streaming):服务器会先发送页面的静态部分(布局、导航)的 HTML,然后在数据就绪后流式发送动态内容,替换掉 loading UI。用户感受到的是"瞬间看到页面框架,然后内容逐步加载"。

// app/dashboard/loading.tsx — 自动 Suspense fallback
export default function DashboardLoading() {
  return (
    <div className="loading-skeleton">
      <div className="skeleton-header" />
      <div className="skeleton-content" />
    </div>
  )
}
TSX

手动使用 Suspense 实现更细粒度的加载状态

loading.tsx 会将整个页面放入单一 Suspense 边界。如果需要页面不同部分独立加载,应手动使用 <Suspense> 包裹各个数据依赖组件。

import { Suspense } from 'react'
import { PostList, PostListSkeleton } from './components'
import { RecommendedPosts } from './recommendations'

export default function BlogPage() {
  return (
    <div>
      <h1>博客</h1>

      {/* 文章列表独立加载 */}
      <Suspense fallback={<PostListSkeleton />}>
        <PostList />
      </Suspense>

      {/* 推荐文章独立加载,不阻塞主列表 */}
      <Suspense fallback={<p>加载推荐中...</p>}>
        <RecommendedPosts />
      </Suspense>
    </div>
  )
}

// PostList 内部是 async Server Component
async function PostList() {
  const posts = await fetchPosts() // 慢速请求
  return <ul>{posts.map(...)}</ul>
}
TSX

4.5 并行数据获取与 Promise.all

当页面需要多个独立的数据源时,应该并行发起请求,而不是串行等待。使用 Promise.allPromise.allSettled 可以同时发起多个请求,总时间等于最慢请求的时间,而非所有请求时间之和。

// ❌ 串行获取 — 总时间 = 200ms + 300ms = 500ms
async function BadPage() {
  const user    = await fetchUser()    // 200ms
  const posts   = await fetchPosts()   // 300ms(等 user 完成才开始)
  return /* ... */
}

// ✅ 并行获取 — 总时间 ≈ max(200ms, 300ms) = 300ms
async function GoodPage() {
  const [user, posts] = await Promise.all([
    fetchUser(),
    fetchPosts(),
  ])
  return /* ... */
}

// ✅ 不需要等 posts 再渲染 user 时,用"预加载"模式
async function BestPage() {
  // 立即发起请求,不 await(让它在后台进行)
  const postsPromise = fetchPosts()

  // await user(很快)
  const user = await fetchUser()

  // posts 已经在获取中,等待结果
  const posts = await postsPromise

  return /* ... */
}
TSX

4.6 路由段配置 — 控制渲染策略

每个路由段(layout.tsx 或 page.tsx)可以通过导出特殊的配置变量来控制其渲染和缓存行为,这些配置会影响整个路由段的构建时策略。

// app/dashboard/page.tsx

// 强制动态渲染(相当于旧版 getServerSideProps)
export const dynamic = 'force-dynamic'

// 强制静态渲染(相当于旧版 getStaticProps)
// export const dynamic = 'force-static'

// 路由段级别的 ISR(秒)
export const revalidate = 3600

// 使用 Edge Runtime(减少冷启动,限制 Node.js API 使用)
// export const runtime = 'edge'

// 动态路由是否预渲染全部可能的值
export const dynamicParams = false

export default async function Page() {
  return <div>内容</div>
}
TSX
💡

动态函数触发器使用 cookies()headers()searchParams(在 page.tsx 中)或 noStore() 会自动将该路由段标记为动态渲染,无需显式设置 dynamic = 'force-dynamic'。这是 Next.js 的"动态渲染自动检测"机制。