Link と ISR が引き起こす Next.js の過負荷
「なんだか Next.js の Static Generate に使っている外部 API 呼び出し回数が多いような?」と思っている方へ。閲覧されもしないページを、ISR(Incremental Static Regeneration)でみだりに再生成していませんか?本稿では、Link コンポーネントの振る舞いと ISG / ISR の組み合わせの際、注意したい prefetch の設定について言及します。
ISG のおさらい
ISG(Incremental Static Generation)は、Next.js がオンデマンドでページを静的生成するアプローチです。「オンデマンドで静的生成する」ことで、ビルドタイムの静的生成をスキップすることができます。
getStaticPaths
の fallback
オプションを true
か 'blocking'
にすることで発動します。
これは膨大な Dynamic route を提供するページであっても、静的生成を現実的なものとします。実際にページがリクエストされるまで、そのページは静的生成されません。ある種の SSR とも言うことができますが、ページが一度生成されてしまえば削除されるまで、再生成は起こりません。
ISR のおさらい
ISR(Incremental Static Regeneration)は ISG の「再生成が起こらない」課題を解決するアプローチです(データ陳腐化防止)。revalidate
オプションを付与することで、一度生成したページであっても、指定経過時間後に再生成を試みます。
revalidate
オプションの付与で ISG は ISR になるわけですが、あまりにも短い指定をすると、タイトルのような過負荷を引き起こすことが懸念されます。
Link と ISG の関係
そもそも、ISG が実行されるタイミングはいつでしょうか?「オンデマンド」と聞くと、1 番目はすぐに想像できると思いますが、2・3 番目は意外な事実かもしれません。
- 1.ページが直叩きされたとき
- 2.Link コンポーネントにマウスオーバーした時
- 3.Link コンポーネントが画面内に入った時(prefetch={true}時)
この仕様は ISG の場合問題にはならず、投機的にページを静的生成してくれるのは歓迎できることです。しかし「一定間隔」を期に静的生成を試行する ISR の場合、問題になり得ます。
Link と ISR の関係
次の様なページがあった場合、どの様なことが起こるでしょうか?
- Link コンポーネントが 30 個マウントされている
- Link コンポーネントで遷移する先が「
revalidate: 1
」 - 30 個の Link コンポーネントがファーストビューに収まっている
正解は「このページを毎秒リロードすると、毎秒 30 ページ分の静的再生成を試行する」です。もし裏側で不本意に 30 倍 API が呼ばれていたのなら、prefetch 設定を見直しましょう。
Link コンポーネントの prefetch 設定は、default で true
です。何気に Link コンポーネントを利用していると、ISR は「被リンクページが閲覧されただけ」で発動します。Link コンポーネントのリンク先が ISR の場合、prefetch={false}
を指定することが無難でしょう。(もちろん revalidate の間隔が十分長ければ、この限りではないとは思います)
prefetch のトリガーになるタイミング
「Link コンポーネントが画面内に入った時」と書きましたが、正確には「画面外 200px 以内に入った時」です。useIntersection
なる custom hooks の isVisible
変化を hook に prefetch
が実行されます。Intersection Observer を使ったこの hook の効果で「スクロールで静的生成を実行する」ページが出来上がります。挙動としては、image の lazyload に似ていますね。
ISG / ISR で getStaticProps
が実行される挙動は next dev
ではなく next start
で立ち上げたローカルの Next.js App でも確認できます。「Link コンポーネントが表示されそうな位置までスクロールされた」 だけで、再生成が試行される価値あるページなのか、ISR を利用する際には注意深く検討したいですね。
参考:https://github.com/vercel/next.js/blob/master/packages/next/client/link.tsx#L245-L271
const [setIntersectionRef, isVisible] = useIntersection({
rootMargin: "200px",
});
const setRef = React.useCallback(
(el: Element) => {
setIntersectionRef(el);
if (childRef) {
if (typeof childRef === "function") childRef(el);
else if (typeof childRef === "object") {
childRef.current = el;
}
}
},
[childRef, setIntersectionRef]
);
useEffect(() => {
const shouldPrefetch = isVisible && p && isLocalURL(href);
const curLocale =
typeof locale !== "undefined" ? locale : router && router.locale;
const isPrefetched =
prefetched[href + "%" + as + (curLocale ? "%" + curLocale : "")];
if (shouldPrefetch && !isPrefetched) {
prefetch(router, href, as, {
locale: curLocale,
});
}
}, [as, href, isVisible, locale, p, router]);
Discussion