Next.js

数据获取

深入理解 Next.js 数据获取,包括 Server Components、Client Components、流式传输、fetch API 扩展和数据安全最佳实践

Server Components 数据获取

Server Components 可以使用任何异步 I/O 来获取数据,包括:

  1. fetch API
  2. ORM 或数据库
  3. 使用 Node.js API(如 fs)从文件系统读取

使用 fetch API

将组件转换为异步函数,并 await fetch 调用:

export default async function Page() {
  const data = await fetch('https://api.vercel.app/blog')
  const posts = await data.json()
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

fetch 缓存行为

fetch 响应默认不会被缓存。但是,Next.js 会预渲染路由,输出会被缓存以提高性能。如果要选择动态渲染,使用 { cache: 'no-store' } 选项:

const data = await fetch('https://api.vercel.app/blog', {
  cache: 'no-store'
})

fetch 选项

Next.js 扩展了 Web fetch() API,允许每个请求设置自己的持久化缓存和重新验证语义。

options.cache

配置请求如何与 Next.js Data Cache 交互:

fetch('https://...', { cache: 'force-cache' | 'no-store' })
  • auto no cache(默认):在开发环境中每次请求都从远程服务器获取资源,但在 next build 期间只获取一次,因为路由将被静态预渲染
  • force-cache:Next.js 在 Data Cache 中查找匹配的请求
    • 如果有匹配且新鲜,将从缓存返回
    • 如果没有匹配或陈旧,Next.js 将从远程服务器获取资源并使用下载的资源更新缓存
  • no-store:Next.js 在每次请求时从远程服务器获取资源,不查看缓存,也不使用下载的资源更新缓存
options.next.revalidate

设置资源的缓存生命周期(以秒为单位):

fetch('https://...', { next: { revalidate: 3600 } })
  • false:无限期缓存资源,语义上等同于 revalidate: Infinity
  • 0:防止资源被缓存
  • number:指定资源的缓存生命周期应为最多 n 秒
options.next.tags

设置资源的缓存标签,然后可以使用 revalidateTag 按需重新验证数据:

fetch('https://...', { next: { tags: ['collection'] } })

最大标签长度为 256 个字符,最大标签项数为 128。

使用 ORM 或数据库

可以直接在 Server Components 中调用数据库或 ORM:

import { db } from '@/lib/db'

export default async function Page() {
  const users = await db.user.findMany()
  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  )
}

并行和顺序数据获取

顺序数据获取

如果需要一个请求的结果来进行另一个请求,可以按顺序获取数据:

export default async function Page({
  params,
}: {
  params: Promise<{ username: string }>
}) {
  const { username } = await params
  
  // 等待 artist 数据
  const artist = await getArtist(username)
  
  // 使用 artist.id 获取 albums
  const albums = await getAlbums(artist.id)

  return (
    <>
      <h1>{artist.name}</h1>
      <Albums list={albums} />
    </>
  )
}

并行数据获取

为了最小化瀑布流,可以并行获取数据:

export default async function Page({
  params,
}: {
  params: Promise<{ username: string }>
}) {
  const { username } = await params
  
  // 并行启动两个请求
  const artistData = getArtist(username)
  const albumsData = getAlbums(username)

  // 等待两个 Promise 解决
  const [artist, albums] = await Promise.all([artistData, albumsData])

  return (
    <>
      <h1>{artist.name}</h1>
      <Albums list={albums} />
    </>
  )
}

Client Components 数据获取

在 Client Components 中获取数据有两种推荐方式:

使用第三方库

推荐使用 SWRTanStack Query 等库。这些库提供了自己的 API 用于记忆请求、缓存、重新验证和变更数据。

SWR 示例

'use client'

import useSWR from 'swr'

const fetcher = (url: string) => fetch(url).then((r) => r.json())

export default function Page() {
  const { data, error, isLoading } = useSWR(
    'https://api.vercel.app/blog',
    fetcher
  )

  if (isLoading) return <p>Loading...</p>
  if (error) return <p>Failed to load.</p>

  return (
    <ul>
      {data.map((post: any) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

TanStack Query 示例

'use client'

import { useQuery } from '@tanstack/react-query'

export default function Page() {
  const { data, error, isLoading } = useQuery({
    queryKey: ['posts'],
    queryFn: async () => {
      const res = await fetch('https://api.vercel.app/blog')
      return res.json()
    },
  })

  if (isLoading) return <p>Loading...</p>
  if (error) return <p>Failed to load.</p>

  return (
    <ul>
      {data.map((post: any) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

使用 React 的 use hook

React 的 use hook 接受一个 Promise,并在 Promise 解决之前显示 Suspense 后备内容:

'use client'

import { use, Suspense } from 'react'

function Posts({ postsPromise }: { postsPromise: Promise<any[]> }) {
  const posts = use(postsPromise)
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

export default function Page() {
  const postsPromise = fetch('https://api.vercel.app/blog').then((res) =>
    res.json()
  )

  return (
    <Suspense fallback={<p>Loading posts...</p>}>
      <Posts postsPromise={postsPromise} />
    </Suspense>
  )
}

流式传输(Streaming)

流式传输允许你逐步从服务器渲染 UI。工作被分成块,并在准备就绪时流式传输到客户端。这使用户可以在整个内容完成渲染之前立即看到页面的部分内容。

使用 loading.js

loading.js 文件帮助你使用 React Suspense 创建有意义的加载 UI。通过此约定,你可以在路由段的内容流式传输时从服务器显示即时加载状态。新内容在完成后自动交换。

export default function Loading() {
  return <p>Loading...</p>
}

即时加载状态

即时加载状态是在导航时立即显示的后备 UI。你可以预渲染加载指示器,如骨架屏和旋转器。

export default function Loading() {
  return <DashboardSkeleton />
}

使用 Suspense 手动流式传输

除了 loading.js,你还可以为自己的 UI 组件手动创建 Suspense 边界:

import { Suspense } from 'react'
import { PostFeed, Weather } from './Components'

export default function Posts() {
  return (
    <section>
      <Suspense fallback={<p>Loading feed...</p>}>
        <PostFeed />
      </Suspense>
      <Suspense fallback={<p>Loading weather...</p>}>
        <Weather />
      </Suspense>
    </section>
  )
}

使用 Suspense 的好处:

  1. 流式服务器渲染:从服务器到客户端逐步渲染 HTML
  2. 选择性水合:React 根据用户交互优先考虑哪些组件首先变为交互式

使用 use hook 流式传输数据

use hook 可以与 Suspense 结合使用来流式传输数据:

import { Suspense } from 'react'
import { use } from 'react'

async function fetchPosts() {
  const res = await fetch('https://api.vercel.app/blog')
  return res.json()
}

function Posts({ postsPromise }: { postsPromise: Promise<any[]> }) {
  const posts = use(postsPromise)
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

export default function Page() {
  const postsPromise = fetchPosts()

  return (
    <Suspense fallback={<p>Loading posts...</p>}>
      <Posts postsPromise={postsPromise} />
    </Suspense>
  )
}

数据获取模式

在服务器上并行获取数据

为了最小化客户端-服务器瀑布流,建议在服务器上并行获取数据:

import Albums from './albums'

async function getArtist(username: string) {
  const res = await fetch(`https://api.example.com/artist/${username}`)
  return res.json()
}

async function getAlbums(username: string) {
  const res = await fetch(`https://api.example.com/artist/${username}/albums`)
  return res.json()
}

export default async function Page({
  params,
}: {
  params: Promise<{ username: string }>
}) {
  const { username } = await params
  
  const artistData = getArtist(username)
  const albumsData = getAlbums(username)

  const [artist, albums] = await Promise.all([artistData, albumsData])

  return (
    <>
      <h1>{artist.name}</h1>
      <Albums list={albums} />
    </>
  )
}

预加载数据

防止瀑布流的另一种方法是使用预加载模式,创建一个实用函数,你可以在阻塞请求之前急切地调用它:

import { getItem } from '@/utils/get-item'

export async function Item({ id }: { id: string }) {
  const result = await getItem(id)
  return <div>{result.name}</div>
}
import { cache } from 'react'
import 'server-only'

export const preload = (id: string) => {
  void getItem(id)
}

export const getItem = cache(async (id: string) => {
  const res = await fetch(`https://api.example.com/items/${id}`)
  return res.json()
})
import { preload, getItem } from '@/utils/get-item'
import Item from '@/components/Item'

export default async function Page({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  
  // 开始加载项目数据
  preload(id)
  
  // 执行另一个异步任务
  const isAvailable = await checkIsAvailable()

  return isAvailable ? <Item id={id} /> : null
}

使用 React cache 缓存数据

React 的 cache 函数允许你记忆函数的返回值,使你可以多次调用同一个函数但只执行一次:

import { cache } from 'react'
import 'server-only'

export const getItem = cache(async (id: string) => {
  const res = await fetch(`https://api.example.com/items/${id}`)
  return res.json()
})

现在可以在多个组件中调用 getItem,但只会向数据库发出一次请求:

import { getItem } from '@/utils/get-item'

export default async function Layout({
  params,
  children,
}: {
  params: Promise<{ id: string }>
  children: React.ReactNode
}) {
  const { id } = await params
  const item = await getItem(id)
  
  return (
    <>
      <h1>{item.name}</h1>
      {children}
    </>
  )
}
import { getItem } from '@/utils/get-item'

export default async function Page({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  // 这不会发出第二次请求
  const item = await getItem(id)
  
  return <div>{item.description}</div>
}

数据安全

React Server Components 改进了性能并简化了数据获取,但也改变了数据访问的位置和方式,改变了处理前端应用程序数据的一些传统安全假设。

数据获取方法

根据项目的规模和年龄,推荐三种主要的数据获取方法:

1. 外部 HTTP API

适用于现有的大型应用程序和组织。在采用 Server Components 时,应遵循零信任模型。可以继续从 Server Components 调用现有的 API 端点(如 REST 或 GraphQL):

import { cookies } from 'next/headers'

export default async function Page() {
  const cookieStore = await cookies()
  const token = cookieStore.get('AUTH_TOKEN')?.value

  const res = await fetch('https://api.example.com/profile', {
    headers: {
      Cookie: `AUTH_TOKEN=${token}`,
    },
  })

  const data = await res.json()
  return <div>{data.name}</div>
}

这种方法适用于:

  • 已经有安全实践
  • 独立的后端团队使用其他语言或独立管理 API

2. 数据访问层(推荐用于新项目)

数据访问层(DAL)是一个集中式的位置,用于与数据库或其他数据源交互。它提供了一个单一的位置来添加授权检查和数据验证。

import 'server-only'
import { cookies } from 'next/headers'
import { decrypt } from '@/lib/session'
import { db } from '@/lib/db'

export async function verifySession() {
  const cookie = (await cookies()).get('session')?.value
  const session = await decrypt(cookie)

  if (!session?.userId) {
    throw new Error('Unauthorized')
  }

  return { userId: session.userId }
}

export async function getUser() {
  const session = await verifySession()
  
  try {
    const user = await db.user.findUnique({
      where: { id: session.userId },
      select: { id: true, name: true, email: true },
    })
    return user
  } catch (error) {
    console.error('Failed to fetch user')
    throw error
  }
}

在组件中使用:

import { getUser } from '@/lib/dal'

export default async function Page() {
  const user = await getUser()
  return <div>Hello, {user.name}</div>
}

3. 组件级数据访问

适用于原型和学习。可以直接在组件中访问数据,但应该意识到安全风险:

import { db } from '@/lib/db'

export default async function Page() {
  const user = await db.user.findFirst()
  return <div>Hello, {user.name}</div>
}

最佳实践

使用 server-only 包

使用 server-only 包确保服务器端代码不会意外地在客户端运行:

import 'server-only'

export async function getUser() {
  // 服务器端代码
}

避免在 searchParams 中使用敏感操作

不要使用 searchParams 来处理变更操作:

// 错误:使用 searchParams 处理变更
export default async function Page({
  searchParams,
}: {
  searchParams: Promise<{ logout?: string }>
}) {
  const params = await searchParams
  
  if (params.get('logout')) {
    cookies().delete('AUTH_TOKEN')
  }

  return <UserProfile />
}

应该使用 Server Actions 来处理变更:

// 正确:使用 Server Actions 处理变更
import { logout } from './actions'

export default function Page() {
  return (
    <>
      <UserProfile />
      <form action={logout}>
        <button type="submit">Logout</button>
      </form>
    </>
  )
}

类型安全

确保组件 props 的类型不会暴露敏感数据:

// 错误:类型过于宽泛
interface UserProps {
  user: {
    id: string
    name: string
    email: string
    password: string // 不应该传递给客户端
  }
}

// 正确:只包含必要的字段
interface UserProps {
  user: {
    id: string
    name: string
  }
}

开发环境优化

HMR 缓存

在本地开发中,Server Components 的更改会导致整个页面重新渲染以显示新更改,包括为组件获取新数据。

serverComponentsHmrCache 选项允许你在本地开发中跨 HMR 刷新缓存 Server Components 中的 fetch 响应:

import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  experimental: {
    serverComponentsHmrCache: true,
  },
}

export default nextConfig

这将导致更快的响应并降低计费 API 调用的成本。

平台支持

流式传输支持

部署选项支持
Node.js 服务器
Docker 容器
静态导出
适配器取决于平台

总结

Next.js 提供了灵活而强大的数据获取方式:

  1. Server Components:使用 fetch、ORM 或数据库直接获取数据,支持并行和顺序获取
  2. Client Components:使用 SWR、TanStack Query 或 React 的 use hook
  3. 流式传输:使用 loading.js<Suspense> 提供即时加载状态
  4. 数据安全:使用数据访问层、server-only 包和 Server Actions 保护数据
  5. 性能优化:使用 React cache、预加载模式和 HMR 缓存提高性能

通过合理使用这些特性,可以构建既快速又安全的现代 Web 应用。

在 GitHub 上编辑

上次更新于