Skip to content
Published on

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

Authors

目次(もくじ)

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(LCPINPCLS├── 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%WebP25-34% │ あり       │ あり       │ 97%+AVIF50%+   │ あり       │ あり       │ 92%+JPEG XL35-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

レンダリングパターン比較:
┌──────────┬──────────┬──────────┬──────────┬──────────┐
│          │ TTFBFCPTTISEO├──────────┼──────────┼──────────┼──────────┼──────────┤
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. プリフェッチ戦略

<!-- 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秒以下です。

最も効果的な改善方法:

  1. LCP画像にfetchpriority="high"を設定 - ブラウザがLCPリソースを最優先でダウンロードするようにします。
  2. 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での実装:

  1. Suspenseコンポーネントで遅いデータコンポーネントをラップ
  2. fallback propにスケルトン/ローディングUIを提供
  3. loading.tsxファイルでルートレベルのローディング状態を定義
  4. サーバーコンポーネントでasync/awaitでデータフェッチ
Q5. Speculation Rules APIと従来のlink rel="prefetch"の違いとメリットは?

A5. 従来のlink rel="prefetch"はリソースのダウンロードのみですが、Speculation Rules APIはページ全体を事前レンダリング(prerender)できます。

主な違い:

  • Prefetch:リソースのダウンロードのみ(HTML、JS等)
  • Prerender(Speculation Rules):隠しタブでページ全体をレンダリングまで完了

メリット:

  1. 即座のページ遷移(既にレンダリング完了)
  2. 条件付きルール:URLパターン、eagernessレベル設定可能
  3. ブラウザ最適化:メモリ/ネットワーク状況に応じて自動調整
  4. JSON宣言的構文でメンテナンスが容易

ただしChrome 121以上でのみサポートされ、ログアウトやAPIエンドポイントは除外が必要です。

15. 参考(さんこう)資料(しりょう)

  1. web.dev - Core Web Vitals - GoogleのCore Web Vitals公式ガイド
  2. web.dev - Optimize LCP - LCP最適化ガイド
  3. web.dev - Optimize INP - INP最適化ガイド
  4. web.dev - Optimize CLS - CLS最適化ガイド
  5. Chrome Developers - Speculation Rules API - Speculation Rulesガイド
  6. Next.js Documentation - Optimizing - Next.js最適化ドキュメント
  7. Workbox - Service Worker Libraries - Google Workbox公式ドキュメント
  8. web.dev - Optimize Images - 画像最適化ガイド
  9. Partytown - Web Worker for Third-party Scripts - Partytown公式サイト
  10. web-vitals - JavaScript Library - web-vitalsライブラリ
  11. CrUX Dashboard - Chrome UX Reportドキュメント
  12. Lighthouse CI - Lighthouse CI自動化
  13. HTTP Caching - MDN - HTTPキャッシュ完全ガイド