フロントエンドパフォーマンスバジェット:Core Web Vitals最適化の6つの重要戦略

性能优化

フロントエンドパフォーマンスの課題:なぜサイトは遅くなり続けるのか

2026年、フロントエンドプロジェクトの複雑さは増し続けている。LCP超過、CLSの揺らぎ、INP遅延、バンドルサイズの膨張、パフォーマンス低下の不可視化——これらの問題がユーザー体験と検索ランキングをむしばんでいる。

課題 典型的な症状 ビジネスへの影響
LCP超過 初回描画 > 3s 直帰率53%上昇
CLS揺らぎ レイアウトシフト > 0.25 誤クリック、信頼低下
INP遅延 インタラクション応答 > 500ms ユーザー離脱率40%増加
バンドル膨張 JS > 500KB (gzip) 初回読み込み時間2倍化
パフォーマンス低下の不可視化 リリース後スコアが静かに低下 数ヶ月間問題に気づかない

コア概念クイックリファレンス

概念 説明 目標値
パフォーマンスバジェット パフォーマンス指標に設定する定量化された閾値 チーム定義
LCP Largest Contentful Paint ≤ 2.5s
FID First Input Delay ≤ 100ms
CLS Cumulative Layout Shift ≤ 0.1
INP Interaction to Next Paint ≤ 200ms
TTFB Time to First Byte ≤ 800ms
Lighthouse Google自動パフォーマンス監査ツール ≥ 90点
RUM (Real User Monitoring) リアルユーザーパフォーマンスデータ収集 P75達標
バンドル分析 Bundle構成とサイズ追跡 JS ≤ 200KB
コード分割 ルート/機能ごとの読み込み単位分割 初回画面 ≤ 100KB

5つの課題:なぜパフォーマンスバジェットは定着しないのか

1. 指標定義の不明確さ — 「良いパフォーマンス」についてチーム内の合意がない。LCPはラボデータかフィールドデータか?P75かP95か?

2. バジェット実行の困難さ — バジェットは定義されたが強制手段がない。PRはマージされ、超過しても誰も気にしない。

3. サードパーティスクリプトの影響 — 分析、広告、チャットウィジェットがJS実行時間の40-60%を占めるが、ほぼ制御できない。

4. モバイルパフォーマンスの格差 — デスクトップでLighthouse 95点、低価格AndroidでLCP 6秒。格差は甚大。

5. パフォーマンス低下の検出 — 継続的監視がないと、パフォーマンス低下は茹でガエルのように進行し、気づいた時には深刻な超過状態に。


戦略1:Core Web Vitals指標収集と分析

web-vitalsライブラリを使用してリアルユーザー指標を収集し、分析プラットフォームに報告:

import { onLCP, onFID, onCLS, onINP, onTTFB } from 'web-vitals';

interface PerformanceMetric {
  name: string;
  value: number;
  rating: 'good' | 'needs-improvement' | 'poor';
  delta: number;
  navigationType: string;
  timestamp: number;
}

function reportMetric(metric: PerformanceMetric): void {
  const body = JSON.stringify({
    name: metric.name,
    value: Math.round(metric.value),
    rating: metric.rating,
    delta: Math.round(metric.delta),
    navigationType: metric.navigationType,
    url: window.location.href,
    timestamp: metric.timestamp,
    userAgent: navigator.userAgent,
    connection: (navigator as any).connection?.effectiveType ?? 'unknown',
  });

  if (navigator.sendBeacon) {
    navigator.sendBeacon('/api/v1/metrics', body);
  } else {
    fetch('/api/v1/metrics', { body, method: 'POST', keepalive: true });
  }
}

onLCP(reportMetric);
onFID(reportMetric);
onCLS(reportMetric);
onINP(reportMetric);
onTTFB(reportMetric);

サーバー側でP75集計後、バジェット閾値と比較:

const PERFORMANCE_BUDGET: Record<string, { good: number; poor: number }> = {
  LCP:  { good: 2500, poor: 4000 },
  FID:  { good: 100,  poor: 300 },
  CLS:  { good: 0.1,  poor: 0.25 },
  INP:  { good: 200,  poor: 500 },
  TTFB: { good: 800,  poor: 1800 },
};

function evaluateBudget(
  metric: string,
  p75Value: number,
): 'pass' | 'warning' | 'fail' {
  const budget = PERFORMANCE_BUDGET[metric];
  if (!budget) return 'pass';
  if (p75Value <= budget.good) return 'pass';
  if (p75Value <= budget.poor) return 'warning';
  return 'fail';
}

戦略2:バンドルサイズバジェットとLighthouse CI統合

CI/CDパイプラインでバンドルサイズとLighthouseスコアバジェットを強制実行:

// lighthouse-budget.json
const lighthouseBudget = {
  budgets: [
    {
      path: '/*',
      options: {
        firstContentfulPaint: 1800,
        largestContentfulPaint: 2500,
        cumulativeLayoutShift: 0.1,
        totalBlockingTime: 200,
        interactive: 3500,
      },
      resourceSizes: [
        { resourceType: 'script', budget: 200 },
        { resourceType: 'stylesheet', budget: 50 },
        { resourceType: 'image', budget: 300 },
        { resourceType: 'total', budget: 800 },
      ],
      resourceCounts: [
        { resourceType: 'third-party', budget: 5 },
        { resourceType: 'total', budget: 30 },
      ],
    },
  ],
};

export default lighthouseBudget;

GitHub Actions統合:

# .github/workflows/lighthouse-ci.yml
name: Lighthouse CI
on: [push]
jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci && npm run build
      - name: Run Lighthouse CI
        uses: treosh/lighthouse-ci-action@v12
        with:
          configPath: .lighthouserc.json
          budgetPath: lighthouse-budget.json
          uploadArtifacts: true
          failOnBudgetExceeded: true
{
  "ci": {
    "collect": {
      "numberOfRuns": 3,
      "startServerCommand": "npm run preview",
      "url": ["http://localhost:4173/"]
    },
    "assert": {
      "assertions": {
        "categories:performance": ["error", { "minScore": 0.9 }],
        "first-contentful-paint": ["error", { "maxNumericValue": 1800 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
        "total-blocking-time": ["error", { "maxNumericValue": 200 }]
      }
    }
  }
}

戦略3:画像とリソース読み込み最適化

interface ImageOptimizationConfig {
  maxWidth: number;
  quality: number;
  formats: string[];
  sizes: string;
  lazyThreshold: number;
}

const imageConfig: ImageOptimizationConfig = {
  maxWidth: 1600,
  quality: 80,
  formats: ['avif', 'webp', 'jpg'],
  sizes: '(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px',
  lazyThreshold: 0.1,
};

function generatePictureElement(
  src: string,
  alt: string,
  width: number,
  height: number,
  isLcp: boolean = false,
): string {
  const { formats, sizes, quality } = imageConfig;
  const widths = [400, 800, 1200, 1600];

  const sources = formats
    .filter((f) => f !== 'jpg')
    .map((format) => {
      const srcset = widths
        .map((w) => `${src}?w=${w}&q=${quality}&f=${format} ${w}w`)
        .join(', ');
      return `<source srcset="${srcset}" type="image/${format}" />`;
    })
    .join('\n');

  const imgSrcset = widths
    .map((w) => `${src}?w=${w}&q=${quality} ${w}w`)
    .join(', ');

  const lcpAttrs = isLcp
    ? 'fetchpriority="high" loading="eager"'
    : 'loading="lazy"';

  return `<picture>\n${sources}\n<img src="${src}?w=${width}&q=${quality}" srcset="${imgSrcset}" sizes="${sizes}" alt="${alt}" width="${width}" height="${height}" decoding="async" ${lcpAttrs} />\n</picture>`;
}

function setupResourceHints(): void {
  const preconnectDomains = [
    'https://fonts.googleapis.com',
    'https://cdn.example.com',
  ];
  preconnectDomains.forEach((domain) => {
    const link = document.createElement('link');
    link.rel = 'preconnect';
    link.href = domain;
    link.crossOrigin = 'anonymous';
    document.head.appendChild(link);
  });
}

戦略4:コード分割とレイジーローディング戦略

import { defineAsyncComponent } from 'vue';

interface ChunkConfig {
  name: string;
  test: (modulePath: string) => boolean;
  priority: number;
  minSize: number;
}

const chunkStrategy: ChunkConfig[] = [
  { name: 'vendor-vue', test: /node_modules\/vue/, priority: 10, minSize: 0 },
  { name: 'vendor-ui', test: /node_modules\/@ui-lib/, priority: 8, minSize: 10000 },
  { name: 'vendor-utils', test: /node_modules\/(lodash|date-fns)/, priority: 5, minSize: 0 },
  { name: 'vendor-other', test: /node_modules/, priority: -10, minSize: 20000 },
];

function buildRollupOutputChunks() {
  const manualChunks = (id: string) => {
    if (!id.includes('node_modules')) return;
    for (const chunk of chunkStrategy) {
      if (chunk.test(id)) return chunk.name;
    }
  };
  return { manualChunks };
}

const LazyChart = defineAsyncComponent({
  loader: () => import('@/components/HeavyChart.vue'),
  loadingComponent: () => null,
  delay: 200,
  timeout: 10000,
});

const LazyEditor = defineAsyncComponent({
  loader: () => import('@/components/RichEditor.vue'),
  loadingComponent: () => null,
  delay: 200,
  timeout: 10000,
});

function setupIntersectionLazyLoad(): void {
  const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          const el = entry.target as HTMLElement;
          const modulePath = el.dataset.lazyModule;
          if (modulePath) {
            import(/* @vite-ignore */ modulePath).then((mod) => {
              el.dispatchEvent(new CustomEvent('lazy-loaded', { detail: mod }));
            });
            observer.unobserve(el);
          }
        }
      });
    },
    { rootMargin: '200px' },
  );

  document.querySelectorAll('[data-lazy-module]').forEach((el) => {
    observer.observe(el);
  });
}

戦略5:サードパーティスクリプトのガバナンス

interface ThirdPartyScript {
  name: string;
  src: string;
  category: 'analytics' | 'ads' | 'chat' | 'social' | 'other';
  impact: 'high' | 'medium' | 'low';
  loadStrategy: 'async' | 'defer' | 'lazy' | 'worker';
  condition?: () => boolean;
}

const thirdPartyRegistry: ThirdPartyScript[] = [
  {
    name: 'google-analytics',
    src: 'https://www.googletagmanager.com/gtag/js',
    category: 'analytics',
    impact: 'medium',
    loadStrategy: 'async',
    condition: () => !navigator.doNotTrack,
  },
  {
    name: 'live-chat',
    src: 'https://chat.example.com/widget.js',
    category: 'chat',
    impact: 'high',
    loadStrategy: 'lazy',
    condition: () => window.innerWidth >= 768,
  },
  {
    name: 'ad-network',
    src: 'https://ads.example.com/sdk.js',
    category: 'ads',
    impact: 'high',
    loadStrategy: 'worker',
  },
];

function loadThirdPartyScript(script: ThirdPartyScript): Promise<void> {
  if (script.condition && !script.condition()) {
    return Promise.resolve();
  }

  return new Promise((resolve, reject) => {
    const el = document.createElement('script');
    el.src = script.src;

    switch (script.loadStrategy) {
      case 'async':
        el.async = true;
        document.head.appendChild(el);
        break;
      case 'defer':
        el.defer = true;
        document.head.appendChild(el);
        break;
      case 'lazy':
        setTimeout(() => {
          el.async = true;
          document.head.appendChild(el);
        }, 3000);
        break;
      case 'worker':
        fetch(script.src)
          .then((r) => r.text())
          .then((code) => {
            const blob = new Blob([code], { type: 'application/javascript' });
            const worker = new Worker(URL.createObjectURL(blob));
            worker.postMessage({ type: 'init' });
          })
          .catch(reject);
        break;
    }

    el.onload = () => resolve();
    el.onerror = () => reject(new Error(`Failed to load: ${script.name}`));
  });
}

function initThirdPartyScripts(): void {
  const highPriority = thirdPartyRegistry.filter((s) => s.impact === 'low');
  const lowPriority = thirdPartyRegistry.filter((s) => s.impact !== 'low');

  highPriority.forEach(loadThirdPartyScript);

  if ('requestIdleCallback' in window) {
    requestIdleCallback(() => {
      lowPriority.forEach(loadThirdPartyScript);
    });
  } else {
    setTimeout(() => {
      lowPriority.forEach(loadThirdPartyScript);
    }, 5000);
  }
}

戦略6:パフォーマンス監視とアラートシステム

interface AlertRule {
  metric: string;
  threshold: number;
  windowMinutes: number;
  sampleSize: number;
  channels: ('email' | 'slack' | 'webhook')[];
}

interface PerformanceAlert {
  metric: string;
  currentValue: number;
  threshold: number;
  affectedUsers: number;
  timestamp: string;
}

const alertRules: AlertRule[] = [
  { metric: 'LCP', threshold: 4000, windowMinutes: 30, sampleSize: 50, channels: ['slack', 'email'] },
  { metric: 'CLS', threshold: 0.25, windowMinutes: 30, sampleSize: 50, channels: ['slack'] },
  { metric: 'INP', threshold: 500, windowMinutes: 60, sampleSize: 100, channels: ['slack', 'email'] },
  { metric: 'TTFB', threshold: 1800, windowMinutes: 30, sampleSize: 50, channels: ['slack'] },
];

class PerformanceMonitor {
  private metricsBuffer: Map<string, number[]> = new Map();

  addMetric(name: string, value: number): void {
    if (!this.metricsBuffer.has(name)) {
      this.metricsBuffer.set(name, []);
    }
    const buffer = this.metricsBuffer.get(name)!;
    buffer.push(value);
    if (buffer.length > 1000) buffer.shift();
    this.checkAlerts(name);
  }

  private checkAlerts(metricName: string): void {
    const rule = alertRules.find((r) => r.metric === metricName);
    if (!rule) return;

    const buffer = this.metricsBuffer.get(metricName) ?? [];
    const recentValues = buffer.slice(-rule.sampleSize);
    if (recentValues.length < rule.sampleSize) return;

    const p75 = this.calculatePercentile(recentValues, 75);
    if (p75 > rule.threshold) {
      this.fireAlert({
        metric: metricName,
        currentValue: p75,
        threshold: rule.threshold,
        affectedUsers: recentValues.length,
        timestamp: new Date().toISOString(),
      });
    }
  }

  private calculatePercentile(values: number[], percentile: number): number {
    const sorted = [...values].sort((a, b) => a - b);
    const index = Math.ceil((percentile / 100) * sorted.length) - 1;
    return sorted[index];
  }

  private fireAlert(alert: PerformanceAlert): void {
    console.warn(`[Performance Alert] ${alert.metric} P75=${alert.currentValue}ms exceeds ${alert.threshold}ms`);
    fetch('/api/v1/alerts', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(alert),
      keepalive: true,
    });
  }
}

const monitor = new PerformanceMonitor();

よくある落とし穴

❌ やってはいけないこと ✅ 正しいアプローチ
Lighthouseのラボデータのみ確認 RUMフィールドデータと組み合わせて、ラボ+フィールドの二重検証
バジェットをドキュメントに定義するだけ CI/CDでfailOnBudgetExceeded: trueを強制実行
LCP画像にlazy loadingを使用 LCP要素にはloading="eager" + fetchpriority="high"が必須
サードパーティスクリプトをすべて同期的に読み込み 影響度で優先順位付け、低優先度はrequestIdleCallbackで遅延
デスクトップのみ最適化 低価格モバイルをベースラインに—Moto G Powerなどのミッドレンジ端末でテスト

トラブルシューティング

症状 考えられる原因 解決策
Lighthouseスコアが変動 ネットワーク不安定、CPUスロットリングの不一致 複数回実行して中央値を取得、numberOfRuns: 3を使用
LCPが常に > 4s 画像が最適化されていない、またはプリロードされていない AVIF + fetchpriority="high" + preloadを使用
CLS > 0.25 画像/広告にサイズ宣言がない 常にwidth/heightまたはaspect-ratioを設定
INP > 500ms ロングタスクがメインスレッドをブロック ロングタスクを分割、scheduler.yield()でメインスレッドを明け渡す
バンドルバジェットCIが機能しない budgetPathのパスが間違っている 相対パスを確認、JSON構文をチェック
web-vitalsデータが消失 ページアンロード時にsendBeaconがキャンセルされる fetch + keepalive: trueをフォールバックとして使用
コード分割で初回描画が遅くなる 分割しすぎてリクエストウォーターフォールが発生 小さなチャンクをマージ、クリティカルパスのリソースをインライン化
サードパーティスクリプトの読み込み失敗 CSPポリシーによるブロック Content-Security-Policyに該当ドメインを追加
TTFB > 1.8s SSRが遅い、またはCDNキャッシュミス エッジキャッシュを有効化、SSRキャッシュ戦略を最適化
フォントのフラッシュ (FOIT/FOUT) font-display宣言がない font-display: swap + preloadを使用

高度な最適化

  1. PartytownでサードパーティスクリプトをWeb Workerに移動 — 分析スクリプトなどの非クリティカルJSをメインスレッドからオフロード。INPを30-50%削減可能。<script type="text/partytown">を設定するだけ。

  2. Speculation Rulesによるプリレンダリング<script type="speculationrules">を使用して、ユーザーが訪問しそうなページをプリレンダリング。LCPを0.5s以下に削減可能。

  3. RUMデータに基づくA/Bパフォーマンス実験 — 異なる最適化戦略を異なるユーザーセグメントに適用し、P75指標の差を比較。推測ではなくデータで意思決定。

  4. Service Workerストリーミングキャッシュ — Streams APIを使用してプログレッシブなキャッシュレスポンスを実現。初回描画が完全なリソースダウンロードでブロックされない。


ツール比較

観点 Lighthouse WebPageTest SpeedCurve Calibre
位置づけ 自動監査 深度ネットワーク分析 継続的パフォーマンス監視 チームコラボレーション監視
データ種別 Synthetic Synthetic Synthetic + RUM Synthetic + RUM
デバイスエミュレーション 限定(Throttling) 豊富(実デバイス) マルチリージョン・マルチデバイス マルチリージョン・マルチデバイス
CI統合 優秀(公式CI) 普通 優秀 優秀
費用 無料 無料枠あり 有料 有料
適用シーン クイック監査、CIゲート ネットワーク問題の深度診断 長期トレンド監視 チーム協働とアラート

まとめと展望

フロントエンドパフォーマンスバジェットは一度きりのタスクではなく、継続的なエンジニアリングプラクティスである。2026年の主要トレンド:

  • INPがFIDに代わり Core Web Vitalsの正式指標に—インタラクション応答性が新たな焦点
  • パフォーマンスバジェットのCI化 — ドキュメントの取り決めからコードによる強制へ、failOnBudgetExceededがベースライン
  • RUM駆動の意思決定 — ラボデータは出発点にすぎない、リアルユーザーデータこそが真実
  • Web Workerオフロード — Partytownなどのソリューションでサードパーティスクリプトがメインスレッドを遅くしない

パフォーマンスバジェット体制を構築し、リリースのたびに「パフォーマンスセキュリティチェック」を行えるようにしよう。ユーザーからの苦情で問題を発見するのではなく。


オンラインツールおすすめ

ブラウザローカルツールを無料で試す →

#性能预算#前端性能优化#Core Web Vitals#Lighthouse#包体积优化#2026#性能优化