Skip to content

パフォーマンスの深い理解

シニアエンジニアとして、Next.jsアプリケーションのパフォーマンスを最適化するには、表面的な最適化ではなく、ブラウザとNext.jsの動作を深く理解する必要があります。この章では、パフォーマンスの根本的な理解について解説します。

LCPとは何か:

LCPは、ページの主要コンテンツが表示されるまでの時間を測定します。

LCPに影響する要素:

// 問題のあるコード: LCPが遅い
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<div>
{/* 問題: 画像がLCP要素だが最適化されていない */}
<img src="/large-product-image.jpg" alt="Product" />
<h1>Product Name</h1>
</div>
);
}
// 解決: Next.js Imageコンポーネントを使用
import Image from 'next/image';
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<div>
{/* 解決: 画像が最適化され、優先的にロードされる */}
<Image
src="/large-product-image.jpg"
alt="Product"
width={1200}
height={800}
priority // LCP要素を優先的にロード
/>
<h1>Product Name</h1>
</div>
);
}
// LCPの最適化戦略:
// 1. LCP要素を特定(通常は画像、動画、または大きなテキストブロック)
// 2. LCP要素を優先的にロード(priorityプロパティ)
// 3. 画像の最適化(next/imageを使用)
// 4. サーバーサイドレンダリングでLCP要素を含める

FIDとは何か:

FIDは、ユーザーが最初にインタラクション(クリック、タップ、キー入力)してから、ブラウザがそのインタラクションに応答するまでの時間を測定します。

FIDに影響する要素:

// 問題のあるコード: メインスレッドがブロックされる
'use client';
export default function HeavyComponent() {
// 問題: 重い処理がメインスレッドをブロック
const processHeavyData = () => {
const data = Array.from({ length: 1000000 }, (_, i) => i);
return data.map(x => x * 2).filter(x => x > 1000);
};
const [result, setResult] = useState(null);
useEffect(() => {
// 問題: 同期的な重い処理がFIDを悪化させる
const processed = processHeavyData();
setResult(processed);
}, []);
return <div>{result?.length}</div>;
}
// 解決1: Web Workerを使用
// worker.ts
self.onmessage = (e) => {
const data = Array.from({ length: 1000000 }, (_, i) => i);
const processed = data.map(x => x * 2).filter(x => x > 1000);
self.postMessage(processed);
};
// Component.tsx
'use client';
export default function HeavyComponent() {
const [result, setResult] = useState(null);
useEffect(() => {
const worker = new Worker(new URL('./worker.ts', import.meta.url));
worker.onmessage = (e) => {
setResult(e.data);
};
worker.postMessage('start');
return () => worker.terminate();
}, []);
return <div>{result?.length}</div>;
}
// 解決2: 時間を分割して処理(Time Slicing)
'use client';
export default function HeavyComponent() {
const [result, setResult] = useState<number[]>([]);
useEffect(() => {
const processInChunks = async () => {
const data = Array.from({ length: 1000000 }, (_, i) => i);
const chunkSize = 10000;
for (let i = 0; i < data.length; i += chunkSize) {
const chunk = data.slice(i, i + chunkSize);
const processed = chunk.map(x => x * 2).filter(x => x > 1000);
setResult(prev => [...prev, ...processed]);
// メインスレッドを解放
await new Promise(resolve => setTimeout(resolve, 0));
}
};
processInChunks();
}, []);
return <div>{result.length}</div>;
}

CLSとは何か:

CLSは、ページの読み込み中にレイアウトがどれだけシフトするかを測定します。

CLSに影響する要素:

// 問題のあるコード: レイアウトシフトが発生
export default function ProductList() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetchProducts().then(setProducts);
}, []);
return (
<div>
{products.map(product => (
// 問題: 画像のサイズが指定されていない
<img src={product.image} alt={product.name} />
))}
</div>
);
}
// 解決: 画像のサイズを事前に指定
import Image from 'next/image';
export default function ProductList() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetchProducts().then(setProducts);
}, []);
return (
<div>
{products.map(product => (
// 解決: サイズを指定してレイアウトシフトを防止
<Image
src={product.image}
alt={product.name}
width={300}
height={300}
/>
))}
</div>
);
}
// 解決: スケルトンローディングでプレースホルダーを表示
export default function ProductList() {
const [products, setProducts] = useState([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetchProducts().then(data => {
setProducts(data);
setIsLoading(false);
});
}, []);
if (isLoading) {
// スケルトンでレイアウトを確保
return (
<div>
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="h-64 w-64 bg-gray-200 animate-pulse" />
))}
</div>
);
}
return (
<div>
{products.map(product => (
<Image
src={product.image}
alt={product.name}
width={300}
height={300}
/>
))}
</div>
);
}

動的インポートの活用:

// 問題のあるコード: すべてのコードを1つのバンドルに
import HeavyComponent from '@/components/HeavyComponent';
import ChartLibrary from '@/lib/chart-library';
export default function Page() {
return (
<div>
<HeavyComponent />
<ChartLibrary />
</div>
);
}
// 解決: 動的インポートでコード分割
import dynamic from 'next/dynamic';
// 動的インポート(コード分割)
const HeavyComponent = dynamic(() => import('@/components/HeavyComponent'), {
loading: () => <p>Loading...</p>,
ssr: false, // サーバーサイドレンダリングを無効化
});
const ChartLibrary = dynamic(() => import('@/lib/chart-library'), {
loading: () => <p>Loading chart...</p>,
});
export default function Page() {
return (
<div>
<HeavyComponent />
<ChartLibrary />
</div>
);
}
// 条件付きロード
const AdminPanel = dynamic(() => import('@/components/AdminPanel'), {
loading: () => <p>Loading admin panel...</p>,
});
export default function Dashboard({ isAdmin }: { isAdmin: boolean }) {
return (
<div>
<UserDashboard />
{isAdmin && <AdminPanel />} // 必要な時だけロード
</div>
);
}

Tree Shakingの最適化:

// 問題のあるコード: ライブラリ全体をインポート
import _ from 'lodash';
export default function Component() {
const result = _.uniq([1, 2, 2, 3]); // lodash全体がバンドルに含まれる
return <div>{result}</div>;
}
// 解決: 必要な関数だけをインポート
import uniq from 'lodash/uniq';
export default function Component() {
const result = uniq([1, 2, 2, 3]); // uniqだけがバンドルに含まれる
return <div>{result}</div>;
}
// さらに良い: ネイティブの実装を使用
export default function Component() {
const result = [...new Set([1, 2, 2, 3])]; // ライブラリ不要
return <div>{result}</div>;
}

キャッシングの階層:

1. Request Memoization
- 同一リクエスト内での重複リクエストを防ぐ
- 自動的に適用される
2. Data Cache (fetch cache)
- fetch()の結果をキャッシュ
- revalidateオプションで制御
3. Full Route Cache
- レンダリング結果をキャッシュ
- 静的生成されたページ
4. Router Cache
- クライアント側のルーターキャッシュ
- ナビゲーション時の高速化

実践的なキャッシング設定:

// 1. 常に最新のデータが必要
export default async function Dashboard() {
const data = await fetch('https://api.example.com/dashboard', {
cache: 'no-store', // キャッシュしない
});
return <Dashboard data={data} />;
}
// 2. 定期的に更新(ISR)
export default async function Products() {
const data = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 }, // 1時間ごとに再検証
});
return <ProductList products={data} />;
}
// 3. ビルド時に生成(SSG)
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await fetch(`https://api.example.com/posts/${params.slug}`, {
next: { revalidate: false }, // キャッシュを無効化しない
});
return <Article post={post} />;
}
// 4. タグベースの再検証
export default async function Product({ params }: { params: { id: string } }) {
const product = await fetch(`https://api.example.com/products/${params.id}`, {
next: {
revalidate: 3600,
tags: ['products'] // タグでキャッシュを管理
},
});
return <ProductDetails product={product} />;
}
// タグベースの再検証API
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
export async function POST(request: Request) {
const { tag } = await request.json();
revalidateTag(tag); // 特定のタグのキャッシュを無効化
return Response.json({ revalidated: true });
}

Next.js Imageコンポーネントの動作

Section titled “Next.js Imageコンポーネントの動作”

画像最適化のメカニズム:

// Next.js Imageコンポーネントが行う最適化:
// 1. 自動的な画像フォーマットの変換(WebP、AVIF)
// 2. レスポンシブ画像の生成(複数のサイズ)
// 3. Lazy Loading(デフォルト)
// 4. レイアウトシフトの防止
import Image from 'next/image';
export default function ProductImage({ src, alt }: { src: string; alt: string }) {
return (
<Image
src={src}
alt={alt}
width={800}
height={600}
priority // LCP要素の場合
placeholder="blur" // ブラー効果でレイアウトシフトを防止
blurDataURL="data:image/jpeg;base64,..." // プレースホルダー
/>
);
}
// 画像最適化の設定(next.config.js)
module.exports = {
images: {
formats: ['image/avif', 'image/webp'], // 優先フォーマット
deviceSizes: [640, 750, 828, 1080, 1200, 1920], // デバイスサイズ
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], // 画像サイズ
minimumCacheTTL: 60, // キャッシュの最小TTL
},
};

Next.jsアプリケーションのパフォーマンスの深い理解において重要なポイント:

  1. Core Web Vitals: LCP、FID、CLSの根本的な理解
  2. バンドルサイズ: コード分割とTree Shakingの最適化
  3. キャッシング: 多層的なキャッシング戦略の活用
  4. 画像最適化: Next.js Imageコンポーネントの適切な使用

シニアエンジニアとして考慮すべき点:

  • 計測が最優先: 推測ではなく、実際のパフォーマンスデータに基づいて判断
  • ユーザー体験: パフォーマンス指標だけでなく、実際のユーザー体験を考慮
  • トレードオフ: パフォーマンスと開発体験のバランス
  • 継続的な改善: 一度の最適化ではなく、継続的な監視と改善

パフォーマンスの問題は、表面的な症状ではなく、根本原因を理解することで解決できます。