この記事の背景、目的
前回の投稿では、Next.js 13のドキュメントのServerComponent関連を一通り見てみたので
今回は実際に手元で触って、デプロイし、挙動を確認したいとおもいます。
https://qiita.com/mjm2kt/items/82bc7cd346701937f4f9
今回作ったコード
Next.js 13:
比較対象SPA(Vite&React)
環境構築
yarn create next-app
appディレクトリを有効化
experimental: {
appDir: true
}
最初のページの実装
Next.js 13のapp/配下では従来のNext.jsのルーティングとは異なり、page.tsxのみがページとして扱われます。
Next.js 13のapp/配下ではデフォルトでServer Componentとして扱われるように設定されています。
実際にデプロイして確認してみると下の画像のようにレスポンスのHTMLに直接page内のElementが埋め込まれています。
つまり、ブラウザ上でのHydrationは行われません。
APIからのデータ取得
話題のfetch APIを試すためにPokeAPIからデータを取得&表示するように実装していきます。
type PokemonListResponse = {
count: number
next: string
results: {
name: string
url: string
}[]
}
const getPokemonList = async () => {
const res = await fetch('https://pokeapi.co/api/v2/pokemon?limit=100&offset=0')
const json = await res.json() as PokemonListResponse
const pokemons = json.results.map(pokemon => {
const id = pokemon.url.split('/').at(-2)
const imgSrc = `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${id}.png`
const url = `/pokemon/${id}`
return({...pokemon, id, url, imgSrc })
})
return pokemons
}
export default async function PokemonIndexPage() {
const pokemons = await getPokemonList()
return (
<>
{
pokemons.map(pokemon => (
<div key={pokemon.id}>
<h4>{pokemon.name}</h4>
<a href={pokemon.url}>{pokemon.name}</a>
<img alt={pokemon.name} src={pokemon.imgSrc} />
</div>
))
}
</>
)
}
先ほど同様レスポンスのHTMLに表示されているDOMがそのまま含まれています。
また、APIから取得したデータもここに含まれています。
Next.js 13のapp/配下ではpageのコンポーネントを非同期関数として実装することができます。
この非同期関数が完了するまではloading.tsxかSuspenceのfallbackが表示されます。
SPAとの比較
同様のページをSPA(Vite+React)で実装したものと比較してみます。
仮想DOMなので、HTMLのbodyはdivが一つのみです。
また、Javascriptがロードされるまでfetchは開始しません。
Static Server ComponentとDynamic Server Componentの比較
Next13の実装をcacheあり、なしで2つのページにしてみます。
デフォルトでキャッシュあり(更新しない)になっているのでfetchのオプションにcache: 'no-store'
をつけて、リクエストごとにfetchが実行されるように変更します。
...
const getPokemonList = async () => {
// デフォルトでキャッシュされる
const res = await fetch('https://pokeapi.co/api/v2/pokemon?limit=100&offset=0')
...
...
const getPokemonList = async () => {
// cache: 'no-store' をつけると毎回実行される
const res = await fetch('https://pokeapi.co/api/v2/pokemon?limit=100&offset=0', { cache: 'no-store' })
...
ビルド時にもfetchは行われるためフェッチするサイズが大きいとbuildの時間が長くなります。
挙動の確認
※ PokeAPIがレスポンス早すぎて差分が見えないためfetchの前に3秒の遅延を挿入しています
https://github.com/macoto1995/next-13/commit/79602ab3321a537d4890c18b731fc45cbac5dc62#diff-ae2e62e1752aea364291e8674b1b68a52aaee0feccc456a82e249f7b70f47a4aR11
Static Server Component
Dynamic Server Component
3秒以上遅延しますが、LayoutとLoading コンポーネントが先に表示されます。
SPA
index.xxxxxx.jsがロードされた後Loadingコンポーネントが表示されます。
また、JSがロードされるまでは真っ白の画面しか表示されません。
Streamingの挙動
Dynamic Server Componentを利用する場合、データ取得開始時にはLayout+Loadingのコンポーネントのみを送っています。
こちらは、データが表示された後でもPreviewから確認できます。
また、HTMLのダウンロード完了後は取得したデータを含んだDOMになっています。
今回検証できなかったこと
Parallel Fetch
複数の非同期でのデータ取得を一つのページで行う場合、並列で実行し、Suspenceで囲うように公式のドキュメントでは説明されています。
これがどのような挙動になるか確認してみます。
import Link from "next/link"
import { Suspense } from "react"
type PokemonResponse = {
id: string
name: string
}
type PokemonListResponse = {
count: number
next: string
results: {
name: string
url: string
}[]
}
const getFavoritPokemon = async () => {
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
await sleep(1000)
const res = await fetch('https://pokeapi.co/api/v2/pokemon/pikachu', { cache: 'no-store' })
const { id, name, } = await res.json() as unknown as PokemonResponse
const imgSrc = `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${id}.png`
const url = `/pokemon/${id}`
return { name, id, imgSrc, url}
}
const getPokemonList = async () => {
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
await sleep(3000)
const res = await fetch('https://pokeapi.co/api/v2/pokemon?limit=100&offset=0', { cache: 'no-store' })
const json = await res.json() as PokemonListResponse
const pokemons = json.results.map(pokemon => {
const id = pokemon.url.split('/').at(-2)
const imgSrc = `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${id}.png`
const url = `/pokemon/${id}`
return({...pokemon, id, url, imgSrc })
})
return pokemons
}
export default async function PokemonIndexPage() {
const favoritPokemon = await getFavoritPokemon()
const pokemons = await getPokemonList()
return (
<>
<h3>Your Favorit Pokemon</h3>
<Suspense fallback={<p>Loading Your Favorit Pokemon...</p>}>
<h4>{favoritPokemon.name}</h4>
<Link href={favoritPokemon.url}>{favoritPokemon.name}</Link>
<img alt={favoritPokemon.name} src={favoritPokemon.imgSrc} />
</Suspense>
<h3>All Pokemons List</h3>
<Suspense fallback={<p>Loading Pokemons...</p>}>
{
pokemons.map(pokemon => (
<div key={pokemon.id}>
<h4>{pokemon.name}</h4>
<Link href={pokemon.url}>{pokemon.name}</Link>
<img alt={pokemon.name} src={pokemon.imgSrc} />
</div>
))
}
</Suspense>
</>
)
}
こちらはデータの取得した順に表示してくれるのかとおもいましたが、両方が完了してからデータが表示されました。
また、Vercelにデプロイしたものでは必ずタイムアウトしてしまったのでローカルでの挙動確認のみ行っています。
よくよく考えると、今回はsetTimeoutでプロセスを占拠しているため、実際のAPIリクエストでは並列で処理が行われるかもしれません。
公式の下の図のように非同期処理が終わった順でレンダリングされることを期待していましたが、今回はそこまで検証できませんでした。
検証には自分でAPIを立てて遅延をコントロールした状態で必要がありそうです。
その他
chakra-uiなどいくつかの人気のUIライブラリはServer Componentに対応していないようです。
Next.js 13がデファクトスタンダードになったら、この辺りを今後の技術選定では気にしていかなければいけないでしょう。
https://chakra-ui.com/getting-started/nextjs-guide
所感
低速回線や低スペックハードウェアを使っているユーザーに対してはやはり、HTMLのみが送られるというのはメリットが大きいです。
ReactのSuspenceの良さをフルで活かすならServer Componentの利用は必須かとおもいます。
ただ、既存のCSRの仕組みとは大きく異なるためUIライブラリやデータ取得で、ベストプラクティスを新たに積み上げていく必要性も感じました。
また、GraphQLをサポートしていないのでServer Component用のGraphQLクライアントがあると嬉しいなあという感想を持ちました。