Next.js
13-数据获取篇 数据获取、缓存与重新验证

前言

在 Next.js 中如何获取数据呢?

Next.js 优先推荐使用原生的 fetch 方法,因为 Next.js 拓展了原生的 fetch 方法,为其添加了缓存和更新缓存(重新验证)的机制。

这样做的好处在于可以自动复用请求数据,提高性能。坏处在于如果你不熟悉,经常会有一些“莫名奇妙”的状况出现……

让我们来看看具体如何使用吧。

1. 服务端使用 fetch

1.1. 基本用法

Next.js 拓展了原生的 fetch Web API (opens in a new tab),可以为服务端的每个请求配置缓存(caching)和重新验证( revalidating)行为。

你可以在服务端组件、路由处理程序、Server Actions 中搭配 async/await 语法使用 fetch。

举个例子:

// app/page.js
async function getData() {
  const res = await fetch('https://jsonplaceholder.typicode.com/todos')
  if (!res.ok) {
    // 由最近的 error.js 处理
    throw new Error('Failed to fetch data')
  }
  return res.json()
}
 
export default async function Page() {
  const data = await getData()
  return <main>{JSON.stringify(data)}</main>
}

1.2. 默认缓存

默认情况下,Next.js 会自动缓存服务端 fetch 请求的返回值(背后用的是数据缓存(Data Cache) (opens in a new tab))。

// fetch 的 cache 选项用于控制该请求的缓存行为
// 默认就是 'force-cache', 平时写的时候可以省略
fetch('https://...', { cache: 'force-cache' })

但这些情况默认不会自动缓存:

  1. 在 Server Action 中使用的时候
  2. 在定义了非 GET 方法的路由处理程序中使用的时候

简单的来说,在服务端组件和只有 GET 方法的路由处理程序中使用 fetch,返回结果会自动缓存。

1.2.1. logging 配置项

让我们分别举个例子演示下。但在写代码之前,先让我们修改下 next.config.mjs 的配置:

const nextConfig = {
  logging: {
    fetches: {
      fullUrl: true
    }
  }
};
 
export default nextConfig;

目前 logging 只有这一个配置,用于在开发模式下显示 fetch 请求和缓存日志:

image.png

上图日志的意思是:

访问 /api/cache 路由,其中 GET 请求了 https://dog.ceo/api/breeds/image/random (opens in a new tab) 这个接口,接口 20ms 返回,状态码 200,此次请求命中了缓存(HIT)。

这个日志会帮助我们查看缓存情况(实际用的时候有的日志结果不是很准,还有待改进)。

1.2.2. 服务端组件

第一种在服务端组件中使用,修改 app/page.js,代码如下:

async function getData() {
  // 接口每次调用都会返回一个随机的猫猫图片数据
  const res = await fetch('https://api.thecatapi.com/v1/images/search')
  if (!res.ok) {
    throw new Error('Failed to fetch data')
  }
 
  return res.json()
}
 
export default async function Page() {
  const data = await getData()
  
  return <img src={data[0].url} width="300" />
}

运行 npm run dev,开启开发模式:

cache-4.gif

在开发模式下,为了方便调试,可以使用浏览器的硬刷新(Command + Shift + R)清除缓存,此时数据会发生更改(cache: SKIP)。普通刷新时因为会命中缓存(cache: HIT),数据会保持不变。

运行 npm run build && npm run start 开启生产版本:

cache.gif

因为 fetch 请求的返回结果被缓存了,无论是否硬刷新,图片数据都会保持不变。

1.2.3. 路由处理程序 GET 请求

第二种在路由处理程序中使用,新建 app/api/cache/route.js,代码如下:

export async function GET() {
  const res = await fetch('https://dog.ceo/api/breeds/image/random')
  
  const data = await res.json()
  return Response.json({ data })
}

运行 npm run dev,开启开发模式:

cache-5.gif

开发模式下,浏览器硬刷新的时候会跳过缓存,普通刷新的时候则会命中缓存。可以看到第一次硬刷新的时候,请求接口时间为 912ms,后面普通刷新的时候,因为使用缓存中的数据,数据返回时间都是 1ms 左右。

运行 npm run build && npm run start 开启生产版本:

cache-6.gif

因为 fetch 请求的返回结果被缓存了,无论是否硬刷新,接口数据都会保持不变。

1.3. 重新验证

在 Next.js 中,清除数据缓存并重新获取最新数据的过程就叫做重新验证(Revalidation)。

Next.js 提供了两种方式重新验证:

一种是基于时间的重新验证(Time-based revalidation),即经过一定时间并有新请求产生后重新验证数据,适用于不经常更改且新鲜度不那么重要的数据。

一种是按需重新验证(On-demand revalidation),根据事件手动重新验证数据。按需重新验证又可以使用基于标签(tag-based)和基于路径(path-based)两种方法重新验证数据。适用于需要尽快展示最新数据的场景。

基于时间的重新验证

使用基于时间的重新验证,你需要在使用 fetch 的时候设置 next.revalidate 选项(以秒为单位):

fetch('https://...', { next: { revalidate: 3600 } })

或者通过路由段配置项 (opens in a new tab)进行配置,使用这种方法,它会重新验证该路由段所有的 fetch 请求。

// layout.jsx | page.jsx | route.js
export const revalidate = 3600

注:在一个静态渲染的路由中,如果你有多个请求,每个请求设置了不同的重新验证时间,将会使用最短的时间用于所有的请求。而对于动态渲染的路由,每一个 fetch请求都将独立重新验证。

按需重新验证

使用按需重新验证,在路由处理程序或者 Server Action 中通过路径( revalidatePath (opens in a new tab)) 或缓存标签 revalidateTag (opens in a new tab) 实现。

revalidatePath

新建 app/api/revalidatePath/route.js,代码如下:

import { revalidatePath } from 'next/cache'
 
export async function GET(request) {
  const path = request.nextUrl.searchParams.get('path')
 
  if (path) {
    revalidatePath(path)
    return Response.json({ revalidated: true, now: Date.now() })
  }
 
  return Response.json({
    revalidated: false,
    now: Date.now(),
    message: 'Missing path to revalidate',
  })
}

此时访问 /api/revalidatePath?path=/ 就会更新 / 的 fetch 请求返回数据,交互效果如下:

cache-7.gif

此时访问 /api/revalidatePath?path=/api/cache 就会更新 /api/cache 的 fetch 请求返回数据,交互效果如下:

cache-8.gif

注意:这两张图演示的是开发模式下的情况,用 revalidatePath 确实更新了对应路径上的 fetch 缓存结果。但如果大家部署到生产版本,你是发现 revalidatePath 只对页面生效,对路由处理程序并不生效。

这是因为 /api/cache 被静态渲染了,首先你要将 /api/cache 转为动态渲染,然后才能测试 revalidatePath 的效果。但是转为动态渲染,比如使用 cookies 等函数,又会触发 Next.js 的自动逻辑,让 fetch 请求退出缓存。

简而言之,如果你想在生产环境测试 revalidatePath 对路由处理程序的影响,你需要多做一些配置:

// 路由动态渲染
export const revalidate = 0
// fetch 强制缓存
export const fetchCache = 'force-cache'
export async function GET() {
  const res = await fetch('https://dog.ceo/api/breeds/image/random')
  
  const data = await res.json()
  return Response.json({ data, now: Date.now() })
}

这样的代码在生产环境下,是可以被 revalidatePath 重新验证的。效果同开发模式下的截图。

revalidateTag

Next.js 有一个路由标签系统,可以跨路由实现多个 fetch 请求重新验证。具体这个过程为:

  1. 使用 fetch 的时候,设置一个或者多个标签标记请求
  2. 调用 revalidateTag 方法重新验证该标签对应的所有请求

举个例子:

// app/page.js
export default async function Page() {
  const res = await fetch('https://...', { next: { tags: ['collection'] } })
  const data = await res.json()
  // ...
}

在这个例子中,为 fetch 请求添加了一个 collection标签。在 Server Action 中调用 revalidateTag,就可以让所有带 collection 标签的 fetch 请求重新验证。

// app/actions.js
'use server'
 
import { revalidateTag } from 'next/cache'
 
export default async function action() {
  revalidateTag('collection')
}

让我们真的写个例子。修改 app/page.js 代码如下:

async function getData() {
  const res = await fetch('https://api.thecatapi.com/v1/images/search', { next: { tags: ['collection'] } })
  if (!res.ok) {
    throw new Error('Failed to fetch data')
  }
 
  return res.json()
}
 
export default async function Page() {
  const data = await getData()
  
  return <img src={data[0].url} width="300" />
}

修改 app/api/cache/route.js,代码如下:

export const revalidate = 0
export const fetchCache = 'force-cache'
 
export async function GET() {
  const res = await fetch('https://dog.ceo/api/breeds/image/random', { next: { tags: ['collection'] } })
  
  const data = await res.json()
  return Response.json({ data, now: Date.now() })
}

新建 app/api/revalidateTag/route.js,代码如下:

import { revalidateTag } from 'next/cache'
 
export async function GET(request) {
  const tag = request.nextUrl.searchParams.get('tag')
  revalidateTag(tag)
  return Response.json({ revalidated: true, now: Date.now() })
}

此时访问 /api/revalidateTag?tag=collection 就会让 / 页面和 /api/cache 接口的数据都重新验证:

cache-9.gif

错误处理和重新验证

如果在尝试重新验证的过程中出现错误,缓存会继续提供上一个重新生成的数据,而在下一个后续请求中,Next.js 会尝试再次重新验证数据。

1.4. 退出数据缓存

fetch 请求满足这些条件时都会退出数据缓存:

  • fetch 请求添加了 cache: 'no-store' 选项
  • fetch 请求添加了 revalidate: 0 选项
  • fetch 请求在路由处理程序中并使用了 POST 方法
  • 使用headerscookies 的方法之后使用 fetch请求
  • 配置了路由段选项 const dynamic = 'force-dynamic'
  • 配置了路由段选项 fetchCache ,默认会跳过缓存
  • fetch 请求使用了 Authorization或者 Cookie请求头,并且在组件树中其上方还有一个未缓存的请求

在具体使用的时候,如果你不想缓存某个单独请求:

// layout.js | page.js
fetch('https://...', { cache: 'no-store' })

不缓存多个请求,可以借助路由段配置项 (opens in a new tab)

// layout.js | page.js
export const dynamic = 'force-dynamic'

Next.js 推荐单独配置每个请求的缓存行为,这可以让你更精细化的控制缓存行为。

2.服务端使用三方请求库

也不是所有时候都能使用 fetch 请求,如果你使用了不支持或者暴露 fetch 方法的三方库(如数据库、CMS 或 ORM 客户端),但又想实现数据缓存机制,那你可以使用 React 的 cache 函数和路由段配置项来实现请求的缓存和重新验证。

举个例子:

// app/utils.js
import { cache } from 'react'
 
export const getItem = cache(async (id) => {
  const item = await db.item.findUnique({ id })
  return item
})

现在我们调用两次 getItem

// app/item/[id]/layout.js
import { getItem } from '@/utils/get-item'
 
export const revalidate = 3600
 
export default async function Layout({ params: { id } }) {
  const item = await getItem(id)
  // ...
}
// app/item/[id]/page.js
import { getItem } from '@/utils/get-item'
 
export const revalidate = 3600
 
export default async function Page({ params: { id } }) {
  const item = await getItem(id)
  // ...
}

在这个例子中,尽管 getItem 被调用两次,但只会产生一次数据库查询。

注:这里的代码并不是完整可运行的,如果想要细致了解 React Cache 函数的特性,可以查看: (技巧)当 Next.js 遇到频繁重复的数据库操作时,记住使用 React 的 cache 函数 (opens in a new tab)

3. 客户端使用路由处理程序

如果你需要在客户端组件中获取数据,可以在客户端调用路由处理程序。路由处理程序会在服务端被执行,然后将数据返回给客户端,适用于不想暴露敏感信息给客户端(比如 API tokens)的场景。

如果你使用的是服务端组件,无须借助路由处理程序,直接获取数据即可。

4. 客户端使用三方请求库

你也可以在客户端使用三方的库如 SWR (opens in a new tab)React Query (opens in a new tab) 来获取数据。这些库都有提供自己的 API 实现记忆请求、缓存、重新验证和更改数据。

5. 建议与最佳实践

有一些在 React 和 Next.js 中获取数据的建议和最佳实践,本节来介绍一下:

5.1. 尽可能在服务端获取数据

尽可能在服务端获取数据,这样做有很多好处,比如:

  1. 可以直接访问后端资源(如数据库)
  2. 防止敏感信息泄漏
  3. 减少客户端和服务端之间的来回通信,加快响应时间
  4. ...

5.2. 在需要的地方就地获取数据

如果组件树中的多个组件使用相同的数据,无须先全局获取,再通过 props 传递,你可以直接在需要的地方使用 fetch 或者 React cache 获取数据,不用担心多次请求造成的性能问题,因为 fetch 请求会自动被记忆化。这也同样适用于布局,毕竟本来父子布局之间也不能传递数据。

5.3. 适当的时候使用 Streaming

Streaming 和 Suspense都是 React 的功能,允许你增量传输内容以及渐进式渲染 UI 单元。页面可以直接渲染部分内容,剩余获取数据的部分会展示加载态,这也意味着用户不需要等到页面完全加载完才能与其交互。

image.png

注:关于 Suspense 和 Streaming,我们会在 《渲染篇 | Suspense 与 Streaming》 (opens in a new tab) 中详细讲解。

5.4. 串行获取数据

在 React 组件内获取数据时,有两种数据获取模式,并行和串行。

image.png

所谓串行数据获取,数据请求相互依赖,形成瀑布结构,这种行为有的时候是必要的,但也会导致加载时间更长。

所谓并行数据获取,请求同时发生并加载数据,这会减少加载数据所需的总时间。

我们先说说串行数据获取,直接举个例子:

// app/artist/page.js
// ...
 
async function Playlists({ artistID }) {
  // 等待 playlists 数据
  const playlists = await getArtistPlaylists(artistID)
 
  return (
    <ul>
      {playlists.map((playlist) => (
        <li key={playlist.id}>{playlist.name}</li>
      ))}
    </ul>
  )
}
 
export default async function Page({ params: { username } }) {
  // 等待 artist 数据
  const artist = await getArtist(username)
 
  return (
    <>
      <h1>{artist.name}</h1>
      <Suspense fallback={<div>Loading...</div>}>
        <Playlists artistID={artist.id} />
      </Suspense>
    </>
  )
}

在这个例子中,Playlists 组件只有当 Artist 组件获得数据才会开始获取数据,因为 Playlists 组件依赖 artistId 这个 prop。这也很容易理解,毕竟只有先知道了是哪位艺术家,才能获取这位艺术家对应的曲目。

在这种情况下,你可以使用 loading.js 或者 React 的 <Suspense> 组件,展示一个即时加载状态,防止整个路由被数据请求阻塞,而且用户还可以与未被阻塞的部分进行交互。

关于阻塞数据请求:

  • 一种防止出现串行数据请求的方法是在应用程序根部全局获取数据,但这会阻塞其下所有路由段的渲染,直到数据加载完毕。
  • 任何使用 awaitfetch 请求都会阻塞渲染和下方所有组件的数据请求,除非它们使用了 <Suspense> 或者 loading.js。另一种替代方式就是使用并行数据请求或者预加载模式。

5.5. 并行数据请求

要实现并行请求数据,你可以在使用数据的组件外定义请求,然后在组件内部调用,举个例子:

import Albums from './albums'
 
// 组件外定义
async function getArtist(username) {
  const res = await fetch(`https://api.example.com/artist/${username}`)
  return res.json()
}
 
async function getArtistAlbums(username) {
  const res = await fetch(`https://api.example.com/artist/${username}/albums`)
  return res.json()
}
 
export default async function Page({ params: { username } }) {
  // 组件内调用,这里是并行的
  const artistData = getArtist(username)
  const albumsData = getArtistAlbums(username)
 
  // 等待 promise resolve
  const [artist, albums] = await Promise.all([artistData, albumsData])
 
  return (
    <>
      <h1>{artist.name}</h1>
      <Albums list={albums}></Albums>
    </>
  )
}

在这个例子中,getArtistgetArtistAlbums 函数都是在 Page 组件外定义,然后在 Page 组件内部调用。用户需要等待两个 promise 都 resolve 后才能看到结果。

为了提升用户体验,可以使用 Suspense 组件来分解渲染工作,尽快展示出部分结果。

5.6. 预加载数据

防止出现串行请求的另外一种方式是使用预加载。举个例子:

// app/article/[id]/page.js
import Article, { preload, checkIsAvailable } from './components/Article'
 
export default async function Page({ params: { id } }) {
  // 获取文章数据
  preload(id)
  // 执行另一个异步任务,这里是伪代码,比如判断文章是否有权限访问
  const isAvailable = await checkIsAvailable()
 
  return isAvailable ? <Article id={id} /> : null
}

而在具体的 preload 函数中,则要搭配 React 的 cache 函数一起使用:

// components/Article.js
import { getArticle } from '@/utils/get-article'
import { cache } from 'react'
 
export const getArticle = cache(async (id) => {
  // ...
})
 
export const preload = (id) => {
	void getArticle(id)
}
 
export const checkIsAvailable = (id) => {
	// ...
}
 
export default async function Article({ id }) {
  const result = await getArticle(id)
  // ...
}

5.7. 使用 React cache server-only 和预加载模式

你可以将 cache 函数,preload 模式和 server-only (opens in a new tab) 包一起使用,创建一个可在整个应用使用的数据请求工具函数。

// utils/get-article.js
import { cache } from 'react'
import 'server-only'
 
export const preloadArticle = (id) => {
  void getArticle(id)
}
 
export const getArticle = cache(async (id) => {
  // ...
})

现在,你可以提前获取数据、缓存返回结果,并保证数据获取只发生在服务端。此外,布局、页面、组件都可以使用 utils/get-article.js

注:如果想要细致了解 preload 函数和 server-only 以及 cache 的特性,可以查看: (技巧)当 Next.js 遇到频繁重复的数据库操作时,记住使用 React 的 cache 函数 (opens in a new tab)

小结

恭喜你,完成了本篇内容的学习!

这一节我们介绍了请求数据的四种方式,重点介绍了服务端使用 fetch 的方式,这是因为 Next.js 拓展了原生的 fetch,增加了数据缓存和重新验证的逻辑。在 Next.js 中,为了提高性能,应该尽可能的使用缓存,但为了保证数据的时效性,也应该设置合理的重新验证逻辑。Next.js 推荐单独配置每个请求的缓存行为,这可以让你更精细化的控制缓存行为。

介绍完四种数据请求方式后,Next.js 提供了一些获取数据的建议和最佳实践,正是因为有了强大的缓存功能,所以在书写代码的时候可以就地获取数据,而不用担心相同请求多次发送造成的性能影响。

参考链接

  1. Data Fetching: Fetching, Caching, and Revalidating | Next.js (opens in a new tab)
  2. Data Fetching: Data Fetching Patterns | Next.js (opens in a new tab)