Cache In Next.js
next.js的缓存机制
前段时间在使用next.js搭建个人博客的时候遇到了一些性能上的问题,最早的实现使用prisma+postgres自己实现了数据缓存。后来发现在部署到生产环境以后效果并不理想,因为云端缓存跟本地的环境还是有差距,无法保障next后台和pg的时延,此外就是缓存过期时从notion取数据会有明显的卡顿,也没有找到xian后来看了了next的文档,发现其已经提供了不少缓存解决方案,降低对后台数据源的实时依赖。虽然有了一些内存上的上涨以及透明度上的隐患,但是相对自己去实现可能会更加合适。
本文重点介绍next.js的几种缓存机制。
简介
next.js的缓存机制实现比较复杂,可以分为多层,重点包括:
Router-Cache: 客户端(浏览器)上的缓存,主要目的是通过预拉取、缓存RSCP等减少客户端等待耗时。
Full Route Cache:服务端的渲染结果缓存,目的是减少静态路由的重复渲染。对于页面无变化的场景适用
React-Cache / Request-Memorization:对同一次(页面渲染)请求时多次拉取同一数据源的缓存,既能减少同一次渲染对某个数据源的多次拉取,也能避免代码上组件之间通过props传递共享数据的繁琐。
Data-Cache:类似后台的共享内存或者缓存中间件(redis等),主要用于缓存用户无关的数据。比如网站文章数量、内容以及访问数等,供多次请求间的数据共享。
数据源:db或者cms等业务逻辑后台。这一层已经不属于缓存了。
Request memorization
// 'force-cache' is the default, and can be omitted |
目的
- 同一次server request多个组件、页面等统一数据源的复用,避免一次渲染对数据源的重复请求,也达到了同一次请求的数据快照的目的。
- 避免多个component之间的共享props数据传递。
next.js默认会将fetch的结果缓存到内存(In-memory),供同一请求内的数据共享。
流程&原理
- Request memorization 是React的一个特性,而不是Next.js的特性。虽然Next.js可能会包含这个特性,但它是React核心库的一部分。
- 只适用于
fetch
请求中的GET方法。不适用post - 只适用于React组件树。这意味着,只有在React组件树中的某些部分发起的
fetch
请求才会被memorization缓存。- 在
generateMetadata
、generateStaticParams
、Layouts
、Pages
以及其他服务端组件中发起的fetch
请求都会被memorization缓存。 - 路由处理器(Route Handlers)中的
fetch
请求不会被记忆化,因为它们不是React组件树的一部分。
- 在
- 对于不适合使用
fetch
的情况(例如,某些数据库客户端、内容管理系统(CMS)客户端或GraphQL客户端),你可以使用React的cache函数来记忆化函数。
有效时长以及更新(revalidation):
- 仅在单次请求期间有效,组件数渲染完毕对应的memorization缓存就会被free掉。相当于对一个server request提供一份数据的快照。
- 由于仅单次请求有效,通常不涉及数据更新机制。
如何取消(禁用机制):
- 如果开发者想要管理单个请求(例如,取消某个正在进行的请求),可以使用
AbortController
的signal
属性。AbortController
提供了一种取消fetch
请求的方法,通过传递signal
属性给fetch
函数,可以在需要时取消请求。 - 需要注意的是,使用
AbortController
并不会让请求不参与记忆化。它仅仅是用来取消正在进行的请求。即使你取消了某个请求,如果该请求是GET请求,它仍然会被记忆化。记忆化和取消请求是两个独立的功能
总结:
- Request-memorization的机制跟react-cache类似,仅用于处理同一个请求时共享数据的场景。
- 在使用fetch()函数GET方式时默认生效。
react-cache
准确的说react-cache属于React的特性,只不过在next中也可以使用。下面是一些注意的点。
- reac-cache仅在服务端组件可以使用。且仅在服务器组件内(直接或者间接都可以)调用时才会生效。在组件外部调用时不会生效。比如:
import {cache} from 'react'; |
cachedFn
还会缓存错误。如果fn
函数对于某些参数抛出了错误,这个错误会被缓存起来。当使用相同的参数再次调用cachedFn
时,相同的错误会被重新抛出。这意味着错误不会被忽视,而是会被记住并在相同的情况下再次触发。- React会为每个服务请求(server request)使所有已缓存的函数失效。这意味着,当服务器处理一个新的请求(即使来自同一个终端用户)时,它不会使用之前的缓存结果,而是会重新计算这些函数的结果。
- cache可以用作服务端的数据预拉取。比如进入页面时先调用async的(不用await结果)cacheFn,服务端就会进入预拉取阶段。后面页面再次调用时就可以直接拿到缓存结果,从而起到加速的效果。
const getUser = cache(async (id) => { |
- 注意:cache对函数的参数也有限制。如果参数类型是object等复杂类型,需要确保传入的是同一个引用,因为react-cache内部仅做shallow equality对比,猜测是出于性能以及安全性的考虑。
总结:
react cache与上面Next的request memorization功能基本一样,是对next下fetch的补充。也仅适合在处理同一个请求的的重复数据源拉取场景,比如generateMetadata和Page渲染使用了同一份统数据(比如需要查询db共享数据)。而业务逻辑上极少有需要取两次同一份数据的场景,有也可以通过代码规避。当然相对通过组件间props透传共享数据还是更加方便多了。
data-cache
为了做到多个server request之间共享数据,Next.js 提供了一个内置的数据缓存(data cache),可以在传入的服务器请求和部署中持久化数据。之所以能做到这一点,是因为 Next.js 扩展了本地 fetch
API,以允许服务器上的每个请求设置自己的持久缓存语义。
默认情况下,使用 fetch
请求的数据请求也会被data-cache缓存。可以通过fetch参数中的cache和next.revalidate控制其行为。
// Opt out of caching |
流程及原理:
- 在渲染过程中首次调用
fetch
请求时,Next.js 会检查data cache是否有缓存响应(前提是request memorization缓存miss了) - 如果data-cache命中缓存,会立即返回并写入到request memorization。
- 如果data-cache未命中,则会向后端数据源发出请求,并将返回结果存储在data cache 和 request memorization中。data cache的数据是为了供后续其他server request来复用。
- 对于指定了不缓存的数据(如fetch设置
{ cache: 'no-store' }
参数),会跳过data-cache的读写,总是从数据源获取数据,并加入request memorization。 - 无论data cache是否缓存,request-memorization都会为本次请求缓存数据,避免同一客户端请求多次访问同一数据源。
有效时长
部署环境默认永久有效,除非指定opt-out、revalidation机制。
缓存失效机制(revalidation)
- 基于时间(被动)
要按时间间隔重新验证数据,可以使用 fetch
的 next.revalidate
选项来设置资源的缓存时间(以秒为单位)。即超过指定的时长之后,会重新从数据源获取,并重新写入data cache和memorization。
// Revalidate at most every hour |
注意:当data cache数据过期后,为了不阻塞请求,本次会优先返回过期的数据。然后异步执行更新机制。这样下次就可以使用到更新后的数据。类似stale-while-revalidate(性能考虑的优化,但是对请求量少且数据时效性有较高要求的场景可能会是个问题)
- 按需触发(主动)
数据可按路径(revalidatePath)或缓存标签(revalidateTag)执行revalidation。
// Cache data with a tag |
与基于时间的模式不同,执行revalidation之后数据会立即过期,当下一次访问时需要先从数据源获取数据,没有旧数据兜底。
按需触发存在两个场景
- root-handler:比如提供webhook给到数据源,当数据源变更时主动通知变更。
- Server action:用户操作触发
如何取消(禁用机制):
有两种方法可以禁用data-cache:
- 对于单个请求,可以在fetch时指定
no-store
参数 - 路由段配置选项,禁用某个route下的所有data-cache,使用一下方法之一都可以禁用data-cache
// Opt out of caching for all data requests in the route segment |
总结:
- data-cache是request memorization的扩展,可以做到在不同请求间共享数据。
- data-cache通常对使用方是透明的,只要是通过fetch() 函数GET方式获取数据时默认生效。如果对数据有实时性的要求需要主动禁用或者设置revalidation更新。
- 不清楚data-cache的实现机制是怎样的,第一印象应该是内存。但是在文档中有提到使用文件,另外在官方的example中也有给出使用redis扩展的列子。所以这块具体的实现机制也不太确定,而且估计本地部署next和vercel平台上也会有差异。
In Next.js, the default cache handler for the Pages and App Router uses the filesystem cache. This requires no configuration, however, you can customize the cache handler by using the
cacheHandler
field innext.config.js
.
unstable_cache
对于未使用fetch()或者使用了HTTP POST的场景(比如通过sdk访问第三方数据、查询db结果等),可以使用nextjs提供的 unstable_cache
来访问来存取data cache。使用方式与react-cache类似,另外也提供了tag、revalidate等更新机制。详见文档。另 unstable_cache
还处于实验室阶段,后续可能存在变更的可能。
Full Route Cache
除了数据的缓存外,页面的渲染结果也可以被缓存。某些page在build阶段就会生成并被Full Route Cache缓存,前提是该页面不包含任何动态数据(无外部数据依赖或者仅依赖 fetch
)。动态数据是指在运行时可能改变的数据,如用户输入、数据库查询结果等。页面的HTML和RSCP会被缓存,避免重新执行渲染操作。只有当重新部署应用程序或手动使该页面依赖的数据缓存失效时,这些缓存的内容才会被更新。
你可能认为,因为我们正在进行一个 fetch 请求,所以我们拥有动态数据。但实际上,这个 fetch 请求被 Next.js 在数据缓存(Data Cache)中缓存了,因此这个页面实际上是被视为静态的。动态数据是指每次请求页面时都会变化的数据,例如dynamic URL 、cookies、请求头、search param等。
流程&原理:
对于符合条件的路由(静态路由),rendering的结果(RSCP、HTML)会被写入Full Route Cache。下次的访问就不会重新执行渲染了。
有效时长:
部署环境默认永久有效,除非指定revalidation机制。
更新机制(revalidation)
有两种机制可以revalidate Full Route Cache:
- Revalidate Data Cache;对Data cache的数据执行更新操作,会使得对应的rooter cache更新。
- 重新部署
如何取消(禁用机制):
可以通过以下方法要求每次访问都重新执行渲染:
- 引入动态函数:即存在fetch+HTTP以外的数据依赖
- 路由段配置选项:与data-cache方法一样,禁用某个route下的所有data-cache时,也会对full route cache生效。
Router-cache
Next.js 具有一个内存中的客户端缓存,称为“路由器缓存”(Router Cache)。这个缓存用于存储 React 服务器组件的负载(React Server Component Payload),这些负载会根据不同的路由段进行分割,并在用户的会话期间保持存储状态。
原理
- 当用户在应用中导航到不同的页面或路由时,Next.js会缓存这些已访问的**路由段(route segment)**。这意味着当用户决定返回到之前访问过的页面时,这些页面可以迅速地从缓存中加载,而不是重新生成。
- Next.js还会根据用户在视口(viewport)中的
<Link>
组件来预测用户可能会导航到的路由,并预先加载这些路由。这确保了当用户决定导航到这些页面时,这些页面已经准备好了,从而实现了快速导航。——预加载这些比较常见的优化手段,next都集成在框架内了。但是这里也有考虑对服务端的无效流量。
总之,在传统的Web应用中,当用户导航到新的页面时,整个页面通常都会重新加载。但在Next.js中,由于路由缓存和预取,这种全页重载被避免了,从而为用户提供了更流畅的体验。
路由器缓存(Router Cache)和全路由缓存(Full Route Cache)之间的主要区别:
- 存储位置:
- 路由器缓存(Router Cache):这种缓存暂时在用户的浏览器会话期间存储React服务器组件的负载。它是内存中的客户端缓存,这意味着数据存储在用户的浏览器内存中,而不是在服务器上。
- 全路由缓存(Full Route Cache):这种缓存持久地在服务器上存储React服务器组件的负载和HTML,跨越多个用户请求。这意味着数据存储在服务器端的持久存储中,而不是在用户的浏览器内存中。
- 缓存内容:
- 路由器缓存:它存储的是React服务器组件的负载,这些负载是根据不同的路由段分割的。
- 全路由缓存:它不仅存储React服务器组件的负载,还存储HTML内容。
- 缓存策略:
- 路由器缓存:它适用于静态渲染和动态渲染的路由。这意味着无论路由是如何渲染的(静态或动态),路由器缓存都会存储相关的负载。
- 全路由缓存:它只适用于静态渲染的路由。这意味着它不会缓存动态渲染的路由的负载或HTML。
- 缓存生命周期:
- 路由器缓存:它的生命周期与用户的会话绑定。一旦用户的会话结束,缓存的数据就会被清除。
- 全路由缓存:它的生命周期跨越多个用户请求,并且会持续存在,直到被显式地清除或由于某种原因(如服务器重启)而失效。
生效时长:
路由器缓存的持续时间由两个因素决定:
- 会话:缓存在浏览过程中持续存在。不过,它会在页面刷新时被清除。
- 自动失效期:单个路由段的缓存会在特定时间后自动失效。持续时间取决于路径是静态渲染还是动态渲染:
- 动态渲染:30 秒
- 静态渲染:5 分钟
缓存失效(revalidation)
有两种方法可以使路由器缓存失效:
- 在服务器操作中:
- 使用(
revalidatePath
)按路径或(revalidateTag
)按缓存标签按需失效数据 - 使用
cookies.set
或cookies.delete
可使路由器缓存失效,以防止使用 cookies 的路由(例如:身份验证)变得过时
- 使用(
- 调用
router.refresh
会使路由器缓存失效,并就当前路由向服务器发出新请求
缓存退出
- 退出路由缓存
你不能选择完全退出路由缓存,因为Next.js设计这个缓存机制是为了提高性能和导航体验。但是,你可以通过调用一些方法来使缓存失效(invalidate the cache):
router.refresh()
: 这个方法会清除当前路由的缓存,并向服务器发送一个新的请求来获取最新的数据。revalidatePath()
: 这个方法允许你指定一个路径,并清除该路径对应的缓存。revalidateTag()
: 类似于revalidatePath()
,但它是基于标签(tag)来清除缓存的,允许你清除一组相关路由的缓存。
- 退出预取
你可以通过设置<Link>
组件的prefetch
属性为false
来退出预取功能。这样做将阻止Next.js预加载该链接对应的页面。
然而,即使你禁用了预取,Next.js仍然会临时存储已访问的路由段,以便在30秒内实现嵌套段(如标签栏)之间的即时导航,以及前后导航。这意味着,即使你退出了预取功能,已访问的路由仍然会被缓存,以便快速回退或前进。