Open15

Next.js(App Router)のお勉強

taro_tenuguitaro_tenugui

Next.js(App Router)の優位性

  • パフォーマンス
  • 開発者体験
  • 多様性

Route定義に関わる用語

https://nextjs.org/docs/app/building-your-application/routing#terminology

Tree / Subtree

  • Tree:階層構造を示す規則
  • Subtree:任意のノードをRootとして始め、Leafで終わるTree
  • Root:TreeまたはSubtreeにおける最初のノード
  • Leaf:子のない最後のノード

Segment / Path

  • Segment:スラッシュで区切られたURLの一部
  • Path:ドメインの後に続くURL文字列

Route Segment

  • Route Sement:特定のPathに対応するSegment
    • Root Segment:一番上のSegment
    • Leaf Segment:子Segmentを持たないSegment

Dynamic Route / Dynamic Segment

  • Dynamic Route:Pathが動的に変わりうるもの、URLパスパラメーターを参照する前提のRoute
  • Dynamic Segment:Dynamic Routeを構成する[]が含まれたSegment
taro_tenuguitaro_tenugui

Server Component / Client Component

  • Server Component
    • サーバーサイドでのみ実行されるコンポーネント
    • 非同期関数として書くことができる
    • ブラウザで実行すべきJavaScriptは送られない
  • Client Component
    • ブラウザ/サーバー両方で実行されるコンポーネント
    • Client Componentからimportされるコンポーネントや関連ファイルもブラウザ向けにバンドルされる

Server Component / Client Componentの使い分け

  • Server Component
    • データを取得する
    • バックエンドリソースを取得する
    • 機密情報を扱う
  • Client Component
    • インタラクティブな機能をもつ
    • コンポーネントに保持した状態を扱う
    • ブラウザ専用のAPIを使用する
    • ブラウザ専用のHooksを使用する
    • React Classコンポーネントを使用する
taro_tenuguitaro_tenugui

動的データ取得と静的データ取得

  • 静的データは、更新頻度が低く、誰もが共有できるデータ
  • fetch関数では、指定がなければ、静的データ取得と扱われて結果がキャッシュされる
  • 動的データとして取得したい場合は、{ cache: "no-store" }を指定する
taro_tenuguitaro_tenugui

Routeのレンダリング

  • Routeごとのレンダリング種別
    • 静的レンダリングRoute:すべてのリクエストに対して、同一のレンダリング結果をレスポンスする
    • 動的レンダリングRoute:リクエストの内容に応じて、異なるレンダリング結果をレスポンスする
  • 動的レンダリングになる要因
    • 動的データ取得の使用:{ cache: "no-store"}を指定したfetch関数
    • 動的関数の使用
      • Cookieの参照
      • リクエストヘッダーの参照
      • URL検索パラメーターの参照
    • Dynamic Segmentの使用
taro_tenuguitaro_tenugui

Routeのメタデータ

  • 静的メタデータ:固定のメタデータを出力。metadataオブジェクトを任意のPageもしくはLayoutからexportする
export const metadata = {
  title: SITE_NAME,
  description:
    "「Photo Share」は、ユーザーが自由に写真を共有し、コメントや「いいね」を通じて交流することができるSNSアプリケーションです。",
};
  • 動的メタデータ:リクエスト内容に応じてメタデータを出力。generateMetadata関数をexportする
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const photo = await getPhoto(params.photoId);
  return {
    title: photo.title,
    description: photo.description,
  };
}
  • メタデータは親から継承することができる
taro_tenuguitaro_tenugui

Route Handler

  • Route Handlerの定義には、Segment構成フォルダにroute.tsというファイルを配置する
  • route.tsからexportする関数は、HTTPリクエストのメソッドに対応する
    • GET、POST、PUT、PATCH、DELETE、HEAD、OPTIONS
    • 対応する関数がない場合、405 Method Not Allowedレスポンスが返される
  • コンフリクトが起こる可能性があるので、/apiフォルダを用意しておくと良い
  • Route HandlerはJSONをレンダリングする
  • ブラウザからのHTTPリクエストはシンプルに、認証認可などをNext.js自身で処理することができる

静的Route Handler

  • レスポンスボティ(JSON)をあらかじめキャッシュファイルとして出力する

動的Route Handler

  • 以下の要因が検出されると動的Route Handlerとみなす
    • Dynamic Segmtn値の参照
    • Requestオブジェクトの参照
    • 動的関数の使用
    • GETとHEAD以外の HTTP関数のexport
    • Segment Config Optionsの指定

https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config

taro_tenuguitaro_tenugui

fetch関数でのデータ取得

export const host = process.env.API_HOST;

export const path = (path?: string) => `${host}${path}`;

export class FetchError extends Error {
  status: number;
  constructor(message: string, status: number) {
    super(message);
    this.status = status;
  }
}

export const handleSucceed = async (res: Response) => {
  const data = await res.json();
  if (!res.ok) {
    throw new FetchError(res.statusText, res.status);
  }
  return data;
};
export const handleFailed = async (err: unknown) => {
  if (err instanceof FetchError) {
    console.warn(err.message);
  }
  throw err;
};
  • 1回のレンダリングで同じデータ取得を行いたい場合は、共有関数にまとめると良い
  • 「Incremental Cache」はデータ取得結果をキャッシュし、必要に応じて更新するメカニズムを持つ(拡張されたfetch関数によるDataキャッシュ)
  • 拡張されたfetch関数は何もしてされない限り、取得したデータを1年間キャッシュする
  • Time-based Revalidation:next.revalidateオプションによる有効期間で指定する
  • fetch関数自体に有効期間を指定すると、キャッシュを活用しWeb APIサーバーへのアクセス頻度を削減できる
taro_tenuguitaro_tenugui

コールバックの紐付けが上手いカスタムフック

import { useEffect, useRef } from "react";

export function useKey(code: string, cb: (event: KeyboardEvent) => void) {
  const cbRef = useRef(cb);
  cbRef.current = cb;
  useEffect(() => {
    const handleKeyPress = (event: KeyboardEvent) => {
      if (event.code === code) {
        cbRef.current(event);
      }
    };
    window.addEventListener("keydown", handleKeyPress);
    return () => {
      window.removeEventListener("keydown", handleKeyPress);
    };
  }, [code]);
}
taro_tenuguitaro_tenugui

fetchのプロミスを処理周りが上手いなーという

  const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    if (!photoData) return;
    try {
      // 【3】アップロードした「写真 URL」を取得(A)
      const imageUrl = await uploadPhoto({ photoData });
      // 【4】投稿内容と「写真 URL」をまとめて Route Handler に送る
      const { photo } = await fetch("/api/photos", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          imageUrl,
          title,
          categoryId,
          description,
        }),
      }).then((res) => {
        if (!res.ok) throw new Error();
        return res.json();
      });
      router.refresh();
      router.push(`/photos/${photo.id}`);
    } catch (err) {
      window.alert("写真のアップロードに失敗しました");
    }
    close();
  };
taro_tenuguitaro_tenugui

childrenは関数でも良いのか

import { useCallback, useState } from "react";
import { clsx } from "clsx";
import { useDropzone } from "react-dropzone";
import { getImageElementFromFile, resizePhoto } from "./fns";

type Props = {
  className?: string;
  areaClassName?: string;
  dragActiveClassName?: string;
  maxUploadRectSize: number;
  maxUploadFileSize: number;
  children?: (isDragActive: boolean) => React.ReactNode;
  onChange: (file: Blob) => void;
};

export function PhotoDndUploader({
  className,
  areaClassName,
  dragActiveClassName,
  maxUploadFileSize,
  maxUploadRectSize,
  children,
  onChange,
}: Props) {
  const [imgSrc, setImgSrc] = useState<string>();
  const onDrop = useCallback(
    async (acceptedFiles: File[]) => {
      const file = acceptedFiles[0];
      const image = await getImageElementFromFile(file);
      const resizedFile = await resizePhoto({
        image,
        size: maxUploadRectSize,
      });
      setImgSrc(image.src);
      onChange?.(resizedFile);
    },
    [onChange],
  );
  const { getRootProps, getInputProps, isDragActive } = useDropzone({
    onDrop,
    accept: { "image/jpeg": [".jpeg", ".jpg"] },
    maxSize: maxUploadFileSize,
    maxFiles: 1,
  });
  return (
    <div className={className}>
      <div
        {...getRootProps()}
        className={clsx(areaClassName, isDragActive && dragActiveClassName)}
        {...(imgSrc && { style: { backgroundImage: `url(${imgSrc})` } })}
      >
        <input {...getInputProps()} />
        {!imgSrc && <>{children?.(isDragActive)}</>}
      </div>
    </div>
  );
}
taro_tenuguitaro_tenugui

クライアント、Route Handler、バックエンドの責務分離がミソポイントか...?

taro_tenuguitaro_tenugui

useFormStateの上手い使い方

export function LikeButtonForm({ photo, isOwner, liked }: Props) {
  // 【1】「いいね」総数が何件か「いいね」済みか否かを、初期値として保持
  const [state, dispatch] = useFormState(
    postLike,
    initialFormState({ liked, likedCount: photo.likedCount }),
  );
  return (
    <form action={dispatch}>
      <input type="hidden" name="photoId" value={photo.id} />
      <LikeButtonComponent
        likedCount={state.likedCount} // 【3】送信に成功した場合「いいね」数が増える
        disabled={isOwner || Boolean(state.liked)}
      />
      {/* 【4】送信に失敗した場合、AlertDialog を表示 */}
      {state.error && (
        <AlertDialogModalComponent
          key={state.updatedAt} // ★ エラーが発生するごとに再マウント、内部状態が破棄される
          status={state.error.status}
        />
      )}
    </form>
  );
taro_tenuguitaro_tenugui
  • Page Routerでは、サーバーサイドのデータを取得できる場所が各画面のRoutewごとに1箇所に限定されていた。getStaticPropsgetServerSidePropsなどで取得したデータを子に渡していた
  • App Routerでは、「コロケーション」を活用して、必要なデータはコンポーネント自身で取得することができる