Next.jsで高速eコマースアプリを構築する | エピソード6: パフォーマンス最適化とCore Web Vitals

こんにちは！前回のエピソードでは、Stripeを統合して安全な決済機能を追加しました。今回は、アプリケーションのパフォーマンスを最適化し、GoogleのCore Web Vitals（LCP、CLS、FID）を向上させます。LighthouseやVercel Analyticsを使った分析を通じて、ユーザー体験をさらに向上させ、SEOスコアを高めましょう。React Server Componentsを活用してクライアントサイドのJavaScriptを削減する方法も紹介します！

## このエピソードのゴール

- LighthouseとVercel Analyticsでパフォーマンスを分析。
- LCP（Largest Contentful Paint）をフォント最適化と遅延読み込みで改善。
- CLS（Cumulative Layout Shift）をレイアウト安定化で削減。
- React Server Componentsを活用してJavaScriptの量を削減。
- Lighthouseスコアを90以上にする。

## 必要なもの

- 前回のプロジェクト（`next-ecommerce`）がセットアップ済み。
- Google Chrome（Lighthouseを使用）。
- Vercelアカウント（Analyticsを使用する場合）。
- 基本的なTypeScript、React、Next.jsの知識。

---

## ステップ1: パフォーマンスの分析

まず、現在のアプリケーションのパフォーマンスをLighthouseで分析します。Google Chromeのデベロッパーツールを開き、以下の手順を実行：

1. **Lighthouseレポートの生成**：
   - Chromeで`http://localhost:3000`にアクセス。
   - デベロッパーツール（F12）を開き、「Lighthouse」タブを選択。
   - 「Performance」と「SEO」をチェックし、「Generate report」をクリック。

2. **Vercel Analyticsの設定**：
   - Vercelにプロジェクトをデプロイ済みの場合、VercelダッシュボードでAnalyticsを有効化。
   - 実際のユーザーアクセスデータ（ページ読み込み時間、デバイス別パフォーマンスなど）を確認。

分析結果から、LCP（Largest Contentful Paint）、CLS（Cumulative Layout Shift）、FID（First Input Delay）などの指標を確認します。目標は、Lighthouseスコアを90以上にすることです。

---

## ステップ2: LCP（Largest Contentful Paint）の最適化

LCPは、ページの主要コンテンツが表示されるまでの時間を測定します。商品一覧ページ（PLP）と商品詳細ページ（PDP）を対象に、以下の最適化を行います。

### フォントの最適化

システムフォントを使用している場合でも、カスタムフォントを追加するとLCPが遅延する可能性があります。`src/styles/globals.css`を更新し、フォントの読み込みを最適化：

```css
@tailwind base;
@tailwind components;
@tailwind utilities;

body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}

/* カスタムフォントの読み込み（必要に応じて） */
@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom-font.woff2') format('woff2');
  font-display: swap; /* フォント読み込み中のテキスト表示を最適化 */
}
```

`font-display: swap`を指定することで、フォントが読み込まれる前に代替フォントを表示し、LCPを改善します。

### 画像の遅延読み込み

商品一覧ページ（`src/app/page.tsx`）の画像に`loading="lazy"`を追加し、ビューポート外の画像を遅延読み込みします：

```tsx
<Image
  src={product.image}
  alt={product.title}
  width={200}
  height={200}
  className="w-full h-48 object-cover rounded"
  loading="lazy" // 遅延読み込みを有効化
/>
```

PDP（`src/app/products/[handle]/page.tsx`）では、最初の画像に`priority`を保持し、以降の画像に`loading="lazy"`を適用：

```tsx
<Image
  key={index}
  src={image.node.url}
  alt={image.node.altText || product.title}
  width={500}
  height={500}
  className="w-full h-auto object-cover rounded"
  priority={index === 0}
  loading={index === 0 ? undefined : 'lazy'}
/>
```

---

## ステップ3: CLS（Cumulative Layout Shift）の削減

CLSは、ページのレイアウトが予期せず移動する問題を測定します。以下の方法でCLSを削減します。

### 画像のアスペクト比を固定

画像に幅と高さを明示的に指定することで、読み込み時のレイアウトシフトを防ぎます。`src/app/page.tsx`の画像に`style`を追加：

```tsx
<Image
  src={product.image}
  alt={product.title}
  width={200}
  height={200}
  className="w-full h-48 object-cover rounded"
  loading="lazy"
  style={{ aspectRatio: '1 / 1' }} // アスペクト比を固定
/>
```

### スケルトンローディングの実装

商品データの読み込み中にスケルトンUIを表示して、CLSを防ぎます。`src/components/ProductSkeleton.tsx`を作成：

```tsx
export default function ProductSkeleton() {
  return (
    <div className="border rounded-lg p-4 animate-pulse">
      <div className="w-full h-48 bg-gray-200 rounded"></div>
      <div className="h-6 bg-gray-200 mt-2 rounded"></div>
      <div className="h-4 bg-gray-200 mt-2 rounded w-1/2"></div>
    </div>
  );
}
```

`src/app/page.tsx`を更新してスケルトンUIを表示：

```tsx
import ProductSkeleton from '@/components/ProductSkeleton';

// ... 既存のコード ...

export default function Home({ products }: HomeProps) {
  return (
    <main className="container mx-auto p-4">
      <h1 className="text-3xl font-bold mb-6">商品一覧</h1>
      <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
        {products.length === 0
          ? Array(6)
              .fill(0)
              .map((_, index) => <ProductSkeleton key={index} />)
          : products.map((product) => (
              <Link href={`/products/${product.handle}`} key={product.id}>
                <div className="border rounded-lg p-4 hover:shadow-lg transition">
                  <Image
                    src={product.image}
                    alt={product.title}
                    width={200}
                    height={200}
                    className="w-full h-48 object-cover rounded"
                    loading="lazy"
                    style={{ aspectRatio: '1 / 1' }}
                  />
                  <h2 className="text-xl font-semibold mt-2">{product.title}</h2>
                  <p className="text-gray-600">
                    {parseFloat(product.priceRange.minVariantPrice.amount).toFixed(2)}{' '}
                    {product.priceRange.minVariantPrice.currencyCode}
                  </p>
                </div>
              </Link>
            ))}
      </div>
    </main>
  );
}
```

これで、データ取得中にスケルトンUIが表示され、CLSが削減されます。

---

## ステップ4: React Server Componentsの活用

React Server Components（RSC）は、サーバー側でコンポーネントをレンダリングし、クライアントに送信するJavaScriptを削減します。商品一覧ページをRSCとして書き換えます。`src/app/page.tsx`を更新：

```tsx
import { getProducts } from '@/lib/shopify';
import Link from 'next/link';
import Image from 'next/image';

interface Product {
  id: string;
  title: string;
  handle: string;
  priceRange: {
    minVariantPrice: {
      amount: string;
      currencyCode: string;
    };
  };
  images: {
    edges: Array<{
      node: {
        url: string;
        altText: string | null;
      };
    }>;
  };
}

export default async function Home() {
  const products = await getProducts(10); // サーバー側で直接データ取得

  return (
    <main className="container mx-auto p-4">
      <h1 className="text-3xl font-bold mb-6">商品一覧</h1>
      <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
        {products.map((product) => (
          <Link href={`/products/${product.handle}`} key={product.id}>
            <div className="border rounded-lg p-4 hover:shadow-lg transition">
              <Image
                src={product.images.edges[0]?.node.url}
                alt={product.images.edges[0]?.node.altText || product.title}
                width={200}
                height={200}
                className="w-full h-48 object-cover rounded"
                loading="lazy"
                style={{ aspectRatio: '1 / 1' }}
              />
              <h2 className="text-xl font-semibold mt-2">{product.title}</h2>
              <p className="text-gray-600">
                {parseFloat(product.priceRange.minVariantPrice.amount).toFixed(2)}{' '}
                {product.priceRange.minVariantPrice.currencyCode}
              </p>
            </div>
          </Link>
        ))}
      </div>
    </main>
  );
}
```

このコードは：
- `getStaticProps`を削除し、サーバー側で直接`getProducts`を呼び出す。
- RSCとしてレンダリングされ、クライアントに送信されるJavaScriptを削減。
- 静的生成（SSG）とISRは引き続き有効。

---

## ステップ5: 動作確認

1. 開発サーバーを起動（`npm run dev`）。
2. `http://localhost:3000`と`/products/[handle]`にアクセスし、以下の点を確認：
   - ページ読み込みが高速（LCPが2.5秒未満）。
   - レイアウトシフトが発生しない（CLSが0.1未満）。
   - スケルトンUIがデータ取得中に表示される。
3. Lighthouseで再度分析し、Performanceスコアが90以上であることを確認。
4. デベロッパーツールのネットワークタブで、JavaScriptのサイズが削減されていることを確認。

エラーがあれば、コンソールログやLighthouseの提案を確認してください。

---

## まとめと次のステップ

このエピソードでは、LighthouseとVercel Analyticsを使ってパフォーマンスを分析し、LCPとCLSを最適化しました。フォント最適化、遅延読み込み、スケルトンUI、React Server Componentsを活用することで、Core Web Vitalsを大幅に改善し、Lighthouseスコア90以上を達成しました。

次回のエピソードでは、Algoliaを使った検索とフィルタリング機能を追加します。商品を素早く見つけるためのオートコンプリートやカテゴリフィルタも実装しますので、引き続きお楽しみに！

---

この記事が役に立ったと思ったら、ぜひ「いいね」を押して、ストックしていただければ嬉しいです！次回のエピソードもお楽しみに！