Vercel + Next.js 12 で Vercel Edge Cache を有効にする
一番参考になった日本語情報🙏
さらにこの記事からリンクされてる
この記事が、stale-while-revalidateの理解にめっちゃ助かった。
関連する公式ドキュメントたち
に書かれているように、getServerSideProps
内で
export const getServerSideProps: GetServerSideProps<Props> = async (ctx) => {
ctx.res.setHeader(
'Cache-Control',
'max-age=0, s-maxage=0, stale-while-revalidate=86400',
)
return {
props: {},
}
ってやれば確かにレスポンスヘッダーをいじれてキャッシュの挙動も期待どおりになった。
また、
https://vercel.com/docs/functions/edge-functions/edge-caching#what-is-cached
に書かれているとおり、Authorization
ヘッダーでBASIC認証をかけているステージング環境だと確かにキャッシュされなくなることも確認できた。
'max-age=0, s-maxage=0, stale-while-revalidate=86400'
だと、
-
max-age=0
ブラウザではキャッシュしない -
s-maxage=0
Edge Cacheは0秒でstaleになる(Edge Cacheは一瞬もfreshと見なさず、常にキャッシュを返しつつ裏ではrevalidateを必ず行う) -
stale-while-revalidate=86400
Edge Cacheはstaleになったあとも86400秒間はキャッシュを返し、staleなキャッシュにリクエストがあった場合は裏でrevalidateを行う
なので、1日間隔以内のリクエストは常に X-Vercel-Cache: STALE
になり、その裏で常にEdge Cacheが更新されるので次のリクエストに対してはまた最新のキャッシュを提供できる、という挙動になった(はず)。
複数のページをまとめて同じ設定にしたい場合は
に書かれているように next.config.js
に設定を書けばよい(設定の書き方の詳細は https://nextjs.org/docs/pages/api-reference/next-config-js/headers を参照)
かと思ったが、やってみても( X-Custom-Header
ヘッダーとかは確かにセットできるのに)なぜか Cache-Control
ヘッダーは変更できなかった。
ググってたら
を見つけて、この中で言及されているように
ここに
You cannot set
Cache-Control
headers innext.config.js
file as these will be overwritten in production to ensure that API Routes and static assets are cached effectively.
と書いてあって、Cache-Control
ヘッダーだけはプロダクション環境では next.config.js
経由での変更ができない仕様っぽい。なんじゃそりゃ
というわけで
export const setCacheControlHeader = (
ctx: GetServerSidePropsContext,
maxAge = 0,
sMaxAge =0,
staleWhileRevalidate = 86400,
): void => {
ctx.res.setHeader(
'Cache-Control',
`max-age=${maxAge}, s-maxage=${sMaxAge}, stale-while-revalidate=${staleWhileRevalidate}`,
)
}
こんなのを書いて、対象のページ(=コンテンツの内容がログイン状態に依存しないページ)の getServerSideProps
で
export const getServerSideProps: GetServerSideProps<Props> = async (ctx) => {
// ...
setCacheControlHeader(ctx)
// ...
}
みたいにした
UGCに関連するページで
この問題が起こることに気づいた。
具体的には、例えば
- ユーザーAがUGCを投稿する
- まだ他のユーザーがUGC一覧ページを表示しておらずキャッシュが古いままになっている状態で、ユーザーAがUGC一覧ページを表示する
- ユーザーAには、さっき自分が投稿したUGCが一覧にまだないように見える
- リロードなどすると新しいキャッシュが読まれて一覧に現れる
というような挙動が起こり得る。
そこで今回は、「次回アクセス時に強制的に最新データをrefetchするべきページのパス」をCookieに持っておけるようにする、という対応をとった。
ちなみにSSRではなくISRを使っていたなら オンデマンドISR というやつでシュッとできるっぽい(?)
import {GetServerSidePropsContext} from 'next'
import {parseCookies, setCookie, destroyCookie} from 'nookies'
const key = 'pocitta-refetch-on'
const maxAge = 86400 /** @see setCacheControlHeader */
export const getRefetchOn = (ctx?: GetServerSidePropsContext): string[] => {
return JSON.parse(parseCookies(ctx)[key] ?? '[]')
}
export const addRefetchOn = (path: string, ctx?: GetServerSidePropsContext) => {
const paths = [...getRefetchOn(ctx), path]
setCookie(ctx, key, JSON.stringify(paths), {path: '/', maxAge})
}
export const removeRefetchOn = (
path: string,
ctx?: GetServerSidePropsContext,
) => {
const paths = getRefetchOn(ctx).filter((v) => v !== path)
setCookie(ctx, key, JSON.stringify(paths), {path: '/', maxAge})
if (paths.length === 0) {
destroyCookie(ctx, key, {path: '/'})
}
}
こんな感じの、Cookieに path
の配列を持たせて追加・削除をするためのモジュールを作って、UGCを投稿・編集したタイミングで
addRefetchOn(目的のURLパス)
を実行するようにし、その上で、_app.tsx
に
useEffect(() => {
if (getRefetchOn().includes(router.asPath)) {
removeRefetchOn(router.asPath)
queryClient.refetchQueries()
}
}, [router.asPath, queryClient])
これを追加した。
このプロジェクトではReactQueryを使っているので queryClient.refetchQueries()
すればそのページの全クエリを再取得できた(多分)。
これで、Cookieに保存されているパスに着地した場合に限り、強制的に最新データを取得しつつ、Cookieから当該パスを削除する(次回はもう強制refetchしない)、という動作が実現できた。
Cookie操作モジュールの
maxAge
をEdge Cacheのstale-while-revalidate
と同じ86400
にしているところがポイントだけど、厳密には、pathAをCookieに追加する→pathAにアクセスしないまま半日経過→pathBをCookieに追加する、みたいなことが起こると、pathBがCookieに追加された時点でまたCookie全体の寿命が86400秒に伸びるので、1日以上経ってもpathAがCookieに残ったままになる、ということが起こり得る。ので、厳密にやるならパスと寿命をセットでCookieに保存するようにすべきだけど、今回のユースケースでは必要以上に長期間Cookieに残り続けてしまっても特に実害はないのでそこまでやらなかった。