Next.js(App Router)の挙動を色々確認
CreateReactAppがdeprecatedになってしまい半ば仕方なくNext.JSに入門した。
"何となく"で使えるほど簡単なものではない、と実感したので基本的な機能(コンポーネント、キャッシュ、Route Handlerあたり)の挙動確認をメモしていく。
Server Component
Next.JSのReactコンポーネントはデフォルトでServer Componentになる。
さらにfetchもDynamic Functionsも使っていなければ、SSGとなりビルド時に静的なページが生成される。
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
(ログは出力されない)
Client Component
クライアント側でレンダリングするパターン。
'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を実行している。これによりクライアント側に静的なコンポーネントを素早く表示できる、という理解。
Server Component + Client Component
Client Componentの子要素としてServer Componentを利用できる。
- RSCを使ったレンダリングの仕組み上、その逆は不可
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;
'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;
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ファイルの参照がある。
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らしきものがが出力されている。
(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ファイルの参照を出力している、という理解。
fetchに対するDataCache
fetch検証のために以下のような外部APIを作成、起動しておく。
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パスでもパラメータが異なればキャッシュの結果は異なるようである。
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
の下に静的ページが出力されている。
動的なfetch
次は動的なfetchを織り交ぜてみる。
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回目のリクエスト以降が動的レンダリングに切り替わる。
ビルド後に何度かページを更新してみる。
ページ本文にも書いているが、以下のような理解。
-
GET,force-cache
: ここは静的レンダリングであるためDataCacheにより初回ビルド時の値が常に表示される。 -
POST,force-cache
: 1と同じ。 -
GET,no-store
: fetchのキャッシュオプションにno-store
を指定すると毎回APIアクセスを実行する。また、これにより以降のレンダリングが全て動的レンダリングに切り替わる。 -
POST,no-store
: 動的レンダリングのコンポーネントはfetchのデフォルトキャッシュがno-store
に切り替わる。このfetchは毎回APIアクセスを行うため、毎回値が変わる。 -
GET,no-store
: このリクエストもキャッシュオプションはno-store
だが、動的レンダリングに切り替わった3.のリクエスト以降で、このAPIパス/メソッドでのリクエストは2回目であるためRequest memorization(Reactのキャッシュ)が働き、結果はキャッシュから返される。このため3.のリクエスト結果を同じ値になる。 -
POST,force-cach
: 動的レンダリングのコンポーネント内でも明示的に`{cache: 'force-cache'}を指定することで、DataCacheとしてキャッシュされる。ただし動的レンダリングに切り替わってからのリクエストであるため2.のリクエストとは異なるキャッシュとして扱われる。
force-staticを指定したページでの動的レンダリング
ページコンポーネントでexport const dynamic='force-static'
を指定すると、そのページを強制的にSSGとしてビルドする。
その中でfetchのキャッシュオプションを指定した場合の挙動を確認する。
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の値が表示される。
force-dynamicを指定したページでの静的レンダリング
今度は逆にページにexport const dynamic='force-dynamic'
を指定してfetchをforce-cache
した場合の挙動。
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により毎回同じ値が表示される。
force-staticのページでrevalidate
いわゆるISRを行うための仕組みとしてfetchのrevalidate
がある。force-staticを指定したページでrevalidate: 10
を指定したfetchを実行して挙動を確認する。
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
という指定もある)ので、この挙動についてはイマイチ理解できていないかもしれない。
静的なRoute Handler
SSGされる(はず)のRoute Handlerに対するfetchの挙動。
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
というファイルにレスポンス値が格納されている。
{"date":"2024-08-03T06:12:18.070Z"}
ページアクセス時は常にこの値が表示される。
動的なRouteHandler
RouteHandlerはPOSTを実装すると強制的に動的レンダリングになる。
単純にPOSTメソッドを実装したものと、export const dynamic='force-static'
を指定したものをfetchしてみる。
const GET = async () => {
return Response.json({ date: new Date().toISOString() });
}
const POST = async () => {
return Response.json({ value: 'dummy' });
}
export { GET, POST };
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 };
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
の指定関係なく、強制的に動的レンダリングとなった。
Router-Cache
Router-Cacheは<Link>
タグを検出して、そのリンク先をプリフェッチしてブラウザキャッシュに保存する。
Router-Cacheは<Link>
タグで何も指定しなければデフォルトのキャッシュ時間が30秒、パラメータ指定で5分となる。
またuserRouter
を使った遷移ではrouter.prefetch
によって5分間 のRouterCacheを利用することができる。
'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>
);
}
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回はキャッシュされる。
この辺を明記したドキュメントは見つけられなかった。
<Link pretefch={true}>によるプリフェッチ
今度は2番目のリンク(Long duration)をで画面遷移する。
prefetch={true}
を指定しているため、5分以上経過してから画面遷移すると再度リクエストが発生する。
userRouterによるプリフェッチ
今度はボタンクリック後のrouter.push()
によって画面遷移を行う。
router.prefetch()
を実行しているため5分間は同じページが表示される。
クライアントコンポーネントでの挙動
なお試しにクライアントコンポーネントページに対しても確認してみたが、プリフェッチ(RouterCache)の対象にはならないようだった。