🐺

RSCもとでのNextJS 14キャッシュ研究

2024/10/08に公開

初めに

どんなシステムにおいても、キャッシュは重要なことであり、性能向上の効果が非常に明らかです。
フロントエンドとバックエンドが分離されたプロジェクトでは、一般的に使用されるキャッシュ手段はHTTPキャッシュを利用し、静的リソースにはCDNを使用し、バックエンドではRedisを使用してAPIの応答速度を向上させます。

しかし、Next.jsではキャッシュの扱いがやや複雑です。公式ウェブサイトには詳細な説明が記載されていますが、それは良質なドキュメントであるものの、教科書として完璧だとは言えません。

具体的に、次の問題があります:

  1. 説明の順序が乱れており、必要な前提知識を先に説明せずに直接キャッシュについて話し始めました
  2. 各知識点についてだけを説明し、全体のプロセスを記述していない
  3. 効果だけを説明し、実証が不足しています
  4. 価値判断がなく、開発者はどれが非常に有用でどれがあまり使われていないかを理解する必要があります

もちろん、これらは元々ドキュメントの責任ではないかもしれません、ドキュメントは単に参照するために使用されることも合理ですが、より良く理解したい場合は、コミュニティからの記事が必要かもしれません。

RSC+CCのもとでのレンダリングメカニズム

NextJSのキャッシュを理解したい場合、まずはNextJSのレンダリング原理を理解する必要があります。

なぜそう言うのか?公式ドキュメントは冒頭で、RSC Payloadがキャッシュされる対象であることを強調しています。そして、RSCペイロードはRSCのレンダリングにおける重要な部分です。RSC Payloadが何であり、その役割を理解していない場合、対応するキャッシュ手段の意味を理解することは不可能です。

もちろん、次に述べるのはRSCに基づいたレンダリングであり、page routerの状況は大きく異なりますが、ここでは記述しません。

まず、一番簡単のRSC+CCの例を作ります:

src/app/page.tsx
import ClientComp from "./client";

export default async function Home() {

  return (
    <>
      Server Comp Content
      <ClientComp />
    </>
  );
}
src/app/client.tsx
"use client"
import React from "react";
import { useRouter } from 'next/navigation';

export default function ClientComp() {
    const router = useRouter();

    function handleClick() {
        router.push("/other-route");
    }

    return (
        <div>
            <div onClick={handleClick}>route change</div>
        </div>
    );
}

まず最初に、http://localhost:3000 にアクセスしたとき、得るのはRSCでレンダリングされた結果のHTMLです。このHTMLのheaderには次のようなタグが含まれています:
<script src="/_next/static/chunks/app/page.js" async=""></script>
これは、クライアントサイドレンダリングコンポーネント(CC)をDOMにレンダリングするためのJSファイルです。

どのようにしてこれを証明しますか?クライアントコンポーネントを削除し、ページを更新すると、HTMLのheaderでそのスクリプトがもはや表示されなくなります。
注意すべきのは、ウェブサイトに初回のアクセス時にはRSC Payloadが関係していないことです。
RSCが返す結果のHTMLと、CCをDOMにレンダリングするためのJSファイルだけで、初回のレンダリング作業が完了することができます。

ここで厳密に考える人は、1つの疑問を持つかもしれません。それは、JSファイルが構築コンポーネントツリーに必要な情報をどのように知っているのかということです。コンポーネントツリーを構築する能力はReactの中で行われるため、根ノードがどこにあるかやCCがマウントされる場所、RSCからCCに渡されるpropsデータなどが明確に必要です。これらの情報はすべて動的であり、RSC内部インタフェースから返された結果と関連しています。
JS文件はビルド時に生成されるため、その中にはこれらの必要な情報が含まれていません。
では、情報はどこから来るのでしょうか?
実際には、JSファイルはビルド時に生成されますが、HTMLは動的に生成することができます。HTML内にリクエスト結果に応じて変化するscriptタグがあり、ここでは動的なコンテンツをブラウザ環境のグローバル変数として設定します。そのため、JSファイルはグローバル変数を読み取るだけで済みます:


<script>
	self.__next_f.push([1, "d:I[\\"(app-pages-browser)/./src/app/client.tsx\\",[\\"app/page\\",\\"static/chunks/app/page.js\\"],\\"ClientComp\\"]\\n4:[\\"Server Comp Content\\",[\\"$\\",\\"$Ld\\",null,{\\"message\\":\\"Response after 2 seconds!\\"}]]\\n"])
</script>

RSC Payloadの登場は、ルートの変更時に行われます。たとえば、ルートジャンプやルートリフレッシュです。

src/app/other-route/page.tsx
import OtherClientComp from "./_components/other-client";
export default async function OtherRoute() {
    const res = await fetch("http://localhost:3001/api/other-route-dummy");
    const {message} = await res.json();

    return (
        <div>
            OtherRoute
            <OtherClientComp message={message} />
        </div>
    );
}
src/app/other-route/_components/other-client.tsx
"use client"
import React from "react";

export default function ClientComp(props: any) {
    const {message} = props

    return (
        <div>
            client compnent: {message}
        </div>
    );
}
src/app/api/other-route-dummy/route.ts
export async function GET(request: any) {
    const headers = Object.fromEntries(request.headers);
    return new Response(JSON.stringify({ headers, message: "rock and roll!" }), {
      headers: { 'Content-Type': 'application/json' },
    });
}

「route change」ボタンを押すと、ルートリフレッシュが実行され、NetWorkのFetch/XHRでRSC Payloadの請求が表示されます:

このリクエストはRSC Payloadの請求なんですが、RSC Payload本体は見えません。見えない理由は、応答のbody形式が最も一般的なjsonではなくストリームであるためです。本当に見たい場合は、このリクエストを「Copy as fetch」してからコンソールにスクリプトを書いて確認できます:

// fetchは「Copy as fetch」したRSC Payloadのリクエストです。
(async () => {
    const res = await fetch("http://localhost:3001/other-route?_rsc=gnerl", {
  "headers": {
    "accept": "*/*",
    "accept-language": "ja,en-US;q=0.9,en;q=0.8",
    "cache-control": "no-cache",
    "next-router-state-tree": "%5B%22%22%2C%7B%22children%22%3A%5B%22other-route%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2C%22%2Fother-route%22%2C%22refresh%22%5D%7D%2Cnull%2C%22refetch%22%5D%7D%5D",
    "pragma": "no-cache",
    "rsc": "1",
    "sec-ch-ua": "\"Chromium\";v=\"128\", \"Not;A=Brand\";v=\"24\", \"Google Chrome\";v=\"128\"",
    "sec-ch-ua-mobile": "?0",
    "sec-ch-ua-platform": "\"Windows\"",
    "sec-fetch-dest": "empty",
    "sec-fetch-mode": "cors",
    "sec-fetch-site": "same-origin"
  },
  "referrer": "http://localhost:3001/",
  "referrerPolicy": "strict-origin-when-cross-origin",
  "body": null,
  "method": "GET",
  "mode": "cors",
  "credentials": "include"
})
    // text関数を利用してstream形式のデータが見えます。
    const result = await res.text();
    console.log(result)
})()

今、RSC Payloadの本体を見ましょう:

3:I["(app-pages-browser)/./node_modules/next/dist/client/components/layout-router.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]
4:I["(app-pages-browser)/./node_modules/next/dist/client/components/render-from-template-context.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]
1:D{"name":"","env":"Server"}
2:D{"name":"OtherRoute","env":"Server"}
5:D{"name":"","env":"Server"}
6:D{"name":"r0","env":"Server"}
6:null
0:["development",[["children","other-route",["other-route",{"children":["__PAGE__",{}]}],["other-route",{"children":["__PAGE__",{},[["$L1","$L2",null],null],null]},[null,["$","$L3",null,{"parallelRouterKey":"children","segmentPath":["children","other-route","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null],["$L5","$6"]]]]
5:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Create Next App"}],["$","meta","3",{"name":"description","content":"Generated by create next app"}],["$","link","4",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"16x16"}]]
1:null
7:I["(app-pages-browser)/./src/app/other-route/_components/other-client.tsx",["app/other-route/page","static/chunks/app/other-route/page.js"],"default"]
2:["$","div",null,{"children":["OtherRoute",["$","$L7",null,{"message":"rock and roll!"}]]}]

具体の各項が何を意味しているかは理解しにくいかもしれませんが、用途は推測するのは難しくありません。RSC Payload はルーティング更新時に取得されるため、その役割は新しいルートを描画し、新しいコンポーネントツリーを構築することであることは間違いありません。具体的は:

  1. レンダリングされたHTML(初回は完全体HTMLなんですが、RSC Payloadの中のHTMLはRSC対応するの部分だけです)
  2. Server ComponentからClient Componentに渡されるprops(上の最後一行で見えますよ)
  3. レンダリングするClient Componentのプレスホルダー
  4. Client ComponentへのJSファイルの参照

それでは、RSC Payloadのリクエストをトリガーしたのは誰であり、そしてRSC Payloadを消費したのは誰でしょうか?
リクエストをトリガーしたのは、_next/static/chunks/app/page.jsというJSリソースだと思います。このファイルには、ルート変更時にRSC Payloadのリクエストが追加されたと考えられます。

RSC Payloadの結果を処理する方については、筆者も確信が持てませんが、_next/static/chunks/app/page.jsでRSC Payloadの中のHTMLをDOMツリーに反映し、コンポーネントツリーを再構築し、新しいルートでCCコンポーネントをレンダリングするためのJSファイルを読み込むことが適切な推測だと考えられます。その後、新しいルートでは、CCをレンダリングするために使用されるJSファイルも、RSC Payloadに存在するPropsの値を読み取ってCCをレンダリングします。

RSC+CCのリンダリングメカニズムは以上となりますね。まとめましょう:

  1. 初回アクセスの時、RSCによって生成されたHTMLファイルがDocument全体とRSC部分をレンダリングし、JSファイルによってCCがレンダリングされます。JSファイルはHTMLファイルのヘッダー内のscriptタグでインポートされます。

  2. ルートジャンプ時にRSC Payloadのリクエストが発生し、これにより新しいルートのRSCがレンダリングされます。また、JSファイルを使用して新しいルートのCCもレンダリングされます。JSファイルのアドレスはRSC Payload内に存在します。

このセクションでは、RSC+CCレンダリングに使用されるリソースと、各リソースがどの段階でどのような役割を果たすかについて説明したいと思います。これらを知っていることで、これらのリソースをキャッシュすることが実際にどんな効果があるのか理解できます。

Vercelのエッジキャッシュ

もう1つの必要な事前知識は、Vercelでデプロイ後のサーバーアーキテクチャです。

実際の状況では、NextJSプロジェクトがVercelにデプロイされるケースは少数かもしれませんが、Vercel上でのサーバーアーキテクチャを理解することは重要です。

Next.js自体のキャッシュ設計はVercelと結びついており、VercelはネイティブでNext.jsのこれらの機能を完全にサポートしています。他のアーキテクチャがサポートするかどうかは、アーキテクチャの具体的な実装によるため、この種の混乱を避けるためには、まずVercelがどのように動作するかを理解することが最も適切です。

もし、Vercelの状況のメカニズムを理解したら、他の状況を理解することも難しくないと思います。

Vercelにデプロイすると、メインサーバーと世界中のエッジノードがあります。 エッジノードは、HTML、RSC Payloadおよびインターフェースから返された結果など、さまざまなリソースをキャッシュします。 メインサーバーは、リソースがキャッシュされているかどうかを最終的に判断するために使用されます。キャッシュが無効または存在しない場合、最新の結果を生成します。ただ、ユーザークライアントは主サーバと通信ではなく、常に最寄りのエッジノードと通信していると考えられます。

では、ユーザーがサーバーからどのようなコンテンツを取得するかを明確する必要があります。
初回アクセスの時は、現在のルートに対応するHTMLとCCレンダリング用のJSファイルです(CCが存在する場合)。
ルート更新の場合は、RSC Payloadのリクエストと新しいCCレンダリング用のJSファイルです(新しいルートでCCが存在する場合)。

次に、これらのリクエストにサーバーがどのように応答するかですか?.next/server/appディレクトリーの下で、各ルートに対応するJSファイルを見つけることができます。キャッシュがない場合、サーバーは、ルートにアクセスすると対応するJSファイルを実行し、その結果を返します。

それが簡単なことです、魔法はありません。さらに、当ルートのpage.tsxでexport const runtime = 'edge'を書いて、このルーテをエッジノードにデプロイすることができます。そのようにすると、クライアントからのリクエスト時に、エッジノードは最新の結果を自分自身から取得し、メインサーバーとやり取りする必要がありません。性能は向上していますが、エッジノードの性能制限とサポートされているAPIの制限を考慮すると、推奨されるエッジノードに展開するのはユーザーの身元に基づくルーティングリダイレクトなどミドルウェアだけです。

NextJSのキャッシュ手段

上記の背景知識をちゃんと理解した後に、NextJSのキャッシュメカニズムを本当に理解できると思います。

Request Memoization

これは簡単のことであり、上記の知識は特に要らないです。
簡単に言うと、特定のルート下のすべてのRSCのfetchは、入力が同じの場合、出力も一致します。
これは、RSCツリーが非常に複雑な場合にデータ共有問題を解決するために使用されます。そのようにして、データを多くの世代のRSCまで伝達する必要がなくなります。
しかしぶっちゃけ、一般的にRSCはほとんど複雑ではありませんし(CCの方が複雑可能性が高い)、個人的にはそんなに役立つと感じません。

Full Route Cache

画面にアクセス時、最初トリガーされるキャッシュは「Full Route Cache」です。
Full Route Cacheの対象はRSC Payloadとルートに対応するHTMLです。保存場所はサーバーにあり、エッジノードとメインサーバーを含みます。

キャッシュが生成されるタイミングについては、明らかにクライアントのリクエストに応答した後、キャッシュが残ります。ただ、特定の条件を満たす場合は、ビルド段階でRSCの結果を計算し、その結果をキャッシュします。このプロセスはプリリンダリングと呼ばれます。

プリリンダリングをトリガーする仕組みは結構複雑なんです。まず、強制的に指定ことができます。
その文はexport const dynamic = 'force-dynamic';export const dynamic = 'force-static';。前者は、強制的にプリレンダリングをトリガーしないし、サーバーがリクエストに応答した後もコンテンツをキャッシュしません。一方、後者はプリレンダリングを強制的にトリガーし、サーバーが要求に応答した後もコンテンツをキャッシュします。
ただ、一般的に強制設定はいらないことなんです、NextJSは自動的に判断することができます。

例えば、セッションからユーザーIDを取得し、そのIDでユーザーのデータをリクエストする場合、明らかにこのルートの内容はログインしたユーザーによって変化するため、動的なルートと判断します。
また、もし
cookies()
headers()
unstable_noStore()
unstable_after()
searchParams
などのAPIを使用した場合も、動的なルートと見なされます。
また、動的ルーティングが使用されている場合、例えばproducts/[id]/page.tsxページを使用して特定の商品の詳細ページをレンダリングする場合、それも動的と見なされます。
動的なルートは、一般的に各アクセスごとに結果が異なるから、このような結果をプリリンダリングやキャッシュする必要はありません。

もし、ページがインターフェースのない純粋な静的コンテンツであるか、またはインターフェースリクエストがありますが、パラメーターがすべて確定しており、動的APIを回避している場合は、キャッシュを使用できると判断されます。

実際NextJSは初回アクセスが遅いとしばしば批判されていますよ。一部の場合ではプリレンダリングを行うことで初回アクセスも瞬時になる効果を得ることができます。しかし、どのようにしてプリレンダリングが発生したかを証明することができるのでしょうか?

一番有効な手段はビルド結果を確認します。もしページが静的として指定または判定された場合、ビルド後に対応するHTMLファイルをビルド結果で確認できます。

src/app/about/page.tsx
export default function AboutPage() {
  return (
    <div>
      <h1>About</h1>
    </div>
  );
}


このHTMLファイルは、aboutルートに直接アクセスする際に結果を直接生成するためのものです。もちろん、aboutルートに直接アクセスせず他のルートに最初にアクセスした場合、aboutルートに初めてアクセス際に最初のリクエストはRSCペイロードとなります。このRSCペイロードもビルド中で生成されました、about.srcです。

利用できることを確信した後に知りたいのは、やはり避け方です。ブルド時も固定したコンテンツは怖いです。完全な静的コンテンツはもちろん問題ありませんが、動的ルーティングや動的APIを避ければ、データベースの読み取りなどの操作を含むRSCもプリレンダリングできます。DBの内容変更の頻度は、構築の頻度よりも絶対に高いため、このような状況では最新データを読み取れなくなる可能性が高いです。

だから避け方は絶対必要です。完全に要らない場合は、上記も記述しましたが、export dynamic = 'force-dynamic'という文ができます、またexport revalidate = 0の効果も同じです。

ただ、強制キャッシュしないは稀なケースであり、通常はこのようなことは行いません。やはり初回アクセス時に瞬時に表示されることを確保するために、プリレンダリングが必要ですが、コンテンツの期限切れを避けるよう努力する必要があります。そのため、通常私たちが行う操作はISR(Incremental Static Regeneration)またはOn-Demand Revalidationです。

export revalidate = 300ような文を書けば、ISR実現することができます。この意味は、味わう時間と似ています、export revalidate = 300を書けば、このRSCの結果(HTMLとRSC Payload)の賞味期限は5分です。でも、無駄な計算と更新を避けるため、賞味期限が切れた時、サーバーは自動的に更新しない、ユーザーからのリクエストを受けることが必要です。ユーザーのリクエストを受けた際、サーバーはリソースの賞味期限をチェックし、古いキャッシュをユーザーに返しうちに最新の結果を取得してキャッシュを更新します。

読みだけで実践しないと、本当の理解は絶対にできない、実験して理解しましょう:

src/app/other-route/page.tsx
import { promises as fs } from 'fs';
import path from 'path';

export default async function OtherRoute() {
  const filePath = path.join(process.cwd(), 'data.json');

  const fileContents = await fs.readFile(filePath, 'utf-8');
  const data = JSON.parse(fileContents);
  
  return (
    <div>
      OtherRoute:{data.sharedValue}
    </div>
  );
}
export const revalidate = 30;

srcディレクトリと同じ階層にdata.jsonファイルが存在します。

data.json
{
  "sharedValue": 0.06547779252650266
}

npm run buildして、プリリンダリングをトリガーします。そのあと、npm run startして、サーバーを起動します。
data.jsonのsharedValueを0.03547779252650266に変更します。変更しましたが、キャッシュが存在するため、http://localhost:3000/other-routeにアクセスすると、結果はまだ0.06547779252650266です。約30秒待って、other-routeページをリフレッシュすると、サーバーは新しいデータを計算しますが、クライアントをその計算結果を待たせないため、古いキャッシュを返します(これはwhile-revalidateメカニズム)。したがって、リフレッシュ後に表示されるのは依然として0.06547779252650266ですが、もう一度リフレッシュすると最新の結果である0.03547779252650266が取得できます。

実験結果は上記を完全に証明しましたが、最初にwhile-revalidateメカニズムをトリガーするユーザーは、古いデータを取得しちゃったことは少し気になります。これを避けたいなら、On-Demand Revalidationを利用してください。

src/app/other-route/page.tsx
import { promises as fs } from 'fs';
import path from 'path';

export default async function OtherRoute() {
  const filePath = path.join(process.cwd(), 'data.json');

  const fileContents = await fs.readFile(filePath, 'utf-8');
  const data = JSON.parse(fileContents);
  
  return (
    <div>
      OtherRoute:{data.sharedValue}
    </div>
  );
}
src/app/page.tsx
"use server"
import {changeFile} from "./server-actions";

export default async function HomePage() {

  return (
    <div>
      <h1>Home</h1>
      <form action={changeFile}>
        <br />
        <button type="submit">Change File</button>
      </form>
    </div>
  );
}
src/app/server-actions/index.ts
"use server"
import { promises as fs } from 'fs';
import path from 'path';
import { revalidatePath } from 'next/cache';

export async function changeFile() {
    const filePath = path.join(process.cwd(), 'data.json');
    const newData = { sharedValue: Math.random() };

    try {
        await fs.writeFile(filePath, JSON.stringify(newData, null, 2));
        console.log("File written successfully:", filePath);
        revalidatePath("/other-route")
    } catch (error) {
        console.error("Error writing file:", error);
    }
}

今回はexport const revalidate = 30;を削除しました、この結果プリリンダリングから生成したキャッシュは永遠に存在します。
root routeでキャッシュをリフレッシュするためのChange Fileボタンを設置しています、それをクリックして、data.jsonが更新した後、revalidatePath("/other-route")を実行されます、この文の目的は、このルートに関連するキャッシュをすべてクリアし、サーバー側で保存されたキャッシュだけでなく、次のセクションで説明されるクライアントルートのキャッシュも含めることです。次のセクションでもう一度この点に触れますので、今すぐこの点を理解する必要はありません。

次は実験結果を確認します。root routeでのChange Fileをクリックしないとキャッシュが永遠に存在します、data.jsonを変更して、何回リフレッシュしても、/other-routeで表示するのは古い値ですが、Change Fileをクリックして、/other-routeに戻して、新しい値が見えます。

このセクションは以上になりますね。まとめすると、以下の点です:

  1. Full Route Cacheの対象主にはRSCの計算結果、HTMLとRSC Payload
  2. Full Route Cacheの存在場所はサーバーです、メーンサーバーもエッジノードも
  3. Full Route Cacheの保存タイミングは、ビルド時(強制指定と自動判断メカニズム)とクライアントがリクエストを開始した後
  4. 強制指定で完全に無効にすることができます
  5. ISR(Incremental Static Regeneration)やOn-Demand Revalidationを使用すると、キャッシュの更新が可能になります。

本来ここで終わるべきだが、まだ2つの事柄がある。

第一はfetchです、実際Full Route Cacheの動作はRSCの中のfetchと関係が深いです、例えfetchのcacheオプションはno-storeなら、Full Route Cacheは無効になるみたいです。完全にfetchについて触れなかった理由は、通常RSCでデータを取得する際にfetchを使用すべきではないと考えるからです。代わりにDBを直接操作すべきです。もちろん、高負荷のシナリオや外部インターフェースに依存している場合は、fetchを使用する必要があるかもしれませんが、少数のケースのために本来単純でないメカニズムをより複雑にすることは避けたいと思っています。

第二つは、Vercelにデプロイする際のエッジノードとメインサーバー上でのキャッシュ、ストレージおよび更新の具体的な動作に関するいくつかの推測です。ここでは実験検証が困難なため、単なる筆者の推測であり、誤りがある可能性がありますので、注意してお読みください。
RSCの計算結果の有効期間を300秒に設定します(export const revalidate = 300;)。
ビルドが完了した時間点を0とし、100秒の時にクライアントがリソースにアクセスすると、有効期限が切れていないため、エッジノードはメインサーバーからキャッシュを取得して自身に保存します。そのため、エッジノードのキャッシュの有効期限は400秒です。
そして350秒にアクセスした場合、メインサーバーはすでに有効期限切れですが、エッジノード上のリソースはまだ有効期限切れではないため、キャッシュを利用できると思います。
これは筆者が推測したエッジノードとメインサーバーのISR時の協力動作であり、ただの推論です、別に重要ではないし、正確性を保証するものではありません。

Client-side Router Cache

NextJSのドキュメントは、Client-side Router Cacheについての紹介もかなり曖昧です。実際には
、Client-side Router Cacheの対象が主にRSC Payloadであると考えています。また、ブラウザのスクロール位置もキャッシュされるようです。これらの情報はブラウザのメモリに保存されます。

次に、Client-side Router Cacheの2つの存在形態とキャッシュの使用を避ける方法について紹介します。

prefetchを介して実現された初期Client-side Router Cache

初回アクセスの時、デフォルトではキャッシュは発生しません。refetchを使用する必要があります。

例えば、Linkタグにprefetch属性を追加します。このように、レイアウトに多くのLinkを書いて、すべてにprefetchを付けた場合、あるルートにアクセスすると、他のルートのRSCペイロードも読み込まれます。そのため、別のルートに入るとすぐにページが表示されます。

src/app/page.tsx
'use client';
export default function HomePage() {
  return (
    <div>
      <h1>Home</h1>
    </div>
  );
}
src/app/layout.tsx
import Link from 'next/link';

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body>
        <main>
          <div>
            <Link prefetch href="/other-route">other-route</Link>
            /
            <Link prefetch href="/">home</Link>
          </div>
          {children}
        </main>
      </body>
    </html>
  );
}
src/app/other-route/page.tsx
import OtherClientComp from "./_components/other-client";

export default async function OtherRoute() {
  await new Promise(resolve => setTimeout(resolve, 5000));

  return (
    <div>
      OtherRoute
      <OtherClientComp message={123} />
    </div>
  );
}

export const dynamic = 'force-dynamic';

prefetchは開発モードでは無効ですので、効果を確認するにはnpm run buildを先に実行してからnpm run startを実行する必要があります。

localhost:3000をアクセスすると、Networkのfetch/XHRでhttp://localhost:3000/other-route?_rsc=1iwkqというリクエストが見えます。5秒待ってから/other-routeにアクセスすると、すぐに入れることがわかります。

prefetchを削除した場合、再度npm run buildを実行してからnpm run startを実行すると、http://localhost:3000/other-route?_rsc=1iwkqというリクエストが存在しなくなります。そのため、ルートルーターで何秒待っても/other-routeにアクセスする際に、5秒の待つ必要があります。

おそらく読者の中には、「export const dynamic = 'force-dynamic';」という文に気づいた方もいるかもしれませんが、これは非常に重要な文であり、RSCがプリレンダリングされないことを意味します。
プリレンダリングとプリフェッチは完全に異なる概念であり、プリレンダリングはビルド段階で発生し、サーバー側でRSCペイロードのキャッシュを生成することを意味します。
つまり、RSCを実行してRSCペイロードを取得する必要がなくなります。/other-routeにアクセスする場合際に、すぐにRSCペイロードを取得して描画することができますから、プリフェッチの効果が確認できないになります。
この部分ではプリフェッチの効果を確認するため、「export const dynamic = 'force-dynamic';」という文を強制的に使用してプリレンダリングを無効化する必要があります。

後のセクションでは、プリレンダリングについて詳しく説明します。

1回のルーターへのアクセス後、短時間内に再度アクセスするとキャッシュがデフォルトで使用されます。

上記のように、prefetchを使用する必要はありません。これはデフォルトの動作です。
特定のルートに入り、そこから別のルートに移動し、その後一定時間(おおよそ1分)以内に戻ってくると、RSC Payloadリクエストが発生しないし、ページが瞬時にレンダリングされます。

Client-side Router CacheのOpting out

Client-side Router Cacheは非常に効果あるなんですが、キャッシュを避ける必要がある状況に遭遇する可能性が結構あります。
例えば、すべてのルートをprefetchしてから、ルートAでデータベースを更新し、その後ルートBに移動して更新の結果を確認しようっていうのはよく直面するシチュエーションです。しかし、Bのデータはキャッシュされた結果で、Aで行った変更が反映されていないしまいます。
それは絶対避けたいことなんですよね、以下、実際の例を通じてこの点を避ける方法について説明します。

src/app/layout.tsx
import Link from 'next/link';

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body>
        <main>
          <div>
            <Link prefetch href="/other-route">other-route</Link>
            /
            <Link prefetch href="/">home</Link>
          </div>
          {children}
        </main>
      </body>
    </html>
  );
}

src/app/page.tsx
"use server"
import {changeFile} from "./server-actions";

export default async function HomePage() {
  return (
    <div>
      <h1>Home</h1>
      <form action={changeFile}>
        <br />
        <button type="submit">Change File</button>
      </form>
    </div>
  );
}
src/app/server-actions/index.ts
"use server"
import { promises as fs } from 'fs';
import path from 'path';
import { revalidatePath } from 'next/cache';

export async function changeFile() {
    const filePath = path.join(process.cwd(), 'data.json');
    const newData = { sharedValue: Math.random() };

    try {
        await fs.writeFile(filePath, JSON.stringify(newData, null, 2));
        console.log("File written successfully:", filePath);
        // revalidatePath("/other-route")
    } catch (error) {
        console.error("Error writing file:", error);
    }
}
src/app/other-route/page.tsx
import { promises as fs } from 'fs';
import path from 'path';
import OtherClientComp from "./_components/other-client";

export default async function OtherRoute() {
  const filePath = path.join(process.cwd(), 'data.json');

  const fileContents = await fs.readFile(filePath, 'utf-8');
  const data = JSON.parse(fileContents);
  await new Promise(resolve => setTimeout(resolve, 2000));
  
  return (
    <div>
      OtherRoute:{data.sharedValue}
      <OtherClientComp message={123} />
    </div>
  );
}

export const dynamic = 'force-dynamic';

これは簡単の例ですね、data.jsonファイルを追加し、ファイルを変更してDBの変更をシミュレートします。
開発モードはprefetchできないので、npm run buildnpm run startを実行してサーバーを起動します。
最初に、2秒待ってprefetchが完了するのを待ちます。あと、Change Fileをクリックして、data.jsonファイル内のsharedValueを確認します。
次、/other-routeにジャンプします、このルートのprefetchが完了したからすぐにリンダリングしますが、data.jsonファイル内のsharedValueは最新値ではなく、古い値です。これは、避けたい動作です。

回避する方法も簡単です。server-actionsでファイルの変更を完了した後、revalidatePath("/other-route")を実行すると、other-routeのキャッシュが無効になります。再度クリックするか、マウスをリンクラベルに重ねると、other-routeのRSCが再度実行され、最新のファイル値を取得できます。
注意すべき点は、ここでのキャッシュ更新のメカニズムが単にother-routeのRSCペイロードのキャッシュを無効にするだけではなく、すべての期限切れのルートキャッシュも無効にするという点です。これにより、現在のページもリフレッシュされる可能性があり、これは通常想定される動作ですから、NextJSはこの動作を回避する手段を特に提供していないようです。

Data Cache

まず、全体的に認識します。個人的な意見ですが、RSC時代においては、fetchは通常、外部インターフェースをリクエストする場合にのみ使用する必要があると考えています。それ以外の場合は、直接RSC内またはServer Actions内でDBから読み取りほうがいいと思います。

それが使用される場面が比較的少ないと感じています(偏見の可能性もある)ので、詳細に説明しますが、関連する実験は行いません。

Data Cacheの対象はfetchリクエストです。
保存する場所もサーバー側です。
発生するタイミングは、サーバーがクライアントのfetchリクエストに応答した後です。デフォルトで、応答したあとはキャッシュしますが、fecthのcacheをno-storeに設定して、強制キャッシュしないことができます。

fetch('https://...', { cache: "no-store" })

revalidate(キャッシュを無効にする方法)には、主に3つの方法があります:

第一目はrevalidateの指定です。
fetch(https://..., { next: { revalidate: 3600 } })
こちらの動作はFull Route Cacheと同じwhile-revalidateメカニズムを適応しています。

第二目はrevalidatePathです。
これもFull Route CacheのrevalidatePathと同じです、ただ対象はfetchの結果。

第三目はrevalidateTagです。
これはData Cache専用のクリア方法です、fetchだけがタグを指定できるからです:fetch(https://..., { next: { tags: ['a', 'b', 'c'] } })
キャッシュをクリアしたいとき:revalidateTag('a')
ここのデザインも面白いですね、1つのリソースに複数のタグを付けることができます。クリアする際は、その中から1つ指定すればクリアできます。この設計により、関連する複数のリソースのキャッシュを一度に簡単にクリーンアップできます。

デフォルトでは、Data Cacheは一度のリクエスト後にパラメータが変わらない限り結果が常に同じであるようです。したがって、前述のキャッシュ再検証手段は非常に重要です。

unstable_cache

通常の場合、Full Route CacheとClent-side Route Cacheだけで十分です。これらはどちらもルートをキャッシュする(RSC HTML、RSC Payload)ものですが、一部のケースではより細かい粒度のキャッシュが必要になることもあります。

例えば、あるルートは、リンダリングするために時間かかるな計算と頻繁に変更されるデータが必要です。

function RSC() {
    const res0 = heavyCalc();
    const res1 = getFrequentlyChangeData();
    return (
        <CC res={[res0, res1]} />
    )
}

初めてアクセスしたときにすぐに表示されるようにするため、動的APIを避け、このルートをプリレンダリング可能にしました。
最新のデータを取得するためには、frequentlyChangeDataを変更した後にrevalidatePathを実行して、Route Cacheを無効にする必要があります。
ただし、Route Cacheが無効になった場合は、再度アクセス時にheavyCalcを実行します。だから、今回のアクセスは結構時間かかります。でも、実際本来の目的は最新のfrequentlyChangeDataを取得したいですから、heavyCalcの実行は無駄な作業です。
どうやって余計なheavyCalcの実行を回避しますか?unstable_cacheが使えます。

src/app/other-route/page.tsx
import { unstable_cache } from "next/cache";

const heavyCalc = unstable_cache(
  async () => new Promise<number>(resolve => setTimeout(() => resolve(Math.random()), 10000)),
  [],
  {
    tags: ["heavyCalc"]
  }
);

export default async function OtherRoute() {
  const result = await heavyCalc();

  return (
    <>
      OtherRoute
      <div>heavy calc result: {result}</div>
    </>
  );
}

heavyCalcのキャッシュが簡単に完了しましたが、getFrequentlyChangeDataの更新方法はまだ決めてない、実際こっちはrevalidatePathを利用して、全ルートのキャッシュをクリアする方法はだめです。なぜならというと、revalidatePathの効果は全ルートのキャッシュをクリアするから、unstable_cacheのキャッシュも無くなった

だから、unstable_cacheを利用してキャッシュするし、revalidateTagを利用してキャッシュをクリアすることが必要です。

src/app/page.tsx
"use server"
import {changeFile, revalidateHeavyCalc} from "./server-actions";

export default async function HomePage() {

  return (
    <div>
      <h1>Home</h1>
      <form action={changeFile}>
        <br />
        <button type="submit">Change File</button>
      </form>
      <form action={revalidateHeavyCalc}>
        <br />
        <button type="submit">revalidateHeavyCalc</button>
      </form>
    </div>
  );
}
src/app/other-route/page.tsx
import { promises as fs } from 'fs';
import path from 'path';
import { unstable_cache } from "next/cache";

const heavyCalc = unstable_cache(
  async () => new Promise<number>(resolve => setTimeout(() => resolve(Math.random()), 10000)),
  [],
  {
    tags: ["heavyCalc"]
  }
);

const filePath = path.join(process.cwd(), 'data.json');

const getFileData = unstable_cache(
  async () => fs.readFile(filePath, 'utf-8'),
  [],
  {
    tags: ["dataJson"]
  }
);


export default async function OtherRoute() {
  const fileContents = await getFileData();
  const data = JSON.parse(fileContents);
  const result = await heavyCalc();

  return (
    <>
      OtherRoute
      <div>fileData: {data.sharedValue}</div>
      <div>heavy calc result: {result}</div>
    </>
  );
}
src/app/server-actions/index.ts
"use server"
import { promises as fs } from 'fs';
import path from 'path';
import { revalidateTag } from 'next/cache';

export async function changeFile() {
    const filePath = path.join(process.cwd(), 'data.json');
    const newData = { sharedValue: Math.random() };

    try {
        await fs.writeFile(filePath, JSON.stringify(newData, null, 2));
        console.log("File written successfully:", filePath);
        revalidateTag("dataJson")
    } catch (error) {
        console.error("Error writing file:", error);
    }
}

export async function revalidateHeavyCalc() {
    try {
        revalidateTag("heavyCalc")
    } catch (error) {
    }
}

npm run buildしてからnpm run startして、サーバーを起動します。

まず、画面は待たずに入ることができます。これはunstable_cacheの内容も事前にレンダリングされることを意味します。

Root RouteでChange Fileボタンを押すと、/other-routeに移動し、ファイルのデータが更新されますが、heavy calc resultは更新されません。これは予想通りの動作です。

Root RouteでrevalidateHeavyCalcボタンを押すと、/other-routeに移動し、heavy calc resultを再取得するには10秒待つ必要があります。これも予想通りの動作です。

こうすると、すべてのアクションがほぼ完璧であり、以前よりも細かいキャッシュ制御を実現しました。ただ、その名前が示すように、このAPIはまだ安定しておらず、本格的な生産環境での使用は避けるべきです。

React cache

import { cache } from 'react';

const fetchData = cache(async () => {
  const response = await fetch('/api/data');
  return response.json();
});

export default async function MyComponent() {
  const data = await fetchData();
  
  return (
    <div>
      <h1>Data</h1>
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
  );
}

React cacheとRequest Memoizationの効果は大体同じです、1回のレンダリングプロセス中に、同じプロセスを繰り返し計算することはしないし、代わりに前回の結果を使用します。

異なる点は、Request Memoizationがデフォルトで存在しているのに対し、Reactのcacheは手動で行う必要があることです。また、Reactのcacheは任意の計算結果をキャッシュすることができますが、Request Memoizationはfetchに対して行われます。

両方とも使用シーンはそれほど多くありません。このような最適化手段が存在することを知っておくだけで十分です。

欠陥(Next.js 15ではこの欠陥が修正されました)

通常、NextJSのキャッシュシステムは効率的ですが、小さな欠陥を見つけたようです。NextJSのインターフェースだけを使用する解決策が見つかりません。もしご存知したらお教えてください。

自分の日本学習用の個人アプリで、randomルートで20件のデータをランダムに取得します。
この画面で、分からない単語を選択して、単語帳に追加するという機能があります。prefetchを利用して、単語帳ルートがキャッシュされてますので、単語帳画面で最新なデータを取得するために、単語帳に追加するというアクションでrevalidatePathを利用して、単語帳ルートのキャッシュをクリアすることが必要です。

https://japanese-memory-rsc.vercel.app/

https://github.com/chenbj5515/japanese-memory-rsc

確かに、単語帳ルートのキャッシュをクリアしましたが、この時、NextJSはrandomルートへの最新のRSC Payloadをリクエストし、randomルートのページに反映されます。このようにして最終的な効果は、randomルートで単語を切り出し、単語帳に追加された後、ページ全体が他の20件のデータに更新されることです。これは絶対おかしいな動作ですね。

もちろん、データ取得の流れを変更するとこの問題が避けますが、全体の流れをキャッシュのために変更するのは適切ではないと考えます。問題の根源は、revalidatePathとrevalidateTagの動作が予想を超えていることにあります。名前だけを見ると、このルートとタグのキャッシュをクリアし、その後リソースを再リクエストするなどのように思われるんですが、実際には、random画面内で最新のRSCペイロードをリクエストし、RSCから再レンダリングを開始することにつながりました。

NextJSは最新のデータを適用するためにページを直接リフレッシュすべきではないと思います。ユーザーが画面に入るたびに最新データを取得できるといいです。開発者が他のルートのキャッシュを更新しているとき、現在の画面がリフレッシュされるとおかしいです。この動作はデフォルトではなく、オプションであるべきです。せめて、避けるオプションを提供すればいいですが、そういうオプションが見つかりませんでした。

現在、自分のアプリでrandomの場合はrevalidateを一旦行わないように変更されています。

P.S. 最新のNextJS 15バージョンで、この動作もう修正しました。今revalidatePathの時も全てのキャッシュが無効になりますが、最新のリソースはリクエストしないから、現在のページが強制的に再レンダリングされる問題は発生しません。

まとめ

  1. NextJSキャッシュでは、一番役立つの手段はFull Route CacheとClient-side Route Cacheです
  2. Full Route CacheとClient-side Route Cacheの対象主にはRsc Payloadです
  3. 簡単に言えば、Rsc Payloadは特定のルートのコンテンツをレンダリングするために必要なJSONデータです(初回アクセスの場合はRSC HTMLです)
  4. Full Route Cacheの特殊な動作はプリレンダリングであり、これは非常に重要なパフォーマンス最適化手段です
  5. Full Route Cacheしばしば利用できません。多くの場合、ページはユーザーIDに基づいて個別化されたデータを取得する必要があるからです。このような状況ではプリレンダリングを行うことは不可能であり適していません
  6. Client-side Route Cacheの特別な動作はprefetchです、これも非常に重要な最適化手段であり、広範囲に適用され、ほとんど制限がありません
  7. Client-side Route CacheはRSC Payloadをクライアントに保存します、Full Route CacheはRSC Payloadをサーバー側に保存します。
  8. Client-side Route CacheとFull Route Cacheの関係は密接であり、分析する際には両方を一緒に分析する必要があります
  9. キャッシュを利用する際に、再検証手段を考えなければならないです
  10. unstable_cacheも結構役立つなキャッシュ手段ですが、まだ安定していないです

Discussion