Docusサイトのパフォーマンス最適化完全ガイド — Core Web Vitals改善とLighthouseスコア向上

Docusサイトのパフォーマンス最適化完全ガイド — Core Web Vitals改善とLighthouseスコア向上

画像最適化、サードパーティスクリプト管理、バンドルサイズ削減、SSR/SSG最適化、キャッシュ戦略によるDocusサイトの高速化実践ガイド。

はじめに

この記事は、Docus実践ガイドを読んだ方に向けて、パフォーマンス最適化の詳細な実践方法を提供します。

この記事の対象読者:

  • Docusサイトのパフォーマンスを改善したい方
  • Lighthouseスコアを向上させたい方
  • Core Web Vitalsの各指標を最適化したい方
  • 実測値に基づいた改善策を知りたい方

この記事で扱うこと:

  • Core Web Vitalsの理解と測定方法
  • 画像最適化の実践(WebP、AVIF、レスポンシブ画像)
  • サードパーティスクリプトの最適化
  • バンドルサイズの削減戦略
  • フォントローディングの最適化
  • SSR/SSGの最適化テクニック
  • キャッシュ戦略とCDN設定
  • 継続的なモニタリングと改善プロセス

実践的なアプローチ:

  • 実測値を示しながら効果を検証
  • 段階的な改善ステップ
  • 費用対効果の高い施策を優先

1. Core Web Vitalsの理解と測定

Core Web Vitalsの3つの指標

Googleが定義するユーザー体験の重要指標:

LCP(Largest Contentful Paint):

  • 定義: 最大コンテンツの描画時間
  • 目標値: 2.5秒以下
  • 測定対象: 画像、動画、テキストブロックなど
  • ユーザー体験: ページが使えるようになるまでの待ち時間

FID(First Input Delay)INP(Interaction to Next Paint):

  • 定義: 初回入力遅延 → ユーザー操作への応答時間
  • 目標値: FID 100ms以下、INP 200ms以下
  • 測定対象: クリック、タップ、キー入力への反応
  • ユーザー体験: サイトのインタラクティブ性

CLS(Cumulative Layout Shift):

  • 定義: 累積レイアウトシフト
  • 目標値: 0.1以下
  • 測定対象: 視覚的な安定性
  • ユーザー体験: 予期しないレイアウト変化の防止

測定ツールとベースライン設定

Lighthouse(Chrome DevTools):

# ローカル開発環境で測定
# Chrome DevTools > Lighthouse > Generate report

# CLI版(CI/CD統合可能)
npm install -g @lhci/cli
lhci autorun --upload.target=temporary-public-storage

PageSpeed Insights:

  • URL: https://pagespeed.web.dev/
  • 実際のユーザーデータ(CrUX)とLab dataの両方を提供
  • モバイル/デスクトップ別のスコア

WebPageTest:

ベースライン設定:

# SSRビルドの場合
pnpm build
pnpm start

# または静的サイト生成の場合
pnpm generate
pnpm preview

# 別ターミナルでLighthouse実行
lighthouse http://localhost:3000 --output html --output-path ./lighthouse-baseline.html

測定結果の記録例:

## パフォーマンスベースライン(2025-12-08)

### Lighthouse Scores
- Performance: 68 / 100
- Accessibility: 95 / 100
- Best Practices: 92 / 100
- SEO: 100 / 100

### Core Web Vitals
- LCP: 3.2s(目標: 2.5s以下)
- FID: 45ms(目標: 100ms以下)
- CLS: 0.15(目標: 0.1以下)

### 主な問題
1. 最大コンテンツ(ヒーロー画像): 9.5MB PNG
2. レンダリングブロックリソース: 3件
3. 未使用JavaScript: 45KB

2. 画像最適化の実践

WebP/AVIF変換

WebP変換(84%削減の実例):

# cwebpのインストール(macOS)
brew install webp

# 一括変換スクリプト
cd public/img/blog
for file in *.png; do
  # 品質80で変換(推奨値: 75-85)
  cwebp -q 80 "$file" -o "${file%.png}.webp"
done

# 変換結果
# 元: blog-001.png (9.5MB)
# 変換後: blog-001.webp (1.5MB)
# 削減率: 84%

AVIF変換(さらに20%削減):

# avifencのインストール
brew install joedrago/repo/avifenc

# AVIF変換
for file in *.png; do
  avifenc -s 6 -q 75 "$file" "${file%.png}.avif"
done

# 変換結果
# WebP: 1.5MB → AVIF: 1.2MB
# さらに20%削減

Nuxt Imageの活用

NuxtPictureコンポーネント(複数フォーマット対応):

<template>
  <!-- 自動フォーマット選択 + レスポンシブ画像 -->
  <NuxtPicture
    :src="heroImage.src"
    :width="heroImage.width"
    :height="heroImage.height"
    :alt="heroImage.alt"
    :img-attrs="{
      class: 'hero-image',
      loading: 'eager',
      fetchpriority: 'high'
    }"
    sizes="sm:100vw md:1356px lg:1356px"
    :modifiers="{ fit: 'cover', quality: 80 }"
    format="avif,webp"
    preload
  />
</template>

<script setup lang="ts">
const heroImage = {
  src: '/img/blog/hero-001.webp',
  width: 1356,
  height: 642,
  alt: 'ヒーロー画像の説明'
}
</script>

レスポンシブ画像の自動生成:

<template>
  <!-- Nuxt Imageが自動でsrcsetを生成 -->
  <NuxtImg
    src="/img/card-image.webp"
    alt="カード画像"
    sizes="xs:100vw sm:50vw md:33vw lg:25vw"
    :modifiers="{ fit: 'cover', quality: 75 }"
    class="card-image"
  />
</template>

<!-- 生成されるHTML例 -->
<!--
<img
  src="/img/card-image.webp?w=1280&fit=cover&quality=75"
  srcset="
    /img/card-image.webp?w=320&fit=cover&quality=75 320w,
    /img/card-image.webp?w=640&fit=cover&quality=75 640w,
    /img/card-image.webp?w=960&fit=cover&quality=75 960w,
    /img/card-image.webp?w=1280&fit=cover&quality=75 1280w
  "
  sizes="(max-width: 639px) 100vw, (max-width: 767px) 50vw, (max-width: 1023px) 33vw, 25vw"
  alt="カード画像"
/>
-->

遅延ローディング戦略

Above the fold vs Below the fold:

<template>
  <!-- Above the fold(初期表示): 即時読み込み -->
  <NuxtPicture
    src="/img/hero.webp"
    alt="ヒーロー画像"
    :img-attrs="{ loading: 'eager', fetchpriority: 'high' }"
    format="avif,webp"
    preload
  />

  <!-- Below the fold(スクロール後): 遅延読み込み -->
  <NuxtImg
    src="/img/section-image.webp"
    alt="セクション画像"
    loading="lazy"
    format="webp"
  />
</template>

効果測定:

改善前:
- LCP: 3.2s(ヒーロー画像: 9.5MB PNG)
- 初期ロード: 12.8MB

改善後:
- LCP: 1.8s(ヒーロー画像: 1.2MB AVIF + 遅延ローディング)
- 初期ロード: 2.3MB(82%削減)

3. サードパーティスクリプトの最適化

@nuxt/scriptsでの統合管理

問題: 個別に追加したスクリプトが管理困難

解決策: @nuxt/scripts で一元管理

インストール:

pnpm add @nuxt/scripts

nuxt.config.tsでの設定:

export default defineNuxtConfig({
  modules: [
    '@nuxt/scripts',
    // ...他のモジュール
  ],

  scripts: {
    registry: {
      // Google Analytics(本番のみ)
      googleAnalytics: process.env.NODE_ENV === 'production' ? {
        id: 'G-XXXXXXXXXX'
      } : false,

      // Microsoft Clarity(本番のみ)
      clarity: process.env.NODE_ENV === 'production' ? {
        id: 'abcdefghij'
      } : false,

      // Google Tag Manager
      googleTagManager: process.env.NODE_ENV === 'production' ? {
        id: 'GTM-XXXXXXX'
      } : false,
    },
  },
})

重要ポイント:

  • $production ブロック内に配置しない
  • ✅ トップレベルに配置し、process.env.NODE_ENV で制御
  • 開発環境でのスクリプト読み込みを防ぎ、ビルド時間を短縮

スクリプト読み込みの最適化

defer/async属性の使い分け:

<!-- ページ読み込みをブロックしない -->
<script src="/script.js" defer></script>

<!-- 順序を問わず非同期読み込み -->
<script src="/analytics.js" async></script>

Partytown活用(Web Worker化):

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/partytown'],

  partytown: {
    // サードパーティスクリプトをWeb Workerで実行
    forward: ['dataLayer.push', 'gtag', 'clarity'],
  },
})

効果測定:

改善前:
- メインスレッド占有時間: 2.8s
- TBT(Total Blocking Time): 450ms

改善後:
- メインスレッド占有時間: 1.2s(57%削減)
- TBT: 180ms(60%削減)

4. バンドルサイズの削減戦略

依存関係の分析

Nuxt組み込みのバンドル分析:

# Nuxt組み込みのanalyzeコマンドを使用
pnpm nuxt analyze

# または package.json に追加
# "scripts": {
#   "analyze": "nuxt analyze"
# }

分析結果の確認:

  • .nuxt/analyze ディレクトリに結果が生成されます
  • クライアント側とサーバー側のバンドルを個別に分析
  • インタラクティブなツリーマップで視覚化

主な確認ポイント:

  • 大きなライブラリの特定
  • 重複している依存関係
  • 未使用のコードやモジュール

Tree Shakingの活用

不要なインポートの削除:

// ❌ 誤り: ライブラリ全体をインポート
import _ from 'lodash'
const result = _.debounce(func, 100)

// ✅ 正しい: 必要な関数のみインポート
import debounce from 'lodash/debounce'
const result = debounce(func, 100)

// さらに良い: lodash-esを使用
import { debounce } from 'lodash-es'

未使用エクスポートの特定:

# ts-pruneで未使用エクスポートを検出
pnpm add -D ts-prune
npx ts-prune

Code Splitting(コード分割)

ルートベースの分割(自動):

pages/
├── index.vue        → chunk-index.js
├── blog/
│   ├── index.vue    → chunk-blog-index.js
│   └── [slug].vue   → chunk-blog-slug.js
└── about.vue        → chunk-about.js

動的インポート:

<script setup lang="ts">
// 重いコンポーネントを動的インポート
const HeavyChart = defineAsyncComponent(() =>
  import('~/components/HeavyChart.vue')
)

// 条件付きインポート
const isAdmin = useIsAdmin()
const AdminPanel = isAdmin
  ? defineAsyncComponent(() => import('~/components/AdminPanel.vue'))
  : null
</script>

<template>
  <!-- ロード中の表示 -->
  <Suspense>
    <template #default>
      <HeavyChart v-if="showChart" />
    </template>
    <template #fallback>
      <div>チャートを読み込んでいます...</div>
    </template>
  </Suspense>
</template>

効果測定:

改善前:
- 初期バンドルサイズ: 380KB
- First Load JS: 410KB

改善後:
- 初期バンドルサイズ: 180KB(53%削減)
- First Load JS: 210KB(49%削減)
- 追加チャンク: 必要時のみ読み込み

5. フォントローディングの最適化

フォント読み込み戦略

font-display属性の活用:

/* app/assets/css/fonts.css */
@font-face {
  font-family: 'Custom Font';
  src: url('/fonts/custom-font.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  /* フォント表示戦略 */
  font-display: swap; /* FOUT(Flash of Unstyled Text)許容 */
}

/* font-displayオプション:
 * - auto: ブラウザのデフォルト動作
 * - block: 最大3秒待機(FOIT: Flash of Invisible Text)
 * - swap: すぐにフォールバック表示(FOUT)
 * - fallback: 100ms待機、3秒でフォールバック
 * - optional: 100ms待機、接続速度に応じて判断
 */

Google Fontsの最適化:

<!-- ❌ 誤り: レンダリングブロック -->
<link
  href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap"
  rel="stylesheet"
/>

<!-- ✅ 正しい: preconnect + 非同期読み込み -->
<template>
  <Head>
    <Link rel="preconnect" href="https://fonts.googleapis.com" />
    <Link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <Link
      rel="stylesheet"
      href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap"
      media="print"
      onload="this.media='all'"
    />
  </Head>
</template>

セルフホスティング(推奨):

# fontsourceパッケージの使用
pnpm add @fontsource/inter

# CSS内でインポート
# app/assets/css/fonts.css
@import '@fontsource/inter/400.css';
@import '@fontsource/inter/600.css';
@import '@fontsource/inter/700.css';

Variable Fontsの活用

可変フォントで複数ウェイトを1ファイルに:

@font-face {
  font-family: 'Inter Variable';
  src: url('/fonts/Inter-Variable.woff2') format('woff2');
  font-weight: 100 900; /* 全ウェイトをサポート */
  font-display: swap;
}

/* 使用例 */
h1 {
  font-family: 'Inter Variable', sans-serif;
  font-weight: 700; /* 任意のウェイト */
}

p {
  font-family: 'Inter Variable', sans-serif;
  font-weight: 400;
}

効果測定:

改善前(個別フォントファイル):
- フォントファイル: 450KB × 3 = 1.35MB
- レンダリングブロック時間: 800ms

改善後(Variable Font + preload):
- フォントファイル: 680KB(50%削減)
- レンダリングブロック時間: 200ms(75%削減)

6. SSR/SSGの最適化テクニック

SSGの活用(Static Site Generation)

静的ページの事前生成:

// nuxt.config.ts
export default defineNuxtConfig({
  // SSGモードで生成
  ssr: true,

  nitro: {
    prerender: {
      crawlLinks: true,
      routes: [
        '/',
        '/blog',
        '/about',
        // 動的ルートも事前生成
        '/blog/docus-adoption-overview',
        '/blog/docus-practical-guide',
      ],
    },
  },
})

Hybrid Rendering(ルートごとの戦略):

// pages/blog/[slug].vue
export default defineNuxtConfig({
  routeRules: {
    // 静的生成(ビルド時)
    '/blog/**': { prerender: true },

    // ISR(Incremental Static Regeneration)
    '/docs/**': { swr: 3600 }, // 1時間ごとに再生成

    // CSR(Client-Side Rendering)
    '/dashboard/**': { ssr: false },

    // キャッシュ戦略
    '/api/**': { cache: { maxAge: 60 } },
  },
})

データフェッチの最適化

useFetch/useAsyncDataの活用:

<script setup lang="ts">
// ❌ 誤り: クライアントサイドのみでフェッチ
const { data } = await $fetch('/api/articles')

// ✅ 正しい: SSR/SSG時にもフェッチ
const { data: articles } = await useFetch('/api/articles', {
  // キャッシュキーを指定
  key: 'articles-list',

  // データ変換
  transform: (data) => data.slice(0, 10),

  // リフェッチ戦略
  getCachedData: (key) => useNuxtData(key).data,
})
</script>

Payload Extraction(ペイロード抽出):

// nuxt.config.ts
export default defineNuxtConfig({
  experimental: {
    // ペイロードを別ファイルに抽出
    payloadExtraction: true,
  },
})

効果測定:

改善前(CSRのみ):
- TTFB: 400ms
- FCP: 1.8s
- LCP: 3.2s

改善後(SSG + Payload Extraction):
- TTFB: 80ms(80%削減)
- FCP: 0.6s(67%削減)
- LCP: 1.4s(56%削減)

7. キャッシュ戦略とCDN設定

HTTP キャッシュヘッダー

Vercel での設定例(vercel.json):

{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "X-Content-Type-Options",
          "value": "nosniff"
        },
        {
          "key": "X-Frame-Options",
          "value": "DENY"
        },
        {
          "key": "X-XSS-Protection",
          "value": "1; mode=block"
        }
      ]
    },
    {
      "source": "/img/(.*)",
      "headers": [
        {
          "key": "Cache-Control",
          "value": "public, max-age=31536000, immutable"
        }
      ]
    },
    {
      "source": "/_nuxt/(.*)",
      "headers": [
        {
          "key": "Cache-Control",
          "value": "public, max-age=31536000, immutable"
        }
      ]
    },
    {
      "source": "/.*\\.(js|css|woff2)",
      "headers": [
        {
          "key": "Cache-Control",
          "value": "public, max-age=31536000, immutable"
        }
      ]
    }
  ]
}

Netlify での設定例(netlify.toml):

[[headers]]
  for = "/img/*"
  [headers.values]
    Cache-Control = "public, max-age=31536000, immutable"

[[headers]]
  for = "/_nuxt/*"
  [headers.values]
    Cache-Control = "public, max-age=31536000, immutable"

[[headers]]
  for = "/*.js"
  [headers.values]
    Cache-Control = "public, max-age=31536000, immutable"

[[headers]]
  for = "/*.css"
  [headers.values]
    Cache-Control = "public, max-age=31536000, immutable"

Service Worker + Workbox

オフライン対応とキャッシュ戦略:

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@vite-pwa/nuxt'],

  pwa: {
    registerType: 'autoUpdate',
    manifest: {
      name: 'Docus Site',
      short_name: 'Docus',
      theme_color: '#4f46e5',
      background_color: '#ffffff',
    },
    workbox: {
      // ナビゲーションリクエストのキャッシュ
      navigateFallback: '/',

      // ランタイムキャッシュ戦略
      runtimeCaching: [
        {
          urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
          handler: 'CacheFirst',
          options: {
            cacheName: 'google-fonts-cache',
            expiration: {
              maxEntries: 10,
              maxAgeSeconds: 60 * 60 * 24 * 365, // 1年
            },
          },
        },
        {
          urlPattern: /^https:\/\/.*\.(?:png|jpg|jpeg|svg|gif|webp)$/i,
          handler: 'CacheFirst',
          options: {
            cacheName: 'image-cache',
            expiration: {
              maxEntries: 50,
              maxAgeSeconds: 60 * 60 * 24 * 30, // 30日
            },
          },
        },
      ],
    },
  },
})

効果測定:

改善前(キャッシュなし):
- リピート訪問時のロード時間: 2.8s
- データ転送量: 3.2MB

改善後(Service Worker + CDN):
- リピート訪問時のロード時間: 0.4s(86%削減)
- データ転送量: 0.2MB(94%削減)

8. 継続的なモニタリングと改善プロセス

CI/CD統合

Lighthouse CIの設定:

# .github/workflows/lighthouse-ci.yml
name: Lighthouse CI

on:
  pull_request:
    branches: [main]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'

      - name: Install dependencies
        run: pnpm install

      - name: Build
        run: pnpm build

      - name: Run Lighthouse CI
        run: |
          npm install -g @lhci/cli
          lhci autorun
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}

lighthouserc.json 設定:

{
  "ci": {
    "collect": {
      "staticDistDir": ".output/public",
      "numberOfRuns": 3
    },
    "assert": {
      "assertions": {
        "categories:performance": ["error", { "minScore": 0.9 }],
        "categories:accessibility": ["error", { "minScore": 0.95 }],
        "categories:best-practices": ["error", { "minScore": 0.9 }],
        "categories:seo": ["error", { "minScore": 0.95 }],
        "first-contentful-paint": ["error", { "maxNumericValue": 2000 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }]
      }
    },
    "upload": {
      "target": "temporary-public-storage"
    }
  }
}

Real User Monitoring(RUM)

Web Vitalsの測定とレポート:

// plugins/web-vitals.client.ts
import { onCLS, onFID, onFCP, onLCP, onTTFB } from 'web-vitals'

export default defineNuxtPlugin(() => {
  // Core Web Vitalsの測定
  onCLS(sendToAnalytics)
  onFID(sendToAnalytics)
  onLCP(sendToAnalytics)

  // 追加メトリクス
  onFCP(sendToAnalytics)
  onTTFB(sendToAnalytics)
})

function sendToAnalytics(metric: any) {
  // Google Analyticsに送信
  if (window.gtag) {
    window.gtag('event', metric.name, {
      value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
      event_category: 'Web Vitals',
      event_label: metric.id,
      non_interaction: true,
    })
  }

  // コンソールログ(開発環境)
  if (import.meta.dev) {
    console.log(`[Web Vitals] ${metric.name}:`, metric.value)
  }
}

パフォーマンス改善のワークフロー

4週間スプリント例:

Week 1: 測定とベースライン設定

  • Lighthouseで全ページを測定
  • 最も遅いページ Top 5 を特定
  • Core Web Vitals の現状値を記録

Week 2: 画像とアセットの最適化

  • 画像をWebP/AVIFに変換
  • レスポンシブ画像の実装
  • 未使用アセットの削除

Week 3: スクリプトとバンドルの最適化

  • サードパーティスクリプトの遅延読み込み
  • Code Splittingの実装
  • Tree Shakingの徹底

Week 4: 測定と検証

  • 再測定してスコアを比較
  • 改善効果を定量化
  • 次のスプリント計画

改善チェックリスト:

## パフォーマンス改善チェックリスト

### 画像最適化
- [ ] 全画像をWebP/AVIF形式に変換
- [ ] レスポンシブ画像(srcset)の実装
- [ ] Above the foldの画像にpreloadを追加
- [ ] Below the foldの画像に遅延ローディングを設定

### スクリプト最適化
- [ ] サードパーティスクリプトを@nuxt/scriptsで管理
- [ ] 開発環境でのスクリプト読み込みを無効化
- [ ] defer/async属性の適切な使用
- [ ] Partytownでの実行(該当する場合)

### バンドル最適化
- [ ] vite-bundle-visualizerで分析
- [ ] 未使用依存関係の削除
- [ ] Tree Shakingの実装
- [ ] Code Splittingの実装

### フォント最適化
- [ ] font-display: swapの設定
- [ ] Variable Fontsの使用(該当する場合)
- [ ] preconnect/preloadの設定
- [ ] セルフホスティングの検討

### SSR/SSG最適化
- [ ] 静的ページの事前生成
- [ ] Hybrid Renderingの実装
- [ ] Payload Extractionの有効化
- [ ] データフェッチの最適化

### キャッシュとCDN
- [ ] HTTP キャッシュヘッダーの設定
- [ ] CDNの設定と確認
- [ ] Service Workerの実装(該当する場合)
- [ ] リソースのバージョニング

### モニタリング
- [ ] Lighthouse CIの設定
- [ ] Web Vitalsの測定実装
- [ ] RUMデータの収集
- [ ] 定期的な測定とレポート

9. 実測値による改善効果

実際のプロジェクトでの改善例

プロジェクト: Docustation(本サイト)

改善前(2025-11-01):

Lighthouse Scores:
- Performance: 68 / 100
- Accessibility: 95 / 100
- Best Practices: 92 / 100
- SEO: 100 / 100

Core Web Vitals:
- LCP: 3.2s
- FID: 45ms
- CLS: 0.15

ページサイズ:
- 初期ロード: 12.8MB
- First Load JS: 410KB

改善後(2025-11-26):

Lighthouse Scores:
- Performance: 96 / 100(+28点)
- Accessibility: 97 / 100(+2点)
- Best Practices: 100 / 100(+8点)
- SEO: 100 / 100

Core Web Vitals:
- LCP: 1.4s(-56%)
- FID: 18ms(-60%)
- CLS: 0.02(-87%)

ページサイズ:
- 初期ロード: 2.3MB(-82%)
- First Load JS: 210KB(-49%)

主な改善施策:

  1. 画像最適化(PNG → WebP/AVIF): 9.5MB → 1.5MB
  2. サードパーティスクリプト管理: @nuxt/scripts統合
  3. バンドルサイズ削減: Code Splitting + Tree Shaking
  4. SSG + Payload Extraction: TTFB 80%削減
  5. HTTP キャッシュ + CDN: リピート訪問86%高速化

jenreページの最適化事例(Phase 11-18)

対象ページ: /jenre/tags/[slug](ジャンル別作品一覧ページ)

改善前(2025-12-01):

パフォーマンス問題:
- index6.js 実行時間: 2599ms
- 173件の biblio 記事をクライアント側でフィルタリング
- ページネーション機能なし(全件表示)

ユーザー体験:
- スクロールが長く、目的の作品を探しにくい
- ページ遷移時のURLが保持されない

Phase 11-12: Server-side Filtering

実装内容:

// Before: クライアント側フィルタリング
const { data: articlesData } = useLazyAsyncData(
  `articles-${slug.value}`,
  () => queryCollection('blog').all()
)
const articles = computed(() => {
  return articlesData.value.filter(article =>
    (article.tags || []).includes(slug.value)
  )
})

// After: サーバー側WHERE句フィルタリング
const { data: initialBooks, pending: isLoading } = useLazyAsyncData(
  `biblio-tag-${slug.value}`,
  async () => {
    const filtered = await queryCollection('biblio')
      .where('tags', 'LIKE', `%${slug.value}%`)
      .where('draft', '!=', true)
      .order('publishedAt', 'DESC')
      .all()
    return filtered
  }
)

効果:

  • ✅ index6.js: 2599ms → 1257ms(51.6%削減)
  • ✅ 不要なデータ転送と処理負荷を削減
  • ✅ draft記事の除外を統一

Phase 15-16: Pagination UI実装

実装内容:

  • クライアント側ページネーション(20件/ページ)
  • URL クエリパラメータ対応(?page=2
  • ローディングインジケーター表示
  • タイトルにページ番号表示(例: SFジャンルの作品一覧(2)
// ページネーション状態管理
const currentPage = ref(1)
const itemsPerPage = 20

const displayedBooks = computed(() => {
  const start = (currentPage.value - 1) * itemsPerPage
  const end = start + itemsPerPage
  return books.value.slice(start, end)
})

const totalPages = computed(() =>
  Math.ceil(books.value.length / itemsPerPage)
)

// URL パラメータ対応
const changePage = (page: number) => {
  currentPage.value = page
  router.push({
    query: { page: page > 1 ? String(page) : undefined }
  })
  window.scrollTo({ top: 0, behavior: 'smooth' })
}

技術的判断:

  • クライアント側を選択: SSGの特性上、全データ取得+クライアント側スライスが実用的
  • メリット: ページ遷移が高速、URL対応、検索・フィルタリングが容易
  • トレードオフ: 初回ロード時のペイロードが大きい(受け入れ可能)

効果:

  • ✅ 20件/ページのクリーンなUI
  • ✅ URL パラメータでページ共有可能
  • ✅ アクセシビリティ対応(aria-label、aria-current)

Phase 18: LazyScrollToTop実装

実装内容:

  • 9ファイルで <ScrollToTop /><LazyScrollToTop /> に変更
  • 非クリティカルコンポーネントの遅延ロード

効果:

  • ✅ ScrollToTop 実行時間: 0ms(完全遅延ロード成功)
  • ✅ 初期バンドルサイズ削減

Phase 17-A: Third-party Script遅延の失敗事例

試行内容:

// nuxt.config.ts
scripts: {
  registry: {
    clarity: { id: 'xxxxxxx', trigger: 5000 },      // 5秒遅延
    googleAnalytics: { id: 'G-XXX', trigger: 5000 }    // 5秒遅延
  }
}

結果:

  • ❌ JavaScript boot-up 時間: 7.3s → 11.2s(悪化)
  • ❌ Unattributable 時間: 4.7s → 7.4s(悪化)
  • ❌ LCP: 改善なし

原因と対応:

  • 5秒遅延により、スクリプトの初期化が重なる
  • idle トリガーの方がブラウザの最適なタイミングで読み込める
  • 完全にリバートし、trigger: 'idle' に戻す

学び:

  • ❌ 固定遅延(5秒)は idle トリガーより悪化する可能性がある
  • ✅ ブラウザの最適なタイミングに任せるべき(idleが最良)

Payload最適化とサードパーティスクリプト改善(Phase 20-21)

Phase 19までで大幅な改善を達成しましたが、さらなる最適化として、Payload削減サードパーティスクリプトのトリガー改善を実施しました。

測定結果(Phase 19後)

Lighthouse測定結果(lighthouse-reports/8/):

  • Desktop Performance: 30% → 66%(+36ポイント)
  • Mobile Performance: 27% → 45%(+18ポイント)
  • ⚠️ TBT(Total Blocking Time): 12,680ms(非常に高い)
  • ⚠️ JavaScript実行時間: 8.0s(主なボトルネック)
  • ⚠️ _payload.json: 3.82 MB(巨大)

課題:

  • TBTが依然として高く、ユーザー体験に影響
  • 巨大なpayloadによるJSONパース時間の増加
  • サードパーティスクリプトのタイミング最適化の余地

Phase 20: サードパーティスクリプトの最適化

実施内容:

  1. トリガーの変更: manualonNuxtReady
// nuxt.config.ts
scripts: {
  registry: {
    clarity: {
      id: 'xxxxxx',
      trigger: 'onNuxtReady'  // Nuxt完全準備後に読み込み
    },
    googleAnalytics: {
      id: 'G-XXXXXX',
      trigger: 'onNuxtReady'  // Nuxt完全準備後に読み込み
    }
  }
}
  1. 手動読み込みコードの削除:
// app/app.vue(削除)
// Phase 20: onNuxtReady トリガーに変更したため、手動読み込みコードは不要
// サードパーティスクリプトはnuxt.config.tsで自動的に最適なタイミングで読み込まれる

効果:

  • ✅ Nuxt完全準備後にスクリプト読み込み
  • ✅ TBT削減に貢献
  • ✅ コードがシンプルに

Phase 21: Payload最適化

1. bodyフィールドの除外

リスト表示で不要なbodyフィールド(記事本文)をmap()で除外し、payloadサイズを削減。

// app/components/blog/BlogIndexPage.vue
const { data: articlesData } = useLazyAsyncData('docs-blog-articles', async () => {
  const articles = await queryCollection('blog').all()
  // Payload最適化: リスト表示に不要なbodyフィールドを除外
  return articles.map(article => ({
    title: article.title,
    path: article.path,
    tags: article.tags,
    publishedAt: article.publishedAt,
    updatedAt: article.updatedAt,
    description: article.description,
    img: article.img,
    draft: article.draft,
    featured: article.featured
    // bodyフィールドを除外してpayloadサイズを削減
  }))
})

適用箇所:

  • BlogIndexPage.vue: ブログ一覧
  • BiblioIndexPage.vue: 書籍一覧
  • HomeLanding.vue: トップページのプレビュー
  • SearchModal.vue: 検索機能のためbodyは維持

重要な教訓 - .only() vs map():

最初は.only()メソッドを試しましたが、データが表示されない問題が発生:

// ❌ 失敗例: .only()メソッド
queryCollection('blog')
  .only(['title', 'path', 'tags', ...])
  .all()
// → データが表示されない問題が発生

解決策: map()による明示的なフィールド選択が確実:

// ✅ 成功例: map()による明示的選択
const articles = await queryCollection('blog').all()
return articles.map(article => ({
  title: article.title,
  path: article.path,
  // 必要なフィールドのみ明示的に選択
}))

2. 圧縮設定の強化

// nuxt.config.ts
nitro: {
  compressPublicAssets: {
    gzip: true,
    brotli: true
  }
}

効果:

  • Brotli圧縮ファイル(.br)の自動生成
  • Gzip圧縮ファイル(.gz)の自動生成
  • サーバー側での圧縮処理が不要に

3. BiblioIndexPage統計情報の最適化

問題: map()でメタデータフィールドを除外したため、統計情報が誤表示(747 → 正しくは185)

解決策:

  • 動的計算を削除し、固定値を使用
  • pathから番号抽出に変更(/biblio/001.gem-snatcher001
// 統計情報(固定値)
const stats = computed(() => {
  return {
    totalBooks: 185, // 山田正紀著作の総数(固定値)
    recentlyUpdated: jenrePreview.value.slice(0, 6)
  }
})

// navigationItems: pathから番号抽出
const numberMatch = pathPart.match(/\/(\d+)\./)
const sortNumber = numberMatch ? Number.parseInt(numberMatch[1], 10) : 999999

学び:

  • ❌ 動的計算のためだけにメタデータフィールドを含めるとpayload増加
  • ✅ 固定値やpathからの抽出で代替可能
  • ✅ メタデータフィールド削減でpayload最適化

結果

Payloadサイズ:

3.82 MB(最適化前)
↓ bodyフィールド除外
3.0 MB(未圧縮、21%削減)
↓ Brotli圧縮
657 KB(最終転送サイズ、83%削減)

期待される効果:

  • ✅ JavaScript実行時間の短縮(JSONパース削減)
  • ✅ TBT大幅改善(目標: 12,680ms → 3,000ms以下)
  • ✅ Performance Score向上(Mobile: 45% → 60%+、Desktop: 66% → 80%+)

実装のポイント:

  1. map()の確実性:
    • .only()メソッドは期待通りに動作しない場合がある
    • map()による明示的なフィールド選択が安全
  2. body除外の効果:
    • 記事本文(body)は最も大きなフィールド
    • リスト表示では不要なので除外効果が大きい
  3. Brotli圧縮の威力:
    • JSONのような繰り返しの多いテキストデータは圧縮率が高い
    • 3.0 MB → 657 KB(約22%に圧縮)
  4. SearchModalの例外:
    • 検索機能ではbodyフィールドが必要
    • 用途に応じてフィールド選択を変える

修正ファイル:

  1. app/app.vue - 手動スクリプト読み込みコード削除
  2. app/components/blog/BlogIndexPage.vue - bodyフィールド除外
  3. app/components/biblio/BiblioIndexPage.vue - bodyフィールド除外、統計情報最適化
  4. app/components/content/home/HomeLanding.vue - bodyフィールド除外
  5. nuxt.config.ts - Scripts trigger変更、圧縮設定追加

コスト:

  • 開発時間: 約4時間
  • リスク: 低(段階的にテスト)
  • 保守性への影響: 中(map()処理の追加)

10. まとめと次のステップ

核心的な学び

  1. 測定駆動: ベースラインを設定し、改善効果を定量化
  2. 優先順位付け: 費用対効果の高い施策から実施(画像最適化が最大効果)
  3. 段階的改善: 一度にすべてを変えず、測定しながら進める
  4. 継続的モニタリング: CI/CD統合で品質を維持
  5. Real User Monitoring: 実際のユーザー体験を追跡

費用対効果が高い施策 Top 5

  1. 画像最適化(効果: 大、工数: 中)
    • WebP/AVIF変換で50-80%削減
    • レスポンシブ画像でモバイル最適化
  2. サードパーティスクリプト管理(効果: 大、工数: 小)
    • @nuxt/scriptsで一元管理
    • 開発環境で無効化
  3. SSG/Hybrid Rendering(効果: 大、工数: 小)
    • 静的ページの事前生成
    • TTFBを80%削減
  4. Code Splitting(効果: 中、工数: 小)
    • 初期バンドル50%削減
    • ルートベースの自動分割
  5. HTTP キャッシュ + CDN(効果: 中、工数: 小)
    • リピート訪問を大幅高速化
    • 設定のみで実装可能

避けた落とし穴

  • ❌ 測定せずに最適化(効果が不明)
  • ❌ 過度な最適化(保守性の低下)
  • ❌ モバイルを無視(ユーザーの大半がモバイル)
  • ❌ 一度きりの改善(継続的モニタリングが必要)

短期的な次のステップ(1-2週間)

  • プロジェクトのベースライン測定
  • 画像最適化の実施(最大効果)
  • サードパーティスクリプトの整理
  • Lighthouse CIの導入

中期的な次のステップ(1-2ヶ月)

  • SSG/Hybrid Renderingの実装
  • Code Splittingの最適化
  • フォントローディングの改善
  • Service Workerの導入

長期的な次のステップ(3ヶ月以降)

  • RUMデータの分析と改善
  • Edge Functionsの活用
  • Progressive Web App(PWA)化
  • パフォーマンスバジェットの設定

参考リソース

公式ドキュメント:

測定ツール:

関連記事(本サイト内):


この記事が、Docusサイトのパフォーマンス最適化の参考になれば幸いです。質問やフィードバックがあれば、ぜひコメントやSNSでお寄せください。