(技巧)当 Next.js 遇到频繁重复的数据库操作时,记住使用 React 的 cache 函数

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

前言

在开发 Next.js 项目的时候,应该优先使用 fetch 获取数据,因为 Next.js 拓展了原生的 fetch,为了提高应用性能,增加了缓存和重新验证机制。

然而,也不是所有时候都能使用 fetch,就比如获取数据库中的数据,就可能需要自己使用 ORM 库(如 Prisma)查询获取。如果数据量大、操作比较费时,频繁重复的查询会浪费很多时间,为了提高性能,此时就需要加入数据缓存机制。

那么该如何实现呢?这就是本篇要讲的主题之一 ------ React cache。

PS:学习 Next.js,欢迎入手小册《Next.js 开发指南》。基础篇、实战篇、源码篇、面试篇四大篇章带你系统掌握 Next.js!

技巧一:React cache

React 提供了 cache 函数,用于缓存数据获取或计算的结果,用法如下:

javascript 复制代码
const cachedFn = cache(fn);

注意:

  1. cache 仅供与 React 服务端组件一起使用
  2. cache 目前仅在 React 的 Canary 和实验渠道中可用

当结合 Next.js 使用时,举个例子:

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

现在我们调用两次 getItem

javascript 复制代码
// 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)
  // ...
}
javascript 复制代码
// 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,尽管 getItem 被调用两次,但只会产生一次数据库查询。

实战体会

还是让我们在实战中体会 cache 函数的作用吧。

使用 Next.js 官方脚手架创建项目:

bash 复制代码
npx create-next-app@latest

运行效果如下:

为了样式美观,我们会用到 Tailwind CSS,所以注意勾选 Tailwind CSS,其他随意。

进入项目目录,开启本地模式,检查项目是否能够启动成功:

bash 复制代码
npm i && npm run dev

我们以实现这样一个文章页面为例进行讲解:

当我们访问 /article路由的时候,显示一篇文章的具体信息。蓝色部分是一个通知 banner,下边的是文章的具体内容,此外还要注意页面的 title 为文章的标题。

静态渲染

涉及的文件和目录结构如下:

javascript 复制代码
app               
└─ article           
   ├─ layout.js  
   ├─ page.js     
   └─ utils.js         

新建 app/article/layout.js,代码如下:

javascript 复制代码
import { getArticle } from './utils'

export const revalidate = 10

export default async function Layout({ children }) {
  const { title } = await getArticle()
  return (
    <div>
      <div className="bg-indigo-600">
        <div className="max-w-screen-xl mx-auto px-4 py-3 text-white sm:text-center md:px-8">
          <p className="font-medium">
            您正在阅读文章 {title}
          </p>
        </div>
      </div>
      {children}
    </div>
  )
}

新建 app/article/page.js,代码如下:

javascript 复制代码
import { getArticle } from './utils'

export async function generateMetadata() {
  const { title } = await getArticle()

  return {
    title
  }
}

export default async function Page() {
  const { title } = await getArticle()
  return <div className="space-y-3 text-center">
    <h1 className="text-3xl text-gray-800 font-semibold mt-5">
      文章标题:{title}
    </h1>
    <p className="text-gray-600 max-w-lg mx-auto text-lg">
      Assume this is the text of the article
    </p>
  </div>
}

新建 app/article/utils.js,代码如下:

javascript 复制代码
import { cache } from 'react'

const sleep = ms => new Promise(r => setTimeout(r, ms));

export const getArticleWithoutCache = async () => {
  await sleep(2000)
  console.log('执行了一次')
  return {
    title: Math.random().toString(36).slice(-6)
  }
}

export const getArticle = cache(getArticleWithoutCache)

为了模拟数据库操作的费时,我们用了一个 sleep 函数。为了演示数据缓存和更新的效果,每次调用的时候,我们都会随机返回一个文章标题。

此时交互效果如下:

当点击刷新按钮的时候,页面会加载 2s,然后渲染出具体的内容。我们分别在 layout、page、generateMetadata 中调用了 getArticle 函数。

如果没有做缓存,三次调用会返回不同的文章标题。但是因为使用了 React Cache 做缓存,三次调用会返回相同的文章标题。使用 React Cache,我们就可以自由的在需要数据的地方直接查询数据,而不用担心频繁重复的查询导致性能问题,也不需要在顶层组件查询数据,然后将数据一层一层传给需要的组件。

那如何更新数据呢?

我们在布局的代码中也加入了重新验证:

javascript 复制代码
export const revalidate = 10

这个配置的意思是最少 10s 进行一次重新验证。也就是说,当我们运行生产版本时,刷新页面,页面的数据暂时不会发生变化。10s 内页面刷新都不会发生变化,但 10s 之后的第一次刷新依然会返回之前的缓存内容,但会触发重新验证,缓存更新成功后,10s 之后的第二次刷新会返回最新的内容。交互效果如下:

为了防止混淆,关于 React Cache,有一点要强调的是,React Cache 函数解决的是记忆化问题,也就是在一次路由渲染中,对频繁重复的数据查询进行缓存。

我们在这里使用 revalidate 能够触发更新,是因为 Next.js 对 /article进行了静态渲染,revalidate 触发了重新渲染,在渲染的时候,因为 React Cache,三次调用的返回结果才是一致的。也就是页面标题、banner 内容、文章标题,三个地方的内容一致,这是 React Cache 的功效。

动态渲染

让我们用下动态渲染来重新说明这点:

涉及的文件和目录结构如下:

javascript 复制代码
app                 
└─ article          
   └─ [id]          
      ├─ layout.js  
      ├─ page.js    
      └─ utils.js         

修改 app/article/[id]/layout.js,代码如下:

javascript 复制代码
import { getArticle } from './utils'

export const revalidate = 10

export default async function Layout({ children, params: { id } }) {
  const { title } = await getArticle(id)
  return (
    <div>
      <div className="bg-indigo-600">
        <div className="max-w-screen-xl mx-auto px-4 py-3 text-white sm:text-center md:px-8">
          <p className="font-medium">
            您正在阅读文章 {title}
          </p>
        </div>
      </div>
      {children}
    </div>
  )
}

新建 app/article/[id]/page.js,代码如下:

javascript 复制代码
import { getArticle } from './utils'

export async function generateMetadata({ params: { id } }) {
  const { title } = await getArticle(id)

  return {
    title
  }
}

export default async function Page({params: { id }}) {
  const { title } = await getArticle(id)
  return <div className="space-y-3 text-center">
    <h1 className="text-3xl text-gray-800 font-semibold mt-5">
      文章标题:{title}
    </h1>
    <p className="text-gray-600 max-w-lg mx-auto text-lg">
      Assume this is the text of the article
    </p>
  </div>
}

新建 app/article/[id]/utils.js,代码如下:

javascript 复制代码
import { cache } from 'react'

const sleep = ms => new Promise(r => setTimeout(r, ms));

export const getArticleWithoutCache = async (id) => {
  await sleep(2000)
  console.log('执行了一次')
  return {
    id,
    title: Math.random().toString(36).slice(-6)
  }
}

export const getArticle = cache(getArticleWithoutCache)

运行 npm run build && npm run start开启生产版本,此时交互效果如下:

因为使用了动态路由,此时路由动态渲染。每次刷新页面,都会等待 2s,然后页面内容展现,三个部分的文章内容一致。

可以看出:React Cache 函数解决的是记忆化问题,也就是在一次路由渲染中,对频繁重复的数据查询进行缓存。React 实现了 fetch 请求的自动缓存,但对于像数据库查询这种复杂费时的操作,则需要使用 React 的 cache 函数进行缓存。

技巧二:预加载数据

接下来我们讲讲第二个性能优化技巧 ------ 预加载数据。

我们写代码的时候,常会遇到这样的例子:

javascript 复制代码
// app/article/[id]/page.js
import Article, { checkIsAvailable } from '@/components/Article'
 
export default async function Page({ params: { id } }) {
  // 执行另一个异步任务,这里是伪代码
  const isAvailable = await checkIsAvailable()
 
  return isAvailable ? <Article id={id} /> : null
}

让我解释这段代码的含义:比如我们访问一个文章页面,此时需要检查用户是否有权限访问这个页面(checkIsAvailable),如果有权限,isAvailable 为 true,此时再渲染具体的文章内容,也就是 <Article> 组件。Article 组件会根据 id 请求具体的文章数据,我们假设调用 getArticle 方法。

这样就涉及到了 2 个接口,一个是 checkIsAvailable,一个是 getArticle,两个接口是串行的,先调用 checkIsAvailable,再调用 getArticle

有没有方法优化一下?

那就是预加载,在调用 checkIsAvailable 前其实就已经知道了文章 id,直接请求文章具体数据,然后将数据缓存,当 checkIsAvailable 返回为 true 的时候,就可以直接使用文章具体数据了。也就是:

javascript 复制代码
// 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 函数中,则要搭配 cache 函数一起使用:

javascript 复制代码
// 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)
  // ...
}

使用这种方式,你就可以提前获取数据,缓存返回结果。

但是此时还有一个问题,那就是数据获取不一定就只发生在服务端,如果你在客户端发送了请求,尽管不会显示在页面上,但在接口中暴露了文章的具体数据,这也不好。所以还需要保证数据获取只发生在服务端。

保证代码只执行在服务端,Next.js 推荐使用 server-only 这个包,用法也很简单,导入即可:

bash 复制代码
import 'server-only'
 
export async function getData() {
  const res = await fetch('https://external-service.com/data', {
    headers: {
      authorization: process.env.API_KEY,
    },
  })
 
  return res.json()
}

现在,任何导入 getData() 的客户端组件都会收到一个构建时错误,说明该模块只能在服务器上使用。

那么结合 preload、React cache 函数、server-only,就可以写一个 utils 工具函数:

javascript 复制代码
// 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

实战体会

还是让我们在实战中体会预加载的特性吧!

涉及的文件和目录结构如下:

javascript 复制代码
app                 
└─ article2         
   └─ [id]          
      ├─ page.js    
      └─ utils.js         

新建 app/article2/[id]/page.js,代码如下:

javascript 复制代码
import Article, { preloadArticle, checkIsAvailable } from './utils'
 
export default async function Page({ params: { id } }) {
  preloadArticle(id)
  const isAvailable = await checkIsAvailable()
 
  return isAvailable ? <Article id={id} /> : null
}

新建 app/article2/[id]/utils.js,代码如下:

javascript 复制代码
import { cache } from 'react'
import 'server-only'
 
const sleep = ms => new Promise(r => setTimeout(r, ms));

export const preloadArticle = (id) => {
  void getArticle(id)
}
 
export const getArticle = cache(async (id) => {
  await sleep(5000)
  return {
    id,
    title: Math.random().toString(36).slice(-6)
  }
})

export const checkIsAvailable = async () => {
  await sleep(5000)
  return true
}

export default async function Article({ id }) {
  const { title } = await getArticle(id)
  return <div className="space-y-3 text-center">
    <h1 className="text-3xl text-gray-800 font-semibold mt-5">
      文章标题:{title}
    </h1>
    <p className="text-gray-600 max-w-lg mx-auto text-lg">
      Assume this is the text of the article
    </p>
  </div>
}

为了让效果更加明显,checkIsAvailable 和 getArticle 我们都加了一个 5s 延时,如果没有预加载和缓存,页面需要 10s 才能渲染内容,使用预加载和缓存后,因为接口同时请求,所以页面 5s 后就会渲染出内容。交互效果如下:

PS:你可能会有疑惑,checkIsAvailable 也可能返回 false 呀,这样不就白浪费一个接口请求了吗?所以说,这是一种取舍,如果 checkIsAvailable 这个接口常常返回 true,那么使用预加载是一个不错的选择。

总结

在 Next.js 项目开发中,数据库查询操作是非常常见的操作,如果重复且费时,建议加上缓存机制,此时就需要使用 React 的 cache 函数辅助实现。但是要注意,cache 函数解决的是记忆化问题,它不能像 Next.js 自身的数据缓存可以跨用户请求和部署。

基于 React 的 cache 函数,还有一种常见的性能优化策略就是 preload,它的本质上是将串行接口改为并行接口,并通过 cache 缓存数据返回。

PS:学习 Next.js,欢迎入手小册《Next.js 开发指南》。基础篇、实战篇、源码篇、面试篇四大篇章带你系统掌握 Next.js!

相关推荐
diygwcom5 分钟前
electron-updater实现electron全量版本更新
前端·javascript·electron
volodyan9 分钟前
electron react离线使用monaco-editor
javascript·react.js·electron
^^为欢几何^^17 分钟前
lodash中_.difference如何过滤数组
javascript·数据结构·算法
Hello-Mr.Wang22 分钟前
vue3中开发引导页的方法
开发语言·前端·javascript
程序员凡尘1 小时前
完美解决 Array 方法 (map/filter/reduce) 不按预期工作 的正确解决方法,亲测有效!!!
前端·javascript·vue.js
编程零零七4 小时前
Python数据分析工具(三):pymssql的用法
开发语言·前端·数据库·python·oracle·数据分析·pymssql
北岛寒沫5 小时前
JavaScript(JS)学习笔记 1(简单介绍 注释和输入输出语句 变量 数据类型 运算符 流程控制 数组)
javascript·笔记·学习
everyStudy5 小时前
JavaScript如何判断输入的是空格
开发语言·javascript·ecmascript
(⊙o⊙)~哦6 小时前
JavaScript substring() 方法
前端
无心使然云中漫步7 小时前
GIS OGC之WMTS地图服务,通过Capabilities XML描述文档,获取matrixIds,origin,计算resolutions
前端·javascript