こんにちは!前回のエピソードでは、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で分析します。
-
Lighthouseの実行:
- 開発サーバーを起動(
npm run dev
)。 - Chromeで
http://localhost:3000/courses
またはhttp://localhost:3000/courses/[id]
を開く。 - DevTools(F12)→「Lighthouse」タブ→「Performance」を選択し、「Generate Report」を実行。
- 結果を確認(スコア、LCP、CLS、TBTなどの指標)。
- 開発サーバーを起動(
-
主な問題点の特定:
- LCP: 画像やビデオの読み込みが遅い、フォントのレンダリング遅延。
- CLS: 画像やコメントの動的読み込みによるレイアウトシフト。
- TBT(Total Blocking Time): クライアントサイドJavaScriptの実行時間。
ステップ2: LCPの改善
LCP(Largest Contentful Paint)は、ページの主要コンテンツの読み込み時間を測定します。以下の方法で改善します。
-
画像の遅延読み込み(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"
/>
-
フォントの最適化:
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の
Inter
をnext/font
で読み込み。 -
display: 'swap'
でフォントスワップを有効化し、FOUT(Flash of Unstyled Text)を防ぐ。
-
ビデオの最適化:
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)は、予期しないレイアウトのずれを測定します。以下の方法で削減します。
-
画像に固定サイズを指定:
すべてのImage
コンポーネントにwidth
とheight
を明示(すでに適用済み)。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
/>
-
コメントセクションのレイアウト安定化:
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)を最大限活用します。
-
CDPのRSC確認:
src/app/courses/[id]/page.tsx
はすでにRSCを使用(サーバーコンポーネント)。クライアントコンポーネント(CommentSection
,ReactPlayer
)は必要最低限に限定:
// 既存のコード(省略)
// 'use client'なしでサーバーコンポーネントとして動作
-
チェックアウトページの最適化:
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: 動作確認
- 開発サーバーを起動:
npm run dev
- Lighthouseを再実行(
http://localhost:3000/courses
と/courses/[id]
):- LCPが2.5秒未満(フォントと画像の最適化を確認)。
- CLSが0.1未満(プレースホルダーと固定サイズを確認)。
- パフォーマンススコアが90以上。
- ページを確認:
- コース一覧と詳細ページが高速に読み込まれる。
- コメントセクションにレイアウトシフトがない。
- ビデオプレーヤーの読み込みがスムーズ。
- ネットワークタブでJavaScriptのサイズを確認(RSCにより削減されている)。
エラーがあれば、Lighthouseの診断メッセージやSupabaseのクエリログを確認してください。
まとめと次のステップ
このエピソードでは、LMSのパフォーマンスを最適化し、Core Web Vitalsのスコアを向上させました。Lighthouseで分析を行い、LCPを遅延読み込みとフォント最適化で改善、CLSをレイアウト安定化で削減、React Server ComponentsでJavaScriptを削減しました。これで、アプリが高速でユーザー体験が向上しました!
次回のエピソードでは、LMSをProgressive Web App(PWA)に変換します。next-pwa
を使ってオフライン機能やプッシュ通知を追加し、モバイルでのネイティブアプリのような体験を提供しますので、引き続きお楽しみに!
この記事が役に立ったと思ったら、ぜひ「いいね」を押して、ストックしていただければ嬉しいです!次回のエピソードもお楽しみに!