LoginSignup
31
12

More than 1 year has passed since last update.

Next.js 13で実現されるServer Componentについて調べてみた

Last updated at Posted at 2022-11-03

この記事を書く背景

先日Next.js Confが開催され、かなり話題となっており、自分としても気になったので公式のベータドキュメントを読みながら、手を動かして理解を深めようと思いました。

この記事の目的

自分の理解を深めるための学習記録です

Next.js 13 の主なアップデート

公式のブログ、およびカンファレンスの配信も公開されているのでこの辺りはさらっといきます。

  • app/ Direcotry(beta)
    • Nested layout
    • Server Components
    • Streaming
    • Data Fetching
  • Turbopack(alpha)
  • next/image
  • @next/font
  • next/link
  • OG Image Generation
  • Middleware API Updates

この中で私が特に気になったのはapp/ Direcotryです。
これらの変更は今までのReact/Next.jsでの開発とは
全く異なるユーザー体験/開発者体験となるかもしれません。

app/ Direcotry(beta)

Next.jsといえばFile Based Routingです。
pages/ 配下にファイルを配置するとそれらにPathを割り振ってページとしてアクセスできるようにしてくれます。
今回のアップデートで入った(ベータ版) 機能のうち

  • Nested layout
  • Server Components
  • Streaming
  • Data Fetching

はapp/ Directory内でのみ利用可能です。

このあたりの機能が入ると何が嬉しいのか

ズバリ、StreamingとSuspenseにより、少ない遅延と通信量、クライアント側での処理により
低スペックな端末や低速な通信環境でもユーザー体験を損ないづらいWeb Appが実現可能なところです。
これらはReact18で実装されたServer ComponentsとNext.jsのキャッシュの仕組みにより実現されます。

従来のSSR
image.png
ServerComponentでのSSR
image.png

Server Components

Next.js 13はReact 18から導入されたReact Server Componentsをフレームワークはとして初めて提供しています。

Why Server Components?

Server Componentsはアプリケーションの依存が複雑化し、
JavaScriptのバンドルサイズが大きくなることによるパフォーマンスの劣化を回避します。
最初にページがロードされるときにはNext.jsとReactのランタイムがロードされますが
これらのバンドルサイズはある程度の範囲に収まり、キャッシュも可能です。

このNext.jsとReactのランタイムはアプリケーションの拡大があっても肥大化しません。
ただし、動的なJavaScriptによるインタラクションが必要な場合はClient Componentsを利用する必要があります。

※ReactのJavaScriptによるバンドルサイズの肥大化は近年フロントエンドでのホットな話題で
全く別のアプローチでこれらを解決しようとしている、qwikも注目されています。

When to use Server vs. Client Components?

app/配下ではClient Componentが必要でなければServer Componentを使うようにというのがNext.jsの指針です。

比較表
比較表

これを見る限り、クライアント側で実行が必要なJavaScript(hooks, eventListener, browser-only APIs)
があるかどうかでほとんどどちらを使うべきか決まりそうです。

加えて、公式ドキュメントによると3rdパーティ製のコンポーネントが、クライアント側で実行が必要なJavaScriptを含んでいる場合は、Client Componentでラップする必要があるとのことです。
また、Providerを必要とするパッケージはルートのレイアウトで設定する必要があるようです。

Context

ReactのContext APIはReactのコンポーネント間でデータを共有する際に使われることが多いですが、Server Componentではサポートされていません。
Server ComonentはReact Stateを使ってインタラクションを行わないためです。

Sharing data between Server Components

ContextのようにデータをServer Components間で共有するには、JavaScriptのGlobal Singletonパターンなどを使ってデータを共有するように記述されていました。
これについては後述のPer-request Cachingに記載されています。

Moving Client Components to the Leaves

パフォーマンス向上のため、Client Componentはできる限りコンポーネントツリーの端(葉)の部分に配置するように推奨されています。

Importing Server Components into Client Components

Client ComponentからServer Componentをインポートする場合には直接コンポーネント内にネストするのではなく(画像上)、親のServer ComponentからChildrenとして渡す(画像下)ことが必要になります。

image.png

これにより、ReactはClientで必要なコードとそうでないコードが判別でき、クライアント側でレンダリングする処理の量と、送信するデータ量が小さくなります。

Rendering Environments

Server Componentと対になる概念としてClient Componentがあります。
それぞれの違いはReactのレンダリングの実行環境がサーバーかクライアント(ブラウザー)かというものです。
image.png
Server ComponentとClient Componentはコンポーネントレベルで使い分けることができます。
app/配下ではデフォルトはServer Componentです
image.png

Passing props from Server to Client Components (Serialization)

Server ComoponentからClient Componentへのpropsの受け渡しはシリアライズ可能である(JSON.stringifyできる)必要があるため、直接的に関数やDateオブジェクトなどを渡すことはできません。

Static and Dynamic Rendering on the Server

データfetchのキャッシュの有無とDynmamic Functionsの有無は下の画像のようにレンダリングの方式に影響します。

image.png

Server Componentには

  • Static Rendering
  • Dynamic Rendaring
    があります。

Static Renderingは従来のSSG、ISRに対応し、Dynamic Rendaringは従来のSSR(getServerSideProps)に対応するものとのことです。

Static Rendering

Static Renderingはapp/配下ではデフォルトで適用されます。デプロイ時に描画処理が行われ、地理的にユーザーに近いCDNを通してユーザーに配布されるためです。

Static RenderingはClient ComponentとServer Componentで下記のような違いがあります。

  • Client Component:事前に描画され、キャッシュされたHTMLとJSONをクライアントに送り、クライアント側でHydrationします。
  • Server Component:サーバー側でHTMLの生成とHydratoinが行われます。そのためクライアント側にJavaScriptは必要とされません。
Static Data Fetching

Next.jsのデフォルトのfetchの方法です。force-cacheオプションが適応されます。
いずれかの場所でrevalidateオプションを使った場合、静的に再描画されます。

キャッシュについて詳しくは↓

Dynamic Rendering

Static Renderingの際に、Dynamic Funicitonもしくはキャッシュされないfetchを利用した場合に、Next.jsはDynamic Renderingに処理を切り替えます。その描画の際にもキャッシュされたデータは再利用されます。

Using Dynamic Functions

Dynamic Functionはリクエスト時にのみ取得可能な情報(CookieやHeader)に依存する関数です。
このような関数に依存するServer Componentはリクエスト時に描画されます。

Using Dynamic Data Fetches

キャッシュのないfetchを利用するにはcacheオプションを'no-store'に設定するかrevalidateオプションを0に設定する必要があります。

Data Fetching

Next.js 13のapp/配下ではgetServerSideProps, getStaticProps, getInitialPropsはサポートされておらず、fetchのcacheオプションのみでこれらが表現されます。

Server Componnetのでのfetchは常にサーバーで行われます。
これらにより以下のメリットがあります。

  • APIやDBに直接アクセスできる
  • アクセストークンやAPIキーなどをサーバー側で安全に保持できる。
  • データ取得と描画を同じ環境で行うことでクライアントとサーバーの通信コストとクライアント側のメインスレッドの処理を削減できる
  • 複数のデータソースからの取得を一回のクライアント-サーバー間の通信で実施できる。
  • コンポーネントを平行リクエストでロードすることもできる
  • クライアントに送信するJavaScriptのバンドルサイズを小さくできる

Next.jsチームはServer Component間でpropsによるデータの受け渡しをするのではなく、Server Component内で直接fetchすることを推奨しています。そのために、ReactとNext.jsがfetchをキャッシュしてリクエストが重複しないようにしているとのことです。
Reactはfetch関数を拡張し、リクエストの重複が起きないようにしています。また、Next。jsはfetch関数を拡張し、独自のキャッシュのルールを設定しています。

このモデルではレイアウトから子コンポーネントにデータをpropsで渡すことはできませんが、同じfetchを利用することでパフォーマンスを損なうことなく共有することができます。

上述の通りfetchに渡すcacheのオプションにより描画の方式が変わります。

  • cache: 'force-cache'で従来のSSG
  • next.revalidateオプションを指定すると従来のISR
  • cache: 'no-store'を指定すると従来のSSR
    と同等の挙動になります。
    ただし、Hydrationは全てサーバーで行われます。
Data Fetching Patterns

データ取得は並列と直列の2パターンが実現可能です。

Sequential Data Fetching

下記のように親コンポーネント内でデータ取得をネストするとデータ取得が順番に行われます。

app/artist/page.tsx
async function getArtist(username) {
  const res = await fetch(`https://api.example.com/artist/${username}`);
  return res.json();
}

async function getArtistPlaylists(artistID) {
  const res = await fetch(`https://api.example.com/playlists/${artistID}`);
  return res.json();
}

async function Playlists({ artistID }) {
  // Wait for the playlists...
  const playlists = await getArtistPlaylists(artistID);

  return (
    <ul>
      {playlists.map((playlist) => (
        <li key={playlist.id}>{playlist.name}</li>
      ))}
    </ul>
  );
}

export default async function Page({ params: { username } }) {
  // Wait for the artist...
  const artist = await getArtist(username);

  return (
    <>
      <h1>{artist.name}</h1>
      <Suspense fallback={<div>Loading...</div>}>
        <Playlists artistID={artist.id} />
      </Suspense>
    </>
  );
}
Blocking Rendering in a Route

従来のpages/配下ではSSR時にブラウザのローディングのスピナーが表示され、getServerSidePropsが完了するとそのページが表示されるという形(all or nothing)でした。
新しいapp/配下では、loading.tsxに定義されたローディング状態を表示することができます。
そして、ページ全体ではなく、より末端のコンポーネントでfetchすることでそのデータが必要ないコンポーネントについてはデータの取得を待たずに描画することができます。

Data Fetching without fetch()

ORMなどDBへのデータ問い合わせなどではfetchを使えないことがあります。
その際にはデフォルトのキャッシュの挙動(default cache befaibior)に依存することになります。
さらに複雑な管理が必要な場合はキャッシュの設定を変更することができます。

Default Caching Behavior

fetchを直接利用していないデータ取得はキャッシュの挙動に影響しません。
また、staticかDynamicかはそのページに依存します。
もしそのRoute Segmentがcookies()headers()を利用しているのであれば、そのページはDynamicになり、データ取得はキャッシュされません。そうでなければキャッシュされます。

Segment Cache Configuration

fetchを利用しない3rdパーティのデータ取得方法が構築されるまで、Segment Configurationを利用してcacheの挙動を定義することができます。

app/page.tsx
import type { Post } from '@prisma/client';
import prisma from './lib/prisma';

export const revalidate = 3600; // revalidate every hour

async function getPosts() {
  const posts: Post[] = await prisma.post.findMany();
  return posts;
}

export default async function Page() {
  const posts = await getPosts();
  // ...
}
Mutating Data

Next.jsチームはデータの変更に関して新しい未公開のRFCを作成中で、
今の所は下記のパターンに従うことを推奨するとのことです。

Example
下記のTodoのアイテムのリストを取得するServer Componentがあると仮定します。

app/page.tsx
import Todo from './todo';

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

async function getTodos() {
  const res = await fetch('https://api.example.com/todos');
  const todos: Todo[] = await res.json();
  return todos;
}

export default async function Page() {
  const todos = await getTodos();
  return (
    <ul>
      {todos.map((todo) => (
        <Todo key={todo.id} {...todo} />
      ))}
    </ul>
  );
}

それぞれのTodoコンポーネントをClient Componentにすることで、envnt handler(onClick, onSubmit)を扱えるようになります。

app/todo.tsx
"use client";

import { useRouter } from 'next/navigation';

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

async function update(id: number, completed: boolean, refresh: () => void) {
  await fetch(`https://api.example.com/todo/${id}`, {
    method: 'PUT',
    body: JSON.stringify({ completed }),
  });

  // Refresh the current route and fetch new data from the server
  refresh();
}

export default function Todo(todo: Todo) {
  const router = useRouter();

  return (
    <li>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={(e) => update(todo.id, !todo.completed, router.refresh)}
      />
      {todo.title}
    </li>
  );
}

refresh()を呼ぶことで現在のページがリフレッシュされ、新しいTODOのリストをサーバーから取得します。これらはブラウザのhistoryに影響しませんがページ全体のデータを更新します。
reflesh()する際にはClient Componentの状態はReactとブラウザのメモリで保持している情報は失われます。

Caching and Revalidation

キャッシュにはSegment-level CacheとPer-Reuqest Cacheの2種類があります。

Segment-level Caching

Segment-level Cacheではpage.tsxでexport const revalidate = Seconds
を記述してページ全体のキャッシュスケジュールを決めるか、個別のfetchのオプションで指定する方法があります。

app/page.tsx
export const revalidate = 60; // revalidate this page every 60 seconds
app/page.tsx
// revalidate this page every 10 seconds, since the getData's fetch
// request has `revalidate: 10`.
async function getData() {
  const res = await fetch('https://...', { next: { revalidate: 10 } });
  return res.json();
}

export default async function Page() {
  const data = await getData();
  // ...
}

また、fetchCacheを設定することでfetchのCacheオプションのデフォルト値を上書きすることもできます。

Per-request Caching

Per-request CacheではReactのcache関数を利用してリクエストベースで関数の結果をキャッシュします。
そのため、サーバーのどこでその関数が呼ばれていても、引数が同じであればキャッシュの値を返却します。
コードは以下のようになります。

utils/getUser.tsx
import { cache } from 'react';

export const getUser = cache(async (id) => {
  const user = await db.user.findUnique({ id });
  return user;
});
app/user/[id]/layout.tsx
import { getUser } from '@utils/getUser';

export default async function UserLayout({ params: { id } }) {
  const user = await getUser(id);
  // ...
}

このgetUser関数が他の箇所で同じIDを引数に呼ばれた場合はdb.user.findUnique({ id })は実行されず、キャッシュされた値が返却されます。

fetch関数は既にこのcacheがパッチされているため追加でcacheで囲う必要はありません。
fetch以外のデータ取得の方法もこのcacheで囲うことでコンポーネント間でのデータの共有が可能です。
また、preloadパターンを使って、強制的にキャッシュをクリアすることも可能です。

雑感

ほとんど公式ドキュメントの拙い翻訳のようになってしまいましたが、
全体像とServer Componentsの概念については理解できたように思います。

Server ComponentsはContext管理や動的なpropsの受け渡し、useStateなどでの状態管理などの
Reactの基本的な概念がほとんど利用できず全く新しい概念であるため、これを本番に適用するには
まだまだエコシステムの適応なども含めて時間がかかるだろうという印象を受けました。

しかし、

  • 描画が完了しているところから表示できること
  • JSのバンドルサイズが小さくなること
  • 基本的なHydrationはクライアントでは行わないこと
    を鑑みると、ユーザー体験を大きく変える可能性がありそうだと思いました。
31
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
31
12