はじめに
モバイルユーザーの増加に伴い、低速回線(3G回線や不安定なWi-Fi環境)でも快適に利用できるWebアプリケーションの需要が高まっています。Googleの調査によると、ページの読み込みに3秒以上かかると、53%のユーザーがサイトを離脱するとされています。
本記事では、Next.jsを使用して低速回線環境でも優れたユーザー体験を提供するための具体的な最適化手法を、実装コードとともに解説します。
この記事で学べること
- Lazy Loading(遅延読み込み)の実装方法
- 画像・スクリプトの最適化テクニック
- プリフェッチとキャッシュ戦略の管理
- SSR/SSGを活用したパフォーマンス向上
- Service Workerを使ったオフライン対応
低速回線対応が重要な理由
低速回線対応は、以下の理由から現代のWeb開発において不可欠です。
- グローバル展開: 新興国市場ではまだ3G回線が主流の地域が多い
- モバイルファースト: 移動中や地下など、通信環境が不安定な場所での利用増加
- SEOへの影響: Core Web Vitalsはページ速度を重要な指標として評価
- ユーザー体験: 読み込み時間の長さは直帰率に直結
Lazy Loading(遅延読み込み)の実装
Lazy Loading(遅延読み込み) とは、ページの初回表示時には必要最小限のリソースのみを読み込み、残りのリソースは必要になったタイミングで読み込む手法です。
next/dynamicによるコンポーネントの遅延読み込み
next/dynamicを使用すると、コンポーネント単位での遅延読み込みを簡単に実装できます。
import dynamic from 'next/dynamic';
// 重いコンポーネントを遅延読み込み
// loading: ロード中に表示するフォールバックUI
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
loading: () => (
<div className="animate-pulse bg-gray-200 h-64 rounded-lg">
<p className="text-center pt-24">グラフを読み込み中...</p>
</div>
),
// SSRを無効化(クライアントサイドのみでレンダリング)
ssr: false,
});
// 使用例
export default function DashboardPage() {
return (
<main>
<h1>ダッシュボード</h1>
{/* ユーザーがスクロールして見える位置に来たときにロード */}
<HeavyChart />
</main>
);
}
Intersection Observer APIを使った画像の遅延読み込み
画面外の画像は、ユーザーがスクロールして見える位置に来るまで読み込みを遅延させます。
'use client';
import { useEffect, useRef, useState } from 'react';
interface LazyImageProps {
src: string;
alt: string;
className?: string;
placeholderSrc?: string;
}
export function LazyImage({
src,
alt,
className = '',
placeholderSrc = '/images/placeholder.webp',
}: LazyImageProps) {
const [isLoaded, setIsLoaded] = useState(false);
const [isInView, setIsInView] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);
useEffect(() => {
// Intersection Observer APIで要素の可視性を監視
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
// 要素が画面内に入ったら読み込み開始
if (entry.isIntersecting) {
setIsInView(true);
observer.disconnect();
}
});
},
{
// 画面の100px手前から読み込み開始(先読み)
rootMargin: '100px',
threshold: 0.1,
}
);
if (imgRef.current) {
observer.observe(imgRef.current);
}
return () => observer.disconnect();
}, []);
return (
<div className={`relative overflow-hidden ${className}`}>
{/* プレースホルダー画像(ぼかし効果付き) */}
<img
ref={imgRef}
src={isInView ? src : placeholderSrc}
alt={alt}
className={`transition-opacity duration-300 ${
isLoaded ? 'opacity-100' : 'opacity-0'
}`}
onLoad={() => setIsLoaded(true)}
/>
{/* ローディングスケルトン */}
{!isLoaded && (
<div className="absolute inset-0 bg-gray-200 animate-pulse" />
)}
</div>
);
}
画像とスクリプトの最適化
Next.js Imageコンポーネントの活用
next/imageコンポーネントは、自動的に画像を最適化し、適切なサイズとフォーマットで配信します。
import Image from 'next/image';
export function OptimizedGallery() {
return (
<div className="grid grid-cols-2 gap-4">
{/* ファーストビューの画像はpriorityを設定 */}
<Image
src="/images/hero.jpg"
alt="メインビジュアル"
width={800}
height={600}
priority // LCP(Largest Contentful Paint)対象の画像に設定
placeholder="blur" // ぼかしプレースホルダーを表示
blurDataURL="data:image/jpeg;base64,/9j..." // 低解像度のBase64画像
/>
{/* スクロールで表示される画像は遅延読み込み(デフォルト) */}
<Image
src="/images/product-1.jpg"
alt="商品画像1"
width={400}
height={300}
loading="lazy" // 明示的に遅延読み込みを指定
sizes="(max-width: 768px) 100vw, 50vw" // レスポンシブ対応
/>
</div>
);
}
next/scriptによるスクリプト最適化
サードパーティスクリプトは、読み込みタイミングを制御することでパフォーマンスを改善できます。
import Script from 'next/script';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja">
<body>
{children}
{/* afterInteractive: ページがインタラクティブになった後に読み込み */}
<Script
src="https://www.googletagmanager.com/gtag/js?id=GA_MEASUREMENT_ID"
strategy="afterInteractive"
/>
{/* lazyOnload: 全てのリソース読み込み後に読み込み(優先度低) */}
<Script
src="https://platform.twitter.com/widgets.js"
strategy="lazyOnload"
onLoad={() => console.log('Twitter widgets loaded')}
/>
{/* worker: Web Workerで読み込み(メインスレッドをブロックしない) */}
<Script
src="/scripts/analytics.js"
strategy="worker"
/>
</body>
</html>
);
}
プリフェッチとキャッシュ戦略の管理
プリフェッチの制御
Next.jsはデフォルトでビューポート内のリンクをプリフェッチしますが、低速回線では逆効果になることがあります。
import Link from 'next/link';
export function Navigation() {
return (
<nav>
{/* 重要なページはプリフェッチを有効に */}
<Link href="/" prefetch={true}>
ホーム
</Link>
{/* あまりアクセスされないページはプリフェッチを無効に */}
<Link href="/terms" prefetch={false}>
利用規約
</Link>
{/* 条件付きプリフェッチ */}
<Link
href="/dashboard"
prefetch={typeof window !== 'undefined' && navigator.connection?.effectiveType !== '2g'}
>
ダッシュボード
</Link>
</nav>
);
}
ネットワーク状態の検出と適応
Network Information APIを使って、ユーザーのネットワーク状態に応じた最適化を行います。
'use client';
import { useEffect, useState } from 'react';
interface NetworkInfo {
effectiveType: '2g' | '3g' | '4g' | 'slow-2g';
downlink: number;
saveData: boolean;
}
export function useNetworkStatus() {
const [networkInfo, setNetworkInfo] = useState<NetworkInfo | null>(null);
useEffect(() => {
// Network Information APIのサポートチェック
const connection = (navigator as any).connection;
if (!connection) return;
const updateNetworkInfo = () => {
setNetworkInfo({
effectiveType: connection.effectiveType,
downlink: connection.downlink,
saveData: connection.saveData || false,
});
};
updateNetworkInfo();
connection.addEventListener('change', updateNetworkInfo);
return () => connection.removeEventListener('change', updateNetworkInfo);
}, []);
return networkInfo;
}
// 使用例:ネットワーク状態に応じて画像品質を切り替え
export function AdaptiveImage({ src, alt }: { src: string; alt: string }) {
const network = useNetworkStatus();
// 低速回線またはデータセーバーモードの場合は低品質画像を使用
const quality = network?.effectiveType === '2g' || network?.saveData ? 30 : 75;
return (
<Image
src={src}
alt={alt}
width={800}
height={600}
quality={quality}
/>
);
}
Service Workerによるキャッシュ戦略
next-pwaを使用して、オフライン対応とキャッシュ戦略を実装します。
npm install next-pwa
// next.config.js
const withPWA = require('next-pwa')({
dest: 'public',
// 開発環境では無効化
disable: process.env.NODE_ENV === 'development',
// キャッシュ戦略のカスタマイズ
runtimeCaching: [
{
// 画像のキャッシュ戦略
urlPattern: /^https:\/\/.*\.(?:png|jpg|jpeg|webp|svg|gif)$/,
handler: 'CacheFirst', // キャッシュを優先
options: {
cacheName: 'image-cache',
expiration: {
maxEntries: 100, // 最大100件
maxAgeSeconds: 30 * 24 * 60 * 60, // 30日間
},
},
},
{
// APIレスポンスのキャッシュ戦略
urlPattern: /^https:\/\/api\.example\.com\/.*/,
handler: 'NetworkFirst', // ネットワークを優先、失敗時はキャッシュ
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 5 * 60, // 5分間
},
networkTimeoutSeconds: 10, // 10秒でタイムアウト
},
},
{
// 静的アセットのキャッシュ戦略
urlPattern: /^https:\/\/.*\.(?:js|css)$/,
handler: 'StaleWhileRevalidate', // キャッシュを返しつつバックグラウンドで更新
options: {
cacheName: 'static-cache',
},
},
],
});
module.exports = withPWA({
// その他のNext.js設定
});
サーバーサイドレンダリング(SSR)と静的サイト生成(SSG)
適切なレンダリング戦略の選択
コンテンツの特性に応じて、最適なレンダリング方法を選択します。
// app/blog/[slug]/page.tsx
// 静的サイト生成(SSG)- ビルド時にHTMLを生成
export async function generateStaticParams() {
const posts = await fetchAllPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}
export default async function BlogPost({
params,
}: {
params: { slug: string };
}) {
// ビルド時にデータを取得
const post = await fetchPost(params.slug);
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
// ISR(Incremental Static Regeneration)を有効化
// 60秒ごとにバックグラウンドで再生成
export const revalidate = 60;
// app/dashboard/page.tsx
// 動的レンダリング - リクエストごとにサーバーで生成
export const dynamic = 'force-dynamic';
export default async function Dashboard() {
// リクエスト時にデータを取得
const userData = await fetchUserData();
return (
<main>
<h1>ようこそ、{userData.name}さん</h1>
{/* ユーザー固有のコンテンツ */}
</main>
);
}
ストリーミングSSRの活用
React 18のSuspenseを使って、コンテンツを段階的にストリーミング配信します。
import { Suspense } from 'react';
// 重いデータフェッチを行うコンポーネント
async function SlowDataComponent() {
const data = await fetchSlowData(); // 時間がかかる処理
return <div>{data.content}</div>;
}
export default function Page() {
return (
<main>
{/* 即座に表示される部分 */}
<h1>ページタイトル</h1>
<p>この部分はすぐに表示されます。</p>
{/* 遅延読み込みされる部分 */}
<Suspense
fallback={
<div className="animate-pulse bg-gray-200 h-32 rounded">
データを読み込み中...
</div>
}
>
<SlowDataComponent />
</Suspense>
</main>
);
}
ローディング状態の最適化
ユーザーに適切なフィードバックを提供することで、体感速度を向上させます。
// app/loading.tsx
// ルートレベルのローディングUI
export default function Loading() {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="space-y-4 w-full max-w-2xl p-4">
{/* スケルトンスクリーン */}
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4" />
<div className="h-4 bg-gray-200 rounded w-full mb-2" />
<div className="h-4 bg-gray-200 rounded w-5/6 mb-2" />
<div className="h-4 bg-gray-200 rounded w-4/6" />
</div>
</div>
</div>
);
}
'use client';
import { useEffect, useState } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
// ページ遷移時のプログレスバー
export function NavigationProgress() {
const pathname = usePathname();
const searchParams = useSearchParams();
const [isLoading, setIsLoading] = useState(false);
const [progress, setProgress] = useState(0);
useEffect(() => {
setIsLoading(true);
setProgress(30);
const timer1 = setTimeout(() => setProgress(60), 100);
const timer2 = setTimeout(() => setProgress(80), 200);
const timer3 = setTimeout(() => {
setProgress(100);
setTimeout(() => setIsLoading(false), 200);
}, 300);
return () => {
clearTimeout(timer1);
clearTimeout(timer2);
clearTimeout(timer3);
};
}, [pathname, searchParams]);
if (!isLoading) return null;
return (
<div
className="fixed top-0 left-0 h-1 bg-blue-500 transition-all duration-200 z-50"
style={{ width: `${progress}%` }}
/>
);
}
パフォーマンス計測とモニタリング
最適化の効果を測定するために、パフォーマンスメトリクスを収集します。
// app/layout.tsx
import { Analytics } from '@vercel/analytics/react';
import { SpeedInsights } from '@vercel/speed-insights/next';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja">
<body>
{children}
{/* Vercel Analytics */}
<Analytics />
{/* Core Web Vitals計測 */}
<SpeedInsights />
</body>
</html>
);
}
// lib/web-vitals.ts
import { onCLS, onFID, onLCP, onFCP, onTTFB } from 'web-vitals';
type MetricHandler = (metric: any) => void;
export function reportWebVitals(onReport: MetricHandler) {
// Cumulative Layout Shift(視覚的安定性)
onCLS(onReport);
// First Input Delay(インタラクティブ性)
onFID(onReport);
// Largest Contentful Paint(読み込みパフォーマンス)
onLCP(onReport);
// First Contentful Paint
onFCP(onReport);
// Time to First Byte
onTTFB(onReport);
}
// 使用例
reportWebVitals((metric) => {
console.log(metric.name, metric.value);
// アナリティクスサービスに送信
// sendToAnalytics(metric);
});
まとめ
Next.jsを使った低速回線対応のポイントをまとめます。
| 手法 | 効果 | 実装難易度 |
|---|---|---|
| Lazy Loading | 初回読み込み時間の短縮 | 低 |
| 画像最適化 | 転送量の削減 | 低 |
| プリフェッチ制御 | 不要なリソース読み込みの抑制 | 低 |
| Service Worker | オフライン対応・キャッシュ活用 | 中 |
| SSG/ISR | サーバー負荷軽減・高速配信 | 中 |
| ストリーミングSSR | 体感速度の向上 | 中 |
| ネットワーク検出 | 適応的な最適化 | 高 |
これらの手法を組み合わせることで、どのようなネットワーク環境でも快適なユーザー体験を提供できます。重要なのは、実際のユーザー環境でのパフォーマンスを継続的に計測し、改善を続けることです。