Open12

Next.js(App Router)の挙動を色々確認

quo1987quo1987

CreateReactAppがdeprecatedになってしまい半ば仕方なくNext.JSに入門した。

"何となく"で使えるほど簡単なものではない、と実感したので基本的な機能(コンポーネント、キャッシュ、Route Handlerあたり)の挙動確認をメモしていく。

quo1987quo1987

Server Component

Next.JSのReactコンポーネントはデフォルトでServer Componentになる。
さらにfetchもDynamic Functionsも使っていなければ、SSGとなりビルド時に静的なページが生成される。

app/component/server/page.tsx
const ServerComponentPage = () => {
    console.log("ServerComponentPage: build");
    return (
        <div>
            <h1>Server Component(SSG)</h1>
            <p>この日時はビルドした時間が固定で表示される: {new Date().toISOString()}</p>
        </div>
    );
}
export default ServerComponentPage;

これでビルドを実行すると.next/server/app/componentの下にserver.html,server.meta,server.rscが生成されている。

実際に画面にアクセスしてみると、ビルド済みのHTMLレスポンスが返ってくる。

ビルド済みのファイルが返ってくるだけなので、プログラム中に仕込んだconsole.logはビルド時に実行され、アクセス時は実行されていない。

npm run build
...
✓ Collecting page data
...
ServerComponentPage: build
ServerComponentPage: build
...
npm run start
...
 ▲ Next.js 14.1.0
   - Local:        http://localhost:3000
...
✓ Ready in 326ms
(ログは出力されない)
quo1987quo1987

Client Component

クライアント側でレンダリングするパターン。

app/component/client.tsx
'use client';
const ClientComponentPage = () => {
    const date = new Date().toISOString();
    console.log("ClientComponentPage: build,runnning");
    return (
        <div>
            <p>Client Component Page(CSR)</p>
            <p>この日時はリロードするたびに更新される: {date}</p>
        </div>
    );
}
export default ClientComponentPage;

HTMLとJavaScriptが取得され、クライアント側で実行される。

ただしServerComponentと同様にビルド時にRSCが生成されている。

アクセス時も生成されたHTMLが取得され、その後JavaScriptを実行している。これによりクライアント側に静的なコンポーネントを素早く表示できる、という理解。

quo1987quo1987

Server Component + Client Component

Client Componentの子要素としてServer Componentを利用できる。

  • RSCを使ったレンダリングの仕組み上、その逆は不可
app/component/mixed/page.tsx
import ClientComponentPage from "./clie-tcomponent";
import PartialServerComponent from "./server-component";

const MixiedPage = () => {
    return (
        <div>
            <div>Serve Component and Client Component Mixed Page</div>
            <ClientComponentPage>
                <PartialServerComponent />
            </ClientComponentPage>
        </div>
    );
}
export default MixiedPage;
app/component/mixed/client-component.tsx
'use client';
import { ReactElement } from "react";
const ClientComponentPage = ({ children }: { children: ReactElement }) => {
    console.log("ClientComponentPage: runnning");
    const date = new Date().toISOString();
    return (
        <div className="bg-red-500 p-8">
            <div>Client Component Page</div>
            <p>この日時はリロードするたびに更新される: {date}</p>
            <div>
                {children}
            </div>
        </div>
    );
}
export default ClientComponentPage;
app/component/mixed/server-component.tsx
const PartialServerComponent = () => {
    console.log("PartialServerComponent: build");
    return (
        <div className="bg-blue-500">
            <div>Server Component</div>
            <p>ServerComponentはSSGで生成されているため、この日時はビルドした時間が固定で表示される: {new Date().toISOString()}</p>
        </div>
    );
}
export default PartialServerComponent;

Client Component(赤い部分)はアクセスするたびにレンダリングされるが、Server Component(青い部分)はビルド時に生成したコンテンツが返ってくる。

ビルド時に生成された.next/server/app/component/mixed.rscを覗いてみると、jsファイルの参照がある。

.next/server/app/component/mixed.rsc
2:I[1366,["874","static/chunks/app/component/mixed/page-fad79fee7e475cb9.js"],""]
3:I[5613,[],""]
4:I[1778,[],""]
0:["WvhXzB_r6Ch30SSOEDx0r",[[["",{"children":["component",{"children":["mixed",{"children":["__PAGE__",{}]}]}]},"$undefined","$undefined",true],["",{"children":["component",{"children":["mixed",{"children":["__PAGE__",{},["$L1",["$","div",null,{"children":[["$","div",null,{"children":"Serve Component and Client Component Mixed Page"}],["$","$L2",null,{"children":["$","div",null,{"className":"bg-bl

この.next/static/chunks/app/component/mixed/page-fad79fee7e475cb9.jsを見てみると、Client ComponentのJavaScriptらしきものがが出力されている。

.next/static/chunks/app/component/mixed/page-fad79fee7e475cb9.js
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[874],{4566:function(n,e,t){Promise.resolve().then(t.bind(t,1366))},1366:function(n,e,t){"use strict";t.r(e);var i=t(3827);e.default=n=>{let{children:e}=n;console.log("ClientComponentPage: runnning");let t=new Date().toISOString();return(0,i.jsxs)("div",{className:"bg-red-500 p-8",children:[(0,i.jsx)("div",{children:"Client Component Page"}),(0,i.jsxs)("p",{children:["この日時はリロードするたびに更新される: ",t]}),(0,i.jsx)("div",{children:e})]})}}},function(n){n.O(0,[971,69,744],function(){return n(n.s=4566)}),_N_E=n.O()}]);

つまりServer ComponentはDOMが構成され.rscに出力され、クライアント側で実行すべきClient Componentは.rscの中にJSファイルの参照を出力している、という理解。

quo1987quo1987

fetchに対するDataCache

fetch検証のために以下のような外部APIを作成、起動しておく。

external-api.js
const http = require('http');

const app = http.createServer((req, res) => {
    const random = (Math.random() * 1000).toFixed(0);


    console.log(`${req.method}:${random}`);
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end(random);
});

app.listen(3005, '127.0.0.1');
console.log('Extenral Server is running on port 3005');

恒久的なfetchのキャッシュ=DataChacheするページコンポーネントを実装する。本文中にも書いているとおり、同じAPIパスでもパラメータが異なればキャッシュの結果は異なるようである。

app/fetch/static/page.tsx
const ServerComponentFetchStaticPage = async () => {
    console.log("ServerComponentFetchStaticPage: build");
    const get_response = await fetch("http://localhost:3005");
    const post_response = await fetch('http://localhost:3005', { method: 'POST' });
    return (
        <div>
            <div>
                <p>GET,force-cache この値は初回ビルド時にfetchするためずっと固定(Data Cache): {await get_response.text()}</p>
            </div>
            <div>
                <p>POST,force-cache リクエストも初回ビルド時にキャッシュされるのでずっと固定(Data Cache): {await post_response.text()}</p>
                <p>ただしGETとは区別してキャッシュされる。</p>
            </div>
        </div>
    )
}
export default ServerComponentFetchStaticPage;

Next.JSのfetchはデフォルトで{cache: 'force-cache'}が指定されており、このキャッシュオプションの場合ビルド時にfetchが失敗するとビルド全体が失敗する。

npm run build
...
TypeError: fetch failed
    at node:internal/deps/undici/undici:12502:13
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async globalThis.fetch (/workspaces/nextjs-hybrid-starter/.next/server/chunks/638.js:1:36426)
    at async a (/workspaces/nextjs-hybrid-starter/.next/server/app/fetch/static/page.js:1:2708) {
  [cause]: AggregateError [ECONNREFUSED]: 
      at internalConnectMultiple (node:net:1117:18)
      at afterConnectMultiple (node:net:1684:7)
      at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17) {
    code: 'ECONNREFUSED',
    [errors]: [ [Error], [Error] ]
  }
}

Error occurred prerendering page "/fetch/static". Read more: https://nextjs.org/docs/messages/prerender-error
...
> Export encountered errors on following paths:
        /fetch/static/page: /fetch/static

ということで先ほどの外部APIを起動した上でビルドを実行する。ビルド時にfetchが実行されるため、このタイミングでAPIにアクセスが発生する。

node external-api.js

## ビルド時のAPIの出力
Extenral Server is running on port 3005
GET:48
POST:600

ページにアクセスして何度リロードしても同じ値が出力される。またDataCacheはrevalidateしない限り更新されないため、ビルド→デプロイを繰り返してもこの値は変わらない。

またこのページはSSGになるため、.next/server/app/fetchの下に静的ページが出力されている。

quo1987quo1987

動的なfetch

次は動的なfetchを織り交ぜてみる。

app/fetch/dynamic/page.tsx
const ServerComponentFetchDynamicPage = async () => {
    console.log("ServerComponentFetchDynamicPage: build,running");
    const get1_response = await fetch("http://localhost:3005");
    const post1_response = await fetch('http://localhost:3005', { method: 'POST' });
    const get2_response = await fetch("http://localhost:3005", {cache: 'no-store'});
    const get3_response = await fetch("http://localhost:3005");
    const post2_response = await fetch('http://localhost:3005', { method: 'POST' });
    const post3_response = await fetch('http://localhost:3005', {method: 'POST', cache: 'force-cache'});
    return (
        <div>
            <div>
                <p>GET,force-cache この値は初回ビルド時にfetchした値がキャッシュされるためずっと固定(Data Cache): {await get1_response.text()}</p>
            </div>
            <div>
                <p>POST,force-cache リクエストも初回ビルド時にキャッシュされるためずっと固定(Data Cache): {await post1_response.text()}</p>
                <p>ただしGETとは区別してキャッシュされる。</p>
            </div>
            <div>
                <p>GET, no-store: この値はcache: no-storeでfetchするため毎回値が変わる: {await get2_response.text()}</p>
                <p><b>ここでダイナミックレンダリングに切り替わり、fetchのデフォルトがno-storeになる</b></p>
            </div>
            <div>
                <p>POST, no-store: ダイナミックレンダリングではfetchのデフォルトがno-storeになっている。このため毎回値が変わる。: {await post2_response.text()}</p>
            </div>
            <div>
                <p>GET, no-store: 2つ前のfetchでRequest memorizationのキャッシュが更新されているため2つ前のGETと同じ値になる: {await get3_response.text()}</p>
            </div>
            <div>
                <p>POST, force-cache: force-cacheを明示的に指定している。ダイナミックレンダリングに変更した後であるため、この値はfetchし直され、1回目のPOSTと違う固定値になる): {await post3_response.text()}</p>
            </div>
        </div>
    )
}
export default ServerComponentFetchDynamicPage;

ページが動的レンダリング(SSR)になる条件はいくつかあるが、fetchのオプションに{cache: 'no-store'}を指定した場合もその一つ。

今回の例は分かりやすくリクエストを直列で実行していくため、3回目のリクエスト以降が動的レンダリングに切り替わる。

ビルド後に何度かページを更新してみる。

ページ本文にも書いているが、以下のような理解。

  1. GET,force-cache: ここは静的レンダリングであるためDataCacheにより初回ビルド時の値が常に表示される。
  2. POST,force-cache: 1と同じ。
  3. GET,no-store: fetchのキャッシュオプションにno-storeを指定すると毎回APIアクセスを実行する。また、これにより以降のレンダリングが全て動的レンダリングに切り替わる。
  4. POST,no-store: 動的レンダリングのコンポーネントはfetchのデフォルトキャッシュがno-storeに切り替わる。このfetchは毎回APIアクセスを行うため、毎回値が変わる。
  5. GET,no-store: このリクエストもキャッシュオプションはno-storeだが、動的レンダリングに切り替わった3.のリクエスト以降で、このAPIパス/メソッドでのリクエストは2回目であるためRequest memorization(Reactのキャッシュ)が働き、結果はキャッシュから返される。このため3.のリクエスト結果を同じ値になる。
  6. POST,force-cach: 動的レンダリングのコンポーネント内でも明示的に`{cache: 'force-cache'}を指定することで、DataCacheとしてキャッシュされる。ただし動的レンダリングに切り替わってからのリクエストであるため2.のリクエストとは異なるキャッシュとして扱われる。
quo1987quo1987

force-staticを指定したページでの動的レンダリング

ページコンポーネントでexport const dynamic='force-static'を指定すると、そのページを強制的にSSGとしてビルドする。

その中でfetchのキャッシュオプションを指定した場合の挙動を確認する。

app/fetch/force-static/page.tsx
export const dynamic = 'force-static';
const ServerComponentWithForceStaticPage = async () => {
    const ssr = await fetch("http://localhost:3005", {cache: 'no-store'});
    console.log('ServerComponentWithForceStaticPage: build, runnning')
    return (
        <div>
            <p>GET,no-store: no-storeを指定していてもビルド時にfetchした値が表示される。: {await ssr.text()}</p>
        </div>
    )
}

export default ServerComponentWithForceStaticPage;

ビルド時にレンダリングされる。

npm run build
...
ServerComponentFetchStaticPage: build
   Generating static pages (14/24) [    ]
...

APIにリクエストが飛ぶ。

node external-api.js 
Extenral Server is running on port 3005

GET:603
...

no-storeは通常は動的レンダリングになるが、force-static指定時はページ自体がSSGであるためAPIリクエストが発生せず常にDataCacheの値が表示される。

quo1987quo1987

force-dynamicを指定したページでの静的レンダリング

今度は逆にページにexport const dynamic='force-dynamic'を指定してfetchをforce-cacheした場合の挙動。

app/fetch/force-dynamic/page.tsx
export const dynamic = 'force-dynamic';
const ServerComponentWithForceDynamic = async () => {
    console.log('ServerComponentWithForceDynamic: runnning.')
    const ssg = await fetch("http://localhost:3005", {cache: 'force-cache'});
    return (
        <div>
            <p>{new Date().toISOString()}</p>
            <p>GET,force-cache: force-cacheの指定が優先され、毎回同じ値が表示される: {await ssg.text()}</p>
        </div>
    )
}

export default ServerComponentWithForceDynamic;

静的なページは生成されないが、DataCacheにより毎回同じ値が表示される。

quo1987quo1987

force-staticのページでrevalidate

いわゆるISRを行うための仕組みとしてfetchのrevalidateがある。force-staticを指定したページでrevalidate: 10を指定したfetchを実行して挙動を確認する。

app/fetch/force-static/revalidate/page.tsx
export const dynamic = 'force-static';
const ServerComponentWithForceStaticAndFetchRevalidatePage = async () => {
    const isr = await fetch("http://localhost:3005", {next: {revalidate: 10}});
    const ssg = await fetch("http://localhost:3005", {method: 'POST', cache: 'force-cache'});
    console.log('ServerComponentWithForceStaticAndFetchRevalidatePage: build, runnning')
    return (
        <div>
            <p>{new Date().toISOString()}</p>
            <p>GET,revalidate: 10秒ごとにキャッシュを再検証(APIリクエストを実行)する: {await isr.text()}</p>
            <p>POST,force-cache: force-cacheのfetchも動的になる: {await ssg.text()}</p>
        </div>
    )
}

export default ServerComponentWithForceStaticAndFetchRevalidatePage;

※Request memoriazationを避けるため2回目はPOSTメソッドを実行

revalidateの指定によって当該のfetchは10秒ごとに値が更新される......のは想定通りだが他のコンポーネントやfetchもこれに引きづられて値が更新されている。

当該のfetch単位でキャッシュが設定されるものと思っていた(ページ単位でのexport const revalidateという指定もある)ので、この挙動についてはイマイチ理解できていないかもしれない。

quo1987quo1987

静的なRoute Handler

SSGされる(はず)のRoute Handlerに対するfetchの挙動。

api/static/route.tsx
const GET = async () => {
    return Response.json({ date: new Date().toISOString() });
}

export { GET };
``

```ts:app/route-handler/static/page.tsx
export default async function ApiRouteSsgPage() {
    // RouteHnadlerによるAPIはビルド時にfetchが失敗するため、ダイナミックレンダリングにする必要がある。
    const response = await fetch(`http://localhost:3000/api/static`,{cache: 'no-store'});
    const response2 = await fetch('http://localhost:3000/api/static');
    const data = await response.json();
    const data2 = await response2.json();
    return (
        <div>
            <h1>このページはSSR</h1>
            <p>no-store: この値はno-storeでfetchしているが、呼び出し先のApiRouteがSSGであるため毎回同じ値(ビルドの日時)が取得される: {data.date}</p>
            <p>no-store: この値は2回目のfetchなのでRequest memorizationによって1回目と同じ値が表示される: {data2.date}</p>
        </div>
    );
}

↑のコメントにも書いているが、サーバーコンポーネント内でRoute Handlerを呼び出す場合は動的レンダリングとする必要がある。(force-cacheが指定されるとfetchに失敗してキャッシュできないため)

ビルドすると.next/server/app/api/static.bodyというファイルにレスポンス値が格納されている。

.next/server/app/api/static.body
{"date":"2024-08-03T06:12:18.070Z"}

ページアクセス時は常にこの値が表示される。

quo1987quo1987

動的なRouteHandler

RouteHandlerはPOSTを実装すると強制的に動的レンダリングになる。

単純にPOSTメソッドを実装したものと、export const dynamic='force-static'を指定したものをfetchしてみる。

app/api/dynamic/route.tsx
const GET = async () => {
    return Response.json({ date: new Date().toISOString() });
}

const POST = async () => {
    return Response.json({ value: 'dummy' });
}

export { GET, POST };
app/api/force-static/route.tsx
export const dynamic = 'force-static';
const GET = async () => {
    return Response.json({ date: new Date().toISOString() });
}

const POST = async () => {
    return Response.json({ value: 'dummy' });
}

export { GET, POST };
app/route-handler/dynamic/page.tsx
export default async function RouteHandlerDynamicFetch() {
    const get1_response = await fetch('http://localhost:3000/api/dynamic',{cache: 'no-store'});
    const get2_response = await fetch('http://localhost:3000/api/dynamic/force-static',{cache: 'no-store'});
    return (
        <div>
            <p>cache: no-store, /api/fetch/dynamic POSTを実装しているRouteHandlerは常にDynamic Renderingになるため常に値が変わる: {(await get1_response.json()).date}</p>
            <p>cache: no-store, /api/fetch/dynamic/force-static POSTを実装しているRouteHandlerは`export const dynamic='force-static'`を指定しても動的: {(await get2_response.json()).date}</p>
        </div>
    );
}

force-staticの指定関係なく、強制的に動的レンダリングとなった。

quo1987quo1987

Router-Cache

Router-Cacheは<Link>タグを検出して、そのリンク先をプリフェッチしてブラウザキャッシュに保存する。

Router-Cacheは<Link>タグで何も指定しなければデフォルトのキャッシュ時間が30秒、パラメータ指定で5分となる。

またuserRouterを使った遷移ではrouter.prefetchによって5分間 のRouterCacheを利用することができる。

app/router-cache/page.tsx
'use client';
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect } from "react";

export default function RouterCachePage() {
    const router = useRouter();
    useEffect(() => {
        router.prefetch('/router-cache/server-component');
    },[]);
    return (
        <div>
            <p className='text-lg'>Router Cache Page</p>
            <div>
                <Link href="/router-cache/server-component">
                    <p className="underline decoration-sky-500">To ServerComponent(Short duration)</p>
                </Link>
                <Link href="/router-cache/server-component?long=true" prefetch={true}>
                    <p className="underline decoration-sky-500">To ServerComponent(Long duration)</p>
                </Link>
            </div>
            <div>
            <button
                className="rounded bg-gradient-to-r from-cyan-500 to-blue-500 p-2 mt-2"
                onClick={() => {
                router.push('/router-cache/server-component?long=true');
            }}>To Server Component(useRouter)</button>
            </div>
        </div>
    );
}
app/router-cache/server-component/page.tsx
import Link from "next/link";


export default async function RouterCacheServerComponentPage({searchParams}:{searchParams: {long?: boolean}}) {
    const response = await fetch("http://localhost:3005",{cache: 'no-store'});
    const data = await response.text();
    const duration = searchParams.long ? '5分間' : '30秒間';
    return (
        <div>
            <h1>Server Component</h1>
            <p>この値はページをリロードするまで、または{duration}変わらない: {data}</p>
            <div>
                <p>{new Date().toISOString()}</p>
                <Link href="/router-cache">
                    <p className="underline decoration-red-400">Back to Router Cache</p>
                </Link>
            </div>
        </div>
    );
}

通常の<Link>によるプリフェッチ

1番上のリンク(Short duration)をクリックして画面遷移する。

以下のようにリクエストが発生する。

一度前のページに戻り再度リンクを辿るとリクエストは発生しない。また画面の値も変わらない。

30秒経ってから再度リンクを辿るとリクエストが発生する。

また一度キャッシュがクリアされるとその後は毎回リクエストが発生=動的レンダリングになるようだ。 再度リロードすると、再び最初の1回はキャッシュされる。

この辺を明記したドキュメントは見つけられなかった。

今度は2番目のリンク(Long duration)をで画面遷移する。

prefetch={true}を指定しているため、5分以上経過してから画面遷移すると再度リクエストが発生する。

userRouterによるプリフェッチ

今度はボタンクリック後のrouter.push()によって画面遷移を行う。

router.prefetch()を実行しているため5分間は同じページが表示される。

クライアントコンポーネントでの挙動

なお試しにクライアントコンポーネントページに対しても確認してみたが、プリフェッチ(RouterCache)の対象にはならないようだった。