2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.jsとSupabaseでオンライ学習プラットフォーム(LMS)を構築する | エピソード8: パフォーマンス最適化とCore Web Vitalsの向上

Posted at

こんにちは!前回のエピソードでは、Supabase Realtimeを使ってリアルタイムコメント機能を実装し、ユーザーのインタラクティブな学習体験を向上させました。今回は、LMSのパフォーマンスを最適化し、Core Web Vitals(CWV)のスコアを向上させます。Lighthouseでパフォーマンスを分析し、LCP(Largest Contentful Paint)、CLS(Cumulative Layout Shift)を改善、React Server Componentsを活用してクライアントサイドのJavaScriptを削減します。これで、アプリが高速でユーザー体験が向上します!

このエピソードのゴール

  • Lighthouseでパフォーマンスを分析。
  • LCPを改善(遅延読み込み、フォント最適化)。
  • CLSを削減(レイアウトの安定化)。
  • React Server Componentsを活用してJavaScriptを削減。
  • Lighthouseスコア90以上を目指す。

必要なもの

  • 前回のプロジェクト(next-lms)がセットアップ済み。
  • Supabaseプロジェクト(既存のテーブル設定済み)。
  • Lighthouse(Chrome DevTools)または類似ツール。
  • next/fontパッケージ(Next.js内蔵)。
  • 基本的なTypeScript、React、Next.jsの知識。

ステップ1: Lighthouseでパフォーマンス分析

まず、現在のアプリのパフォーマンスをLighthouseで分析します。

  1. Lighthouseの実行

    • 開発サーバーを起動(npm run dev)。
    • Chromeでhttp://localhost:3000/coursesまたはhttp://localhost:3000/courses/[id]を開く。
    • DevTools(F12)→「Lighthouse」タブ→「Performance」を選択し、「Generate Report」を実行。
    • 結果を確認(スコア、LCP、CLS、TBTなどの指標)。
  2. 主な問題点の特定

    • LCP: 画像やビデオの読み込みが遅い、フォントのレンダリング遅延。
    • CLS: 画像やコメントの動的読み込みによるレイアウトシフト。
    • TBT(Total Blocking Time): クライアントサイドJavaScriptの実行時間。

ステップ2: LCPの改善

LCP(Largest Contentful Paint)は、ページの主要コンテンツの読み込み時間を測定します。以下の方法で改善します。

  1. 画像の遅延読み込み(Lazy Loading)
    src/app/courses/page.tsxのコース一覧で、Imageコンポーネントのloading="lazy"を活用(すでに適用済み)。必要に応じて確認:
<Image
  src={course.image_url || `/placeholder-course-${course.id}.jpg`}
  alt={course.title}
  width={300}
  height={200}
  className="w-full h-40 object-cover rounded"
  loading="lazy"
/>
  1. フォントの最適化
    Next.jsのnext/fontを使って、カスタムフォントを最適化。src/app/layout.tsxを更新:
import '../styles/globals.css';
import Link from 'next/link';
import { supabaseServer } from '@/lib/supabase';
import { Inter } from 'next/font/google';

const inter = Inter({ subsets: ['latin'], display: 'swap' });

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const supabase = supabaseServer();
  const { data: { user } } = await supabase.auth.getUser();

  return (
    <html lang="ja" className={inter.className}>
      <body className="bg-gray-100">
        <header className="bg-primary text-white p-4">
          <div className="container mx-auto flex justify-between items-center">
            <h1 className="text-2xl font-bold">
              <Link href="/">Next.js LMS</Link>
            </h1>
            <nav className="flex gap-4">
              <Link href="/courses" className="hover:underline">コース</Link>
              {user ? (
                <>
                  <Link href="/dashboard" className="hover:underline">ダッシュボード</Link>
                  <form
                    action={async () => {
                      'use server';
                      await supabase.auth.signOut();
                    }}
                    className="inline"
                  >
                    <button className="hover:underline">ログアウト</button>
                  </form>
                </>
              ) : (
                <>
                  <Link href="/login" className="hover:underline">ログイン</Link>
                  <Link href="/register" className="hover:underline">登録</Link>
                </>
              )}
            </nav>
          </div>
        </header>
        {children}
      </body>
    </html>
  );
}

このコードは:

  • Google FontsのInternext/fontで読み込み。
  • display: 'swap'でフォントスワップを有効化し、FOUT(Flash of Unstyled Text)を防ぐ。
  1. ビデオの最適化
    src/app/courses/[id]/page.tsxのReact Playerで、プレロードを制御:
<ReactPlayer
  url={lesson.signedUrl}
  controls
  width="100%"
  height="auto"
  className="rounded-lg"
  config={{ file: { attributes: { preload: 'metadata' } } }}
/>

preload="metadata"でビデオの初期読み込みを最小限に抑えます。


ステップ3: CLSの削減

CLS(Cumulative Layout Shift)は、予期しないレイアウトのずれを測定します。以下の方法で削減します。

  1. 画像に固定サイズを指定
    すべてのImageコンポーネントにwidthheightを明示(すでに適用済み)。src/app/courses/[id]/page.tsxを確認:
<Image
  src={course.image_url || 'https://via.placeholder.com/600x400'}
  alt={course.title}
  width={600}
  height={400}
  className="w-full h-64 object-cover rounded-lg mb-6"
  priority
/>
  1. コメントセクションのレイアウト安定化
    src/components/CommentSection.tsxで、コメント読み込み中のプレースホルダーを追加:
'use client';

import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { supabase } from '@/lib/supabase';
import { Comment } from '@/lib/supabase';

type CommentForm = {
  content: string;
};

export default function CommentSection({ lessonId, userId }: { lessonId: string; userId?: string }) {
  const [comments, setComments] = useState<(Comment & { users: { email: string } })[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const { register, handleSubmit, reset, formState: { errors } } = useForm<CommentForm>();

  useEffect(() => {
    const fetchComments = async () => {
      setIsLoading(true);
      const { data } = await supabase
        .from('comments')
        .select('*, users!inner(email)')
        .eq('lesson_id', lessonId)
        .order('created_at', { ascending: true });
      setComments(data || []);
      setIsLoading(false);
    };
    fetchComments();

    const channel = supabase
      .channel(`comments:lesson:${lessonId}`)
      .on(
        'postgres_changes',
        {
          event: '*',
          schema: 'public',
          table: 'comments',
          filter: `lesson_id=eq.${lessonId}`,
        },
        (payload) => {
          if (payload.eventType === 'INSERT') {
            setComments((prev) => [...prev, payload.new as Comment & { users: { email: string } }]);
          }
        }
      )
      .subscribe();

    return () => {
      supabase.removeChannel(channel);
    };
  }, [lessonId]);

  const onSubmit = async (data: CommentForm) => {
    if (!userId) {
      alert('コメントするにはログインしてください');
      return;
    }

    const { error } = await supabase
      .from('comments')
      .insert({
        lesson_id: lessonId,
        user_id: userId,
        content: data.content,
      });

    if (error) {
      alert(error.message);
    } else {
      reset();
    }
  };

  return (
    <div className="mt-6">
      <h3 className="text-xl font-semibold mb-4">コメント</h3>
      {userId ? (
        <form onSubmit={handleSubmit(onSubmit)} className="mb-6">
          <textarea
            {...register('content', { required: 'コメントを入力してください' })}
            className="w-full border p-2 rounded"
            rows={4}
            placeholder="コメントを入力..."
          />
          {errors.content && <p className="text-red-500 text-sm">{errors.content.message}</p>}
          <button
            type="submit"
            className="mt-2 bg-primary text-white px-4 py-2 rounded hover:bg-opacity-90"
          >
            投稿
          </button>
        </form>
      ) : (
        <p className="text-gray-600 mb-4">
          コメントするには<a href="/login" className="text-primary hover:underline">ログイン</a>してください。
        </p>
      )}
      <div className="space-y-4">
        {isLoading ? (
          Array.from({ length: 3 }).map((_, index) => (
            <div key={index} className="border-l-4 pl-4 py-2 animate-pulse">
              <div className="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
              <div className="h-4 bg-gray-200 rounded w-1/2"></div>
            </div>
          ))
        ) : comments.length > 0 ? (
          comments.map((comment) => (
            <div key={comment.id} className="border-l-4 pl-4 py-2">
              <p className="text-sm text-gray-500">{comment.users.email} - {new Date(comment.created_at).toLocaleString()}</p>
              <p>{comment.content}</p>
            </div>
          ))
        ) : (
          <p className="text-gray-600">コメントはまだありません。</p>
        )}
      </div>
    </div>
  );
}

このコードは:

  • コメント読み込み中にプレースホルダーを表示し、CLSを防止。
  • animate-pulseで視覚的なフィードバックを提供。

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

クライアントサイドのJavaScriptを削減するため、React Server Components(RSC)を最大限活用します。

  1. CDPのRSC確認
    src/app/courses/[id]/page.tsxはすでにRSCを使用(サーバーコンポーネント)。クライアントコンポーネント(CommentSection, ReactPlayer)は必要最低限に限定:
// 既存のコード(省略)
// 'use client'なしでサーバーコンポーネントとして動作
  1. チェックアウトページの最適化
    src/app/checkout/page.tsxをサーバーコンポーネント中心に書き換え:
import { supabaseServer } from '@/lib/supabase';
import { cookies } from 'next/headers';
import CheckoutForm from '@/components/CheckoutForm';

export default async function CheckoutPage() {
  const supabase = supabaseServer();
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) {
    return (
      <main className="container mx-auto p-4">
        <h1 className="text-3xl font-bold mb-6">チェックアウト</h1>
        <p className="text-red-500">購入するにはログインしてください。</p>
        <a href="/login" className="text-primary hover:underline">ログイン</a>
      </main>
    );
  }

  return (
    <main className="container mx-auto p-4 max-w-md">
      <h1 className="text-3xl font-bold mb-6">チェックアウト</h1>
      <CheckoutForm userId={user.id} />
    </main>
  );
}

src/components/CheckoutForm.tsx(クライアントコンポーネント):

'use client';

import { useForm } from 'react-hook-form';
import { useRouter, useSearchParams } from 'next/navigation';

type CheckoutForm = {
  courseId: string;
};

export default function CheckoutForm({ userId }: { userId: string }) {
  const { register, handleSubmit, formState: { errors } } = useForm<CheckoutForm>();
  const router = useRouter();
  const searchParams = useSearchParams();
  const courseId = searchParams.get('courseId');

  const onSubmit = async (data: CheckoutForm) => {
    const response = await fetch('/api/checkout', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ courseId: data.courseId }),
    });

    const result = await response.json();
    if (result.url) {
      router.push(result.url);
    } else {
      alert(result.error || '決済処理に失敗しました');
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <label className="block text-sm font-medium">コースID</label>
        <input
          type="text"
          defaultValue={courseId || ''}
          {...register('courseId', { required: 'コースIDは必須です' })}
          className="w-full border p-2 rounded"
        />
        {errors.courseId && <p className="text-red-500 text-sm">{errors.courseId.message}</p>}
      </div>
      <button type="submit" className="bg-primary text-white px-6 py-3 rounded hover:bg-opacity-90 w-full">
        決済に進む
      </button>
    </form>
  );
}

このコードは:

  • サーバーコンポーネントで認証チェックを処理。
  • クライアントコンポーネントをフォーム処理に限定し、JavaScriptを削減。

ステップ5: 動作確認

  1. 開発サーバーを起動:
npm run dev
  1. Lighthouseを再実行(http://localhost:3000/courses/courses/[id]):
    • LCPが2.5秒未満(フォントと画像の最適化を確認)。
    • CLSが0.1未満(プレースホルダーと固定サイズを確認)。
    • パフォーマンススコアが90以上。
  2. ページを確認:
    • コース一覧と詳細ページが高速に読み込まれる。
    • コメントセクションにレイアウトシフトがない。
    • ビデオプレーヤーの読み込みがスムーズ。
  3. ネットワークタブでJavaScriptのサイズを確認(RSCにより削減されている)。

エラーがあれば、Lighthouseの診断メッセージやSupabaseのクエリログを確認してください。


まとめと次のステップ

このエピソードでは、LMSのパフォーマンスを最適化し、Core Web Vitalsのスコアを向上させました。Lighthouseで分析を行い、LCPを遅延読み込みとフォント最適化で改善、CLSをレイアウト安定化で削減、React Server ComponentsでJavaScriptを削減しました。これで、アプリが高速でユーザー体験が向上しました!

次回のエピソードでは、LMSをProgressive Web App(PWA)に変換します。next-pwaを使ってオフライン機能やプッシュ通知を追加し、モバイルでのネイティブアプリのような体験を提供しますので、引き続きお楽しみに!


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

2
2
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
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?