- Published on
Webパフォーマンス最適化完全ガイド2025:Core Web Vitals、LCP/INP/CLS、ローディング戦略
- Authors

- Name
- Youngju Kim
- @fjvbn20031
目次(もくじ)
1. Webパフォーマンスが重要(じゅうよう)な理由(りゆう)
Webパフォーマンスは、ユーザー体験(たいけん)、ビジネス成果(せいか)、SEOランキングに直接的(ちょくせつてき)に影響(えいきょう)を与(あた)えます。Googleの研究(けんきゅう)によると、ページロード時間(じかん)が1秒(びょう)から3秒に延(の)びると離脱率(りだつりつ)が32%増加(ぞうか)し、5秒になると90%増加します。
1.1 パフォーマンスがビジネスに与える影響
| 指標(しひょう) | 改善(かいぜん)効果(こうか) |
|---|---|
| ロード時間0.1秒短縮 | コンバージョン率8%増加(Walmart) |
| ロード時間50%短縮 | 売上12%増加(AutoAnything) |
| ロード時間2.2秒短縮 | ダウンロード15.4%増加(Mozilla) |
| パフォーマンススコア10点向上 | 離脱率5-10%減少 |
1.2 Googleのページ体験シグナル
2021年からGoogleはCore Web Vitalsを検索(けんさく)ランキング要素(ようそ)に含(ふく)めました。これはモバイルとデスクトップの両方(りょうほう)の検索に適用(てきよう)されます。
ページ体験シグナルの構成:
├── Core Web Vitals(LCP、INP、CLS)
├── HTTPSセキュリティ
├── モバイルフレンドリー
├── 侵入的な広告なし
└── Safe Browsing
2. Core Web Vitals完全(かんぜん)攻略(こうりゃく)
Core Web Vitalsは、Googleが定義(ていぎ)した3つの核心(かくしん)ユーザー体験指標です。2024年3月からINP(Interaction to Next Paint)がFIDを置(お)き換(か)えました。
2.1 LCP(Largest Contentful Paint)
LCPは、ビューポートで最大(さいだい)のコンテンツ要素がレンダリングされる時間を測定(そくてい)します。
閾値(しきいち):
- Good:2.5秒以下(いか)
- Needs Improvement:2.5秒~4.0秒
- Poor:4.0秒超過(ちょうか)
LCP対象(たいしょう)要素:
img要素svg内(ない)のimage要素video要素のposter画像(がぞう)- CSS
background-imageがある要素 - テキストノードを含むブロックレベル要素
// LCP測定コード
new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
const lastEntry = entries[entries.length - 1];
console.log('LCP:', lastEntry.startTime);
console.log('LCP Element:', lastEntry.element);
console.log('LCP URL:', lastEntry.url);
console.log('LCP Size:', lastEntry.size);
}).observe({ type: 'largest-contentful-paint', buffered: true });
LCP最適化戦略(せんりゃく):
// 1. ヒーロー画像にfetchpriority="high"を追加
<img
src="/hero-image.webp"
alt="Hero"
fetchpriority="high"
width={1200}
height={600}
/>
// 2. PreloadでLCPリソースを先にロード
<link
rel="preload"
as="image"
href="/hero-image.webp"
fetchpriority="high"
/>
// 3. Next.js Imageコンポーネント活用
import Image from 'next/image';
export default function Hero() {
return (
<Image
src="/hero.webp"
alt="Hero"
width={1200}
height={600}
priority // LCP画像にpriorityを追加
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>
);
}
2.2 INP(Interaction to Next Paint)
INPは、ユーザーインタラクション(クリック、タップ、キー入力(にゅうりょく))から次(つぎ)のフレームがレンダリングされるまでの時間を測定します。FIDと異(こと)なり、セッション全体(ぜんたい)の全てのインタラクションを考慮(こうりょ)します。
閾値:
- Good:200ms以下
- Needs Improvement:200ms~500ms
- Poor:500ms超過
// INP測定
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (entry.interactionId) {
const duration = entry.duration;
const inputDelay = entry.processingStart - entry.startTime;
const processingTime = entry.processingEnd - entry.processingStart;
const presentationDelay = entry.startTime + entry.duration - entry.processingEnd;
console.log('INP Breakdown:', {
duration,
inputDelay, // 入力遅延
processingTime, // 処理時間
presentationDelay // レンダリング遅延
});
}
}
}).observe({ type: 'event', buffered: true, durationThreshold: 16 });
INP最適化戦略:
// 1. 長いタスクをyieldで分離
async function processLargeList(items) {
for (let i = 0; i < items.length; i++) {
processItem(items[i]);
// 100個ごとにメインスレッドに譲渡
if (i % 100 === 0) {
await scheduler.yield(); // Scheduler API
// またはfallback:
// await new Promise(resolve => setTimeout(resolve, 0));
}
}
}
// 2. ReactでuseTransitionによる優先度分離
function SearchResults() {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
function handleChange(e) {
// 入力は即座に反映(緊急更新)
setQuery(e.target.value);
// 検索結果は低優先度(トランジション更新)
startTransition(() => {
setSearchResults(filterResults(e.target.value));
});
}
return (
<div>
<input value={query} onChange={handleChange} />
{isPending ? <Spinner /> : <ResultsList />}
</div>
);
}
// 3. requestIdleCallbackで非必須タスクを遅延
function deferAnalytics(data) {
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
sendAnalytics(data);
}, { timeout: 2000 });
} else {
setTimeout(() => sendAnalytics(data), 100);
}
}
2.3 CLS(Cumulative Layout Shift)
CLSは、ページのライフサイクル中(ちゅう)に発生(はっせい)する予期(よき)しないレイアウトシフトの累積(るいせき)スコアを測定します。
閾値:
- Good:0.1以下
- Needs Improvement:0.1~0.25
- Poor:0.25超過
// CLS測定
let clsValue = 0;
let clsEntries = [];
let sessionValue = 0;
let sessionEntries = [];
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
// ユーザー入力後500ms以内のシフトは除外
if (!entry.hadRecentInput) {
const firstSessionEntry = sessionEntries[0];
const lastSessionEntry = sessionEntries[sessionEntries.length - 1];
// セッションウィンドウ:1秒以内、5秒制限
if (
sessionValue &&
entry.startTime - lastSessionEntry.startTime < 1000 &&
entry.startTime - firstSessionEntry.startTime < 5000
) {
sessionValue += entry.value;
sessionEntries.push(entry);
} else {
sessionValue = entry.value;
sessionEntries = [entry];
}
if (sessionValue > clsValue) {
clsValue = sessionValue;
clsEntries = [...sessionEntries];
}
}
}
}).observe({ type: 'layout-shift', buffered: true });
CLS最適化戦略:
/* 1. 画像/動画には常にサイズを指定 */
img, video {
width: 100%;
height: auto;
aspect-ratio: 16 / 9; /* CSS aspect-ratioを活用 */
}
/* 2. 広告/埋め込み領域を事前確保 */
.ad-slot {
min-height: 250px;
contain: layout; /* CSS Containment */
}
/* 3. フォント読み込み時のレイアウトシフト防止 */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: optional; /* CLSのためにoptionalを使用 */
size-adjust: 100.5%; /* フォールバックフォントとサイズを合わせる */
ascent-override: 95%;
descent-override: 22%;
line-gap-override: 0%;
}
/* 4. 動的コンテンツにcontain属性 */
.dynamic-content {
contain: layout style;
content-visibility: auto;
contain-intrinsic-size: 0 500px;
}
3. 画像(がぞう)最適化(さいてきか)
画像は平均的(へいきんてき)にWebページバイトの50%以上(いじょう)を占(し)めます。画像最適化はパフォーマンス向上(こうじょう)の最(もっと)も効果的な方法(ほうほう)です。
3.1 次世代(じせだい)画像フォーマット
フォーマット別比較(同一品質基準):
┌─────────┬────────┬────────────┬────────────┬──────────┐
│ フォーマット│ 圧縮率 │ 透明度 │ アニメーション│ ブラウザ │
├─────────┼────────┼────────────┼────────────┼──────────┤
│ JPEG │ 基準 │ なし │ なし │ 100% │
│ PNG │ 低 │ あり │ なし │ 100% │
│ WebP │ 25-34% │ あり │ あり │ 97%+ │
│ AVIF │ 50%+ │ あり │ あり │ 92%+ │
│ JPEG XL │ 35-60% │ あり │ あり │ 制限的 │
└─────────┴────────┴────────────┴────────────┴──────────┘
<!-- picture要素でフォーマットフォールバック -->
<picture>
<source srcset="/image.avif" type="image/avif" />
<source srcset="/image.webp" type="image/webp" />
<img src="/image.jpg" alt="Description" width="800" height="600" />
</picture>
3.2 レスポンシブ画像
<!-- srcsetとsizesで適切なサイズを提供 -->
<img
srcset="
/image-400w.webp 400w,
/image-800w.webp 800w,
/image-1200w.webp 1200w,
/image-1600w.webp 1600w
"
sizes="
(max-width: 640px) 100vw,
(max-width: 1024px) 50vw,
33vw
"
src="/image-800w.webp"
alt="Responsive image"
loading="lazy"
decoding="async"
width="800"
height="600"
/>
3.3 Lazy Loadingとプライオリティヒント
// ネイティブlazy loading
<img src="/below-fold.webp" loading="lazy" decoding="async" />
// Intersection Observerを使ったカスタムlazy loading
const imageObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.srcset = img.dataset.srcset || '';
img.classList.add('loaded');
imageObserver.unobserve(img);
}
});
},
{
rootMargin: '200px 0px', // 200px前にロード開始
threshold: 0.01,
}
);
document.querySelectorAll('img[data-src]').forEach((img) => {
imageObserver.observe(img);
});
3.4 Blurプレースホルダー実装(じっそう)
// Next.jsでのblurプレースホルダー
import Image from 'next/image';
import { getPlaiceholder } from 'plaiceholder';
export async function getStaticProps() {
const { base64 } = await getPlaiceholder('/public/hero.jpg');
return {
props: { blurDataURL: base64 },
};
}
export default function Page({ blurDataURL }) {
return (
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
placeholder="blur"
blurDataURL={blurDataURL}
/>
);
}
// CSSで直接blur効果を実装
const BlurImage = ({ src, alt }) => {
const [loaded, setLoaded] = useState(false);
return (
<div style={{ position: 'relative', overflow: 'hidden' }}>
{!loaded && (
<div
style={{
position: 'absolute',
inset: 0,
backgroundImage: `url(${src}?w=20&q=10)`,
backgroundSize: 'cover',
filter: 'blur(20px)',
transform: 'scale(1.1)',
}}
/>
)}
<img
src={src}
alt={alt}
onLoad={() => setLoaded(true)}
style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s' }}
/>
</div>
);
};
4. JavaScript最適化
4.1 Tree Shaking
Tree Shakingは、使用(しよう)していないコードをバンドルから除去(じょきょ)する最適化技法(ぎほう)です。
// package.json - sideEffects設定
{
"name": "my-library",
"sideEffects": false,
// またはサイドエフェクトがあるファイルのみ指定
"sideEffects": ["*.css", "*.scss", "./src/polyfills.js"]
}
// Bad:ライブラリ全体をインポート(tree shaking不可)
import _ from 'lodash';
const result = _.map(data, fn);
// Good:個別関数のみインポート
import map from 'lodash/map';
const result = map(data, fn);
// Best:lodash-esを使用(ES Module)
import { map } from 'lodash-es';
const result = map(data, fn);
4.2 Code Splitting
// 1. 動的importでコード分割
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<HeavyComponent />
</Suspense>
);
}
// 2. ルートベースのコード分割(Next.js)
import dynamic from 'next/dynamic';
const DashboardChart = dynamic(
() => import('@/components/DashboardChart'),
{
loading: () => <ChartSkeleton />,
ssr: false, // クライアントでのみロード
}
);
// 3. webpack magic commentsでチャンク制御
const AdminPanel = React.lazy(
() => import(
/* webpackChunkName: "admin" */
/* webpackPrefetch: true */
'./AdminPanel'
)
);
4.3 バンドル分析(ぶんせき)
// next.config.js - webpack-bundle-analyzer設定
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// Next.js config
});
// 実行:ANALYZE=true next build
// .size-limit.json - バンドルサイズ制限設定
[
{
"path": "dist/index.js",
"limit": "50 KB",
"import": "{ Button }",
"ignore": ["react", "react-dom"]
},
{
"path": "dist/index.js",
"limit": "100 KB"
}
]
5. CSS最適化
5.1 Critical CSS抽出(ちゅうしゅつ)
// crittersプラグインでCritical CSSをインライン化
// next.config.js
module.exports = {
experimental: {
optimizeCss: true, // Next.js内蔵CSS最適化
},
};
// 手動Critical CSS抽出
const critical = require('critical');
critical.generate({
inline: true,
base: 'dist/',
src: 'index.html',
target: 'index-critical.html',
width: 1300,
height: 900,
penthouse: {
blockJSRequests: false,
},
});
5.2 未使用(みしよう)CSSの除去
// PurgeCSS設定
// postcss.config.js
module.exports = {
plugins: [
require('@fullhuman/postcss-purgecss')({
content: [
'./src/**/*.{js,jsx,ts,tsx}',
'./public/index.html',
],
defaultExtractor: (content) =>
content.match(/[\w-/:]+(?<!:)/g) || [],
safelist: {
standard: [/^modal-/, /^tooltip-/],
deep: [/^data-theme/],
greedy: [/animate/],
},
}),
],
};
5.3 CSS Containment
/* contain属性でレンダリング範囲を制限 */
.card {
contain: layout style paint;
}
/* content-visibilityで画面外のレンダリングをスキップ */
.article-section {
content-visibility: auto;
contain-intrinsic-size: 0 500px;
}
/* 長いリストの各アイテムに適用 */
.list-item {
content-visibility: auto;
contain-intrinsic-size: auto 80px;
}
6. フォント最適化
6.1 font-display戦略
/* font-displayオプション比較 */
@font-face {
font-family: 'MyFont';
src: url('/fonts/myfont.woff2') format('woff2');
/* swap:FOUTを許可、CLSが発生する可能性あり */
font-display: swap;
/* optional:3秒FOITの後、システムフォントを維持(CLSなし) */
font-display: optional;
/* fallback:100ms FOIT後にswap、3秒後に維持 */
font-display: fallback;
}
6.2 フォントプリロードとVariable Fonts
<!-- フォントプリロード -->
<link
rel="preload"
href="/fonts/inter-var.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
/* Variable Fontでファイル数を削減 */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2-variations');
font-weight: 100 900;
font-style: normal;
font-display: optional;
}
/* サブセットで必要な文字のみ含める */
@font-face {
font-family: 'NotoSansJP';
src: url('/fonts/noto-sans-jp-subset.woff2') format('woff2');
unicode-range: U+3000-30FF, U+4E00-9FFF; /* ひらがな、カタカナ、漢字 */
font-display: swap;
}
// Next.js - next/font最適化
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'optional',
preload: true,
variable: '--font-inter',
adjustFontFallback: true, // CLS防止のためのフォールバック調整
});
export default function RootLayout({ children }) {
return (
<html className={inter.variable}>
<body>{children}</body>
</html>
);
}
7. キャッシュ戦略(せんりゃく)
7.1 HTTP Cacheヘッダー
キャッシュ戦略フローチャート:
┌──────────────────────────────────────────┐
│ リソースは再利用可能か? │
├── No -> Cache-Control: no-store │
├── Yes -> 毎回サーバー確認が必要か? │
│ ├── Yes -> Cache-Control: no-cache │
│ └── No -> 中間キャッシュを許可? │
│ ├── Yes -> Cache-Control: public │
│ └── No -> Cache-Control: private │
│ └── max-age設定 │
│ ├── ハッシュ付き -> 31536000│
│ └── HTML -> 0 + ETag │
└──────────────────────────────────────────┘
# Nginxキャッシュ設定例
server {
# HTML - 常にサーバーで確認
location ~* \.html$ {
add_header Cache-Control "no-cache";
add_header ETag $upstream_http_etag;
}
# ハッシュ付き静的アセット - 1年キャッシュ
location ~* \.(js|css|webp|avif|woff2)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# APIレスポンス - stale-while-revalidate
location /api/ {
add_header Cache-Control "public, max-age=60, stale-while-revalidate=300";
}
}
7.2 Service Workerキャッシュ
// service-worker.js - Workboxベースのキャッシュ戦略
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import {
CacheFirst,
StaleWhileRevalidate,
NetworkFirst,
} from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
// ビルド時に生成されたアセットをプリキャッシュ
precacheAndRoute(self.__WB_MANIFEST);
// 画像:Cache First
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30日
}),
],
})
);
// API:Stale While Revalidate
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new StaleWhileRevalidate({
cacheName: 'api-cache',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 5 * 60, // 5分
}),
],
})
);
// HTMLページ:Network First
registerRoute(
({ request }) => request.mode === 'navigate',
new NetworkFirst({
cacheName: 'pages',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
],
networkTimeoutSeconds: 3,
})
);
7.3 CDNエッジキャッシュ
// Vercel Edge Config例
// next.config.js
module.exports = {
headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'CDN-Cache-Control',
value: 'public, max-age=60, stale-while-revalidate=3600',
},
{
key: 'Vercel-CDN-Cache-Control',
value: 'public, max-age=3600, stale-while-revalidate=86400',
},
],
},
];
},
};
8. レンダリングパターン比較(ひかく)
8.1 CSR vs SSR vs SSG vs ISR vs Streaming
レンダリングパターン比較:
┌──────────┬──────────┬──────────┬──────────┬──────────┐
│ │ TTFB │ FCP │ TTI │ SEO │
├──────────┼──────────┼──────────┼──────────┼──────────┤
│ CSR │ 速い │ 遅い │ 遅い │ 悪い │
│ SSR │ 遅い │ 速い │ 普通 │ 良い │
│ SSG │ 非常速 │ 非常速 │ 速い │ 良い │
│ ISR │ 非常速 │ 非常速 │ 速い │ 良い │
│ Streaming│ 速い │ 非常速 │ 速い │ 良い │
└──────────┴──────────┴──────────┴──────────┴──────────┘
8.2 Streaming SSR(React 18 + Next.js App Router)
// app/dashboard/page.tsx - Streaming SSR
import { Suspense } from 'react';
async function SlowDataComponent() {
const data = await fetchSlowData(); // 3秒かかる
return <div>{/* データレンダリング */}</div>;
}
async function FastDataComponent() {
const data = await fetchFastData(); // 100msかかる
return <div>{/* データレンダリング */}</div>;
}
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
{/* 速いコンポーネントは即座にレンダリング */}
<FastDataComponent />
{/* 遅いコンポーネントはストリーミング */}
<Suspense fallback={<LoadingSkeleton />}>
<SlowDataComponent />
</Suspense>
</div>
);
}
8.3 ISR(Incremental Static Regeneration)
// Next.js App Router - ISR
// app/products/[id]/page.tsx
export const revalidate = 3600; // 1時間ごとに再生成
export async function generateStaticParams() {
const products = await getTopProducts();
return products.map((product) => ({
id: product.id.toString(),
}));
}
export default async function ProductPage({ params }) {
const product = await getProduct(params.id);
return <ProductDetail product={product} />;
}
// オンデマンド再検証
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
export async function POST(request) {
const { path, tag, secret } = await request.json();
if (secret !== process.env.REVALIDATION_SECRET) {
return Response.json({ error: 'Invalid secret' }, { status: 401 });
}
if (tag) {
revalidateTag(tag);
} else if (path) {
revalidatePath(path);
}
return Response.json({ revalidated: true, now: Date.now() });
}
9. プリフェッチ戦略
9.1 Link PrefetchとRoute Prefetch
<!-- DNS Prefetch:外部ドメインのDNSを事前解決 -->
<link rel="dns-prefetch" href="//cdn.example.com" />
<!-- Preconnect:DNS + TCP + TLS接続を事前確立 -->
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
<!-- Prefetch:次のナビゲーションに必要なリソースを事前取得 -->
<link rel="prefetch" href="/next-page.html" />
<link rel="prefetch" href="/api/data.json" as="fetch" />
<!-- Prerender:ページ全体を事前レンダリング -->
<link rel="prerender" href="/likely-next-page" />
9.2 Speculation Rules API
<!-- Speculation Rules API(Chrome 121+) -->
<script type="speculationrules">
{
"prerender": [
{
"where": {
"and": [
{ "href_matches": "/*" },
{ "not": { "href_matches": "/logout" } },
{ "not": { "href_matches": "/api/*" } }
]
},
"eagerness": "moderate"
}
],
"prefetch": [
{
"urls": ["/products", "/about"],
"eagerness": "eager"
}
]
}
</script>
// Next.jsでのPrefetch戦略
import Link from 'next/link';
// Linkコンポーネントはビューポートに入ると自動的にprefetch
<Link href="/dashboard" prefetch={true}>
Dashboard
</Link>
// router.prefetchでプログラマティックprefetch
import { useRouter } from 'next/navigation';
function Navigation() {
const router = useRouter();
const handleMouseEnter = () => {
router.prefetch('/settings');
};
return (
<button onMouseEnter={handleMouseEnter} onClick={() => router.push('/settings')}>
Settings
</button>
);
}
10. サードパーティスクリプト最適化
10.1 defer/asyncとローディング戦略
<!-- スクリプトローディングパターン比較 -->
<!-- 1. デフォルト:HTMLパース遮断 -->
<script src="script.js"></script>
<!-- 2. async:ダウンロード並列、実行時遮断(順序保証なし) -->
<script async src="analytics.js"></script>
<!-- 3. defer:ダウンロード並列、DOMContentLoaded前に順序通り実行 -->
<script defer src="app.js"></script>
<!-- 4. type=module:deferと同様 + ES Module -->
<script type="module" src="app.mjs"></script>
10.2 Partytownでサードパーティを隔離(かくり)
// Partytown:サードパーティスクリプトをWeb Workerに移動
// next.config.js
const { withPartytown } = require('@builder.io/partytown/next');
module.exports = withPartytown({
partytown: {
forward: ['dataLayer.push', 'gtag'],
},
});
// Next.js Scriptコンポーネント活用
import Script from 'next/script';
export default function MyApp({ Component, pageProps }) {
return (
<>
{/* beforeInteractive:_documentでロード */}
<Script
src="https://polyfill.io/v3/polyfill.min.js"
strategy="beforeInteractive"
/>
{/* afterInteractive:ページハイドレーション後(デフォルト) */}
<Script
src="https://www.googletagmanager.com/gtag/js"
strategy="afterInteractive"
/>
{/* lazyOnload:ブラウザidle時にロード */}
<Script
src="https://connect.facebook.net/en_US/fbevents.js"
strategy="lazyOnload"
/>
{/* worker:PartytownでWeb Worker実行 */}
<Script
src="https://example.com/tracking.js"
strategy="worker"
/>
<Component {...pageProps} />
</>
);
}
11. Lighthouse深掘(ふかぼ)り分析
11.1 Lighthouseスコア体系(たいけい)
Lighthouse Performanceスコア加重(v12):
┌───────────────────────────┬────────┐
│ 指標 │ 加重 │
├───────────────────────────┼────────┤
│ FCP(First Contentful) │ 10% │
│ SI(Speed Index) │ 10% │
│ LCP(Largest Contentful) │ 25% │
│ TBT(Total Blocking Time)│ 30% │
│ CLS(Cumulative L. Shift)│ 25% │
└───────────────────────────┴────────┘
INPはフィールドデータ(CrUX)でのみ測定
11.2 Lighthouse CI自動化(じどうか)
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [pull_request]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci && npm run build
- name: Run Lighthouse
uses: treosh/lighthouse-ci-action@v11
with:
configPath: ./lighthouserc.json
uploadArtifacts: true
temporaryPublicStorage: true
// lighthouserc.json
{
"ci": {
"collect": {
"numberOfRuns": 3,
"url": [
"http://localhost:3000/",
"http://localhost:3000/blog",
"http://localhost:3000/products"
],
"startServerCommand": "npm start"
},
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"categories:accessibility": ["warn", { "minScore": 0.95 }],
"first-contentful-paint": ["error", { "maxNumericValue": 1800 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-blocking-time": ["error", { "maxNumericValue": 300 }]
}
}
}
}
12. パフォーマンスモニタリング
12.1 Web Vitalsライブラリ
// web-vitalsライブラリで実際のユーザー指標を収集
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals';
function sendToAnalytics(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
entries: metric.entries,
});
// Beacon APIでページ離脱時も安定的に送信
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/vitals', body);
} else {
fetch('/api/vitals', { body, method: 'POST', keepalive: true });
}
}
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);
12.2 RUM(Real User Monitoring)ダッシュボード
// カスタムRUMコレクター
class PerformanceMonitor {
constructor() {
this.metrics = {};
this.init();
}
init() {
// Navigation Timing
window.addEventListener('load', () => {
const nav = performance.getEntriesByType('navigation')[0];
this.metrics.dns = nav.domainLookupEnd - nav.domainLookupStart;
this.metrics.tcp = nav.connectEnd - nav.connectStart;
this.metrics.ttfb = nav.responseStart - nav.requestStart;
this.metrics.domLoad = nav.domContentLoadedEventEnd - nav.fetchStart;
this.metrics.fullLoad = nav.loadEventEnd - nav.fetchStart;
this.report();
});
// Long Tasks
const longTaskObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
this.trackLongTask({
duration: entry.duration,
startTime: entry.startTime,
});
}
});
try {
longTaskObserver.observe({ type: 'longtask', buffered: true });
} catch (e) {
// longtask observer未対応
}
}
trackLongTask(data) {
console.warn('Long Task:', data.duration + 'ms');
}
report() {
console.table(this.metrics);
}
}
new PerformanceMonitor();
12.3 CrUX(Chrome User Experience Report)
// CrUX APIでフィールドデータを照会
async function getCruxData(url) {
const apiKey = process.env.CRUX_API_KEY;
const response = await fetch(
'https://chromeuxreport.googleapis.com/v1/records:queryRecord' +
'?key=' + apiKey,
{
method: 'POST',
body: JSON.stringify({
url: url,
formFactor: 'PHONE',
metrics: [
'largest_contentful_paint',
'interaction_to_next_paint',
'cumulative_layout_shift',
'experimental_time_to_first_byte',
],
}),
}
);
const data = await response.json();
const lcp = data.record.metrics.largest_contentful_paint;
console.log('LCP p75:', lcp.percentiles.p75 + 'ms');
return data;
}
13. 実践(じっせん)最適化チェックリスト
パフォーマンス最適化チェックリスト:
[ ] 画像
[ ] WebP/AVIFフォーマット使用
[ ] 適切なサイズのレスポンシブ画像提供
[ ] LCP画像にpriority/fetchpriority設定
[ ] スクロール下の画像にloading="lazy"
[ ] aspect-ratioまたはwidth/height明示
[ ] JavaScript
[ ] コード分割(ルートベース + コンポーネントベース)
[ ] Tree Shaking確認(sideEffects: false)
[ ] バンドルサイズモニタリング
[ ] 不要なpolyfill除去
[ ] サードパーティスクリプトをdefer/async/worker
[ ] CSS
[ ] Critical CSSインライン化
[ ] 未使用CSS除去
[ ] content-visibility活用
[ ] CSS Containment適用
[ ] フォント
[ ] WOFF2フォーマット使用
[ ] font-display: optionalまたはswap
[ ] サブセット適用
[ ] Variable Font活用
[ ] preload設定
[ ] キャッシュ
[ ] 静的アセット:immutable + 1年
[ ] HTML:no-cache + ETag
[ ] API:stale-while-revalidate
[ ] Service Workerキャッシュ戦略
[ ] CDNエッジキャッシュ設定
[ ] レンダリング
[ ] 適切なレンダリングパターン選択
[ ] Suspenseでストリーミング
[ ] ISR revalidate設定
[ ] loading.tsxスケルトン
[ ] モニタリング
[ ] Lighthouse CI自動化
[ ] Web Vitals RUM収集
[ ] CrUXデータモニタリング
[ ] パフォーマンスリグレッションアラート設定
14. クイズ
以下(いか)のクイズで学習内容(がくしゅうないよう)を確認(かくにん)しましょう。
Q1. LCP(Largest Contentful Paint)のGood基準値(きじゅんち)と、改善のための最も効果的な2つの方法は?
A1. LCPのGood基準値は2.5秒以下です。
最も効果的な改善方法:
- LCP画像に
fetchpriority="high"を設定 - ブラウザがLCPリソースを最優先でダウンロードするようにします。 preloadリンクタグを使用 - パーサーが発見する前にLCPリソースのダウンロードを開始します。
追加でサーバーレスポンス時間短縮(CDN、キャッシュ)、レンダリング遮断リソースの除去、画像最適化(WebP/AVIF)等も効果的です。
Q2. INPと以前の指標FIDの違いと、INPを改善するためのReactパターンは?
A2. FIDは最初のインタラクションの入力遅延のみを測定しましたが、INPはセッション全体の全てのインタラクションを考慮し、最も遅いインタラクション(またはp98)を報告します。さらにINPは入力遅延だけでなく処理時間とレンダリング遅延も含みます。
Reactでの改善パターン:
useTransition:緊急でない状態更新を低優先度で処理useDeferredValue:値の更新を遅延してUI応答性を維持scheduler.yield():長いタスクを分割してメインスレッドに譲渡
Q3. Service WorkerのCacheFirstとStaleWhileRevalidate戦略の違いと適切な使用例は?
A3.
CacheFirst:キャッシュにあればキャッシュから即座に返却、なければネットワークリクエスト。キャッシュヒット時はネットワークリクエストをしません。
- 適切な使用例:画像、フォント、静的アセット等、変更頻度の低いリソース
StaleWhileRevalidate:キャッシュから即座に返却しつつ、同時にバックグラウンドでネットワークリクエストしてキャッシュを更新します。
- 適切な使用例:APIレスポンス、ニュースフィード等、最新性は必要だが即座の応答も重要なリソース
NetworkFirstは、常に最新コンテンツが必要なHTMLページ等に適しています。
Q4. Streaming SSRが従来のSSRより優れている点と、Next.js App Routerでの実装方法は?
A4. 従来のSSRは全てのデータフェッチが完了するまでHTML送信を開始できませんでした。Streaming SSRは準備できた部分からHTMLチャンクを即座に送信して:
- TTFBを短縮(遅いデータに依存しない)
- FCPを改善(速いコンポーネントを先に表示)
- 体感パフォーマンスが向上
Next.js App Routerでの実装:
Suspenseコンポーネントで遅いデータコンポーネントをラップfallbackpropにスケルトン/ローディングUIを提供loading.tsxファイルでルートレベルのローディング状態を定義- サーバーコンポーネントで
async/awaitでデータフェッチ
Q5. Speculation Rules APIと従来のlink rel="prefetch"の違いとメリットは?
A5. 従来のlink rel="prefetch"はリソースのダウンロードのみですが、Speculation Rules APIはページ全体を事前レンダリング(prerender)できます。
主な違い:
- Prefetch:リソースのダウンロードのみ(HTML、JS等)
- Prerender(Speculation Rules):隠しタブでページ全体をレンダリングまで完了
メリット:
- 即座のページ遷移(既にレンダリング完了)
- 条件付きルール:URLパターン、eagernessレベル設定可能
- ブラウザ最適化:メモリ/ネットワーク状況に応じて自動調整
- JSON宣言的構文でメンテナンスが容易
ただしChrome 121以上でのみサポートされ、ログアウトやAPIエンドポイントは除外が必要です。
15. 参考(さんこう)資料(しりょう)
- web.dev - Core Web Vitals - GoogleのCore Web Vitals公式ガイド
- web.dev - Optimize LCP - LCP最適化ガイド
- web.dev - Optimize INP - INP最適化ガイド
- web.dev - Optimize CLS - CLS最適化ガイド
- Chrome Developers - Speculation Rules API - Speculation Rulesガイド
- Next.js Documentation - Optimizing - Next.js最適化ドキュメント
- Workbox - Service Worker Libraries - Google Workbox公式ドキュメント
- web.dev - Optimize Images - 画像最適化ガイド
- Partytown - Web Worker for Third-party Scripts - Partytown公式サイト
- web-vitals - JavaScript Library - web-vitalsライブラリ
- CrUX Dashboard - Chrome UX Reportドキュメント
- Lighthouse CI - Lighthouse CI自動化
- HTTP Caching - MDN - HTTPキャッシュ完全ガイド