T-CREATOR

Nuxt パフォーマンス運用:payload/コード分割/プリフェッチの継続的チューニング

Nuxt パフォーマンス運用:payload/コード分割/プリフェッチの継続的チューニング

Nuxt アプリケーションを本番環境で運用していると、初期ローディング時間やページ遷移の体感速度が気になってきませんか。特にアプリケーションの規模が大きくなるにつれて、バンドルサイズの肥大化やデータ取得の遅延が顕著になります。

本記事では、Nuxt におけるパフォーマンス運用の核心である「payload 最適化」「コード分割」「プリフェッチの継続的チューニング」について、実践的なアプローチを解説していきます。これらの技術を組み合わせることで、ユーザー体験を大幅に向上させられるでしょう。

背景

Nuxt アプリケーションのパフォーマンス課題が生まれる理由

Nuxt は、Vue.js をベースにしたフルスタックフレームワークとして、SSR(サーバーサイドレンダリング)や SSG(静的サイト生成)などの機能を提供しています。これにより初期表示の高速化や SEO 対策が可能になりますが、一方でアプリケーション規模の拡大とともに新たな課題も生まれます。

開発初期は軽快に動作していたアプリケーションが、コンポーネントやページの追加に伴い、バンドルサイズが増大していきます。さらに、API からのデータ取得やサードパーティライブラリの導入により、ペイロード(通信データ量)も肥大化していくのです。

パフォーマンス運用の 3 つの柱

Nuxt のパフォーマンスを継続的に最適化するには、以下の 3 つの要素を適切に管理する必要があります。

まず、payload 最適化では、サーバーからクライアントへ送信されるデータ量を最小限に抑えます。次に、コード分割により、必要なコードだけを必要なタイミングで読み込む仕組みを構築します。最後に、プリフェッチを活用して、ユーザーが次に訪れる可能性の高いページのリソースを事前に取得しておくのです。

下記の図は、これら 3 つの最適化手法が Nuxt アプリケーションのどの部分に影響を与えるかを示しています。

mermaidflowchart TB
    user["ユーザー"] -->|初回アクセス| ssr["SSR/SSG<br/>初期HTML生成"]
    ssr -->|payload削減| hydration["Hydration<br/>クライアント側活性化"]
    hydration -->|コード分割| routing["ルーティング<br/>ページ遷移"]
    routing -->|プリフェッチ| prefetch["次ページ予測<br/>リソース先読み"]
    prefetch -->|高速表示| user

    style ssr fill:#e1f5ff
    style hydration fill:#fff4e1
    style routing fill:#f0ffe1
    style prefetch fill:#ffe1f5

この図から分かるように、各最適化手法はアプリケーションのライフサイクル全体に渡って効果を発揮します。

課題

大規模化するアプリケーションで直面する具体的な問題

Nuxt アプリケーションが成長する過程で、開発チームは以下のような課題に直面することが多いでしょう。

バンドルサイズの肥大化による初期ロード遅延が最も顕著な問題です。全てのコンポーネントやライブラリを一つのバンドルに含めると、ファイルサイズが数 MB 以上になることも珍しくありません。これにより、特にモバイルネットワーク環境でのユーザーは長い待ち時間を強いられます。

次に、不要なデータ転送によるネットワーク負荷があります。API レスポンスに含まれる全フィールドをそのままクライアントに送信していると、ユーザーが実際に表示する情報以上のデータを転送することになるでしょう。

パフォーマンス測定の難しさ

パフォーマンス問題のもう一つの課題は、測定と監視の複雑さです。開発環境では高速に動作していても、実際のユーザー環境では様々な要因でパフォーマンスが低下します。

下記の図は、パフォーマンス課題が発生する主な箇所とその影響範囲を示しています。

mermaidflowchart LR
    A["大きなバンドル"] -->|ダウンロード時間増加| B["初期表示遅延"]
    C["過剰なpayload"] -->|データ転送量増加| D["通信コスト上昇"]
    E["未最適化なプリフェッチ"] -->|不要なリクエスト| F["サーバー負荷増"]

    B --> G["ユーザー離脱率上昇"]
    D --> G
    F --> G

    style A fill:#ffcccc
    style C fill:#ffcccc
    style E fill:#ffcccc
    style G fill:#ff9999

この図が示すように、各課題は最終的にユーザー離脱率の上昇という共通の問題に繋がります。

継続的なチューニングの必要性

パフォーマンス最適化は一度実施すれば完了するものではありません。新機能の追加や依存パッケージの更新により、パフォーマンスは常に変動していきます。

したがって、継続的な測定と改善のサイクルを確立することが重要です。しかし、多くのチームでは日々の開発に追われ、パフォーマンス監視が後回しになりがちでしょう。

解決策

payload 最適化の実装戦略

Nuxt 3 では、useStateuseFetchを使ったデータ管理が推奨されています。これらの Composables を適切に使うことで、payload の最適化が可能になります。

必要なデータのみを取得するという原則が基本です。API レスポンスから不要なフィールドを除外し、クライアントで実際に使用するデータだけを抽出しましょう。

以下は、API レスポンスから必要なフィールドのみを選択する例です。

typescript// composables/useOptimizedFetch.ts

/**
 * APIレスポンスから必要なフィールドのみを抽出するカスタムフック
 * 不要なデータをpayloadから除外してパフォーマンスを向上
 */
export const useOptimizedFetch = <T>(
  url: string,
  options?: {
    pick?: string[]; // 取得したいフィールド名の配列
    transform?: (data: any) => T; // カスタム変換関数
  }
) => {
  return useFetch(url, {
    // pickオプションでフィールドを選択
    transform: (data) => {
      if (options?.pick) {
        // 指定されたフィールドのみを抽出
        return options.pick.reduce((acc, key) => {
          acc[key] = data[key];
          return acc;
        }, {} as any);
      }
      // カスタム変換がある場合は適用
      return options?.transform
        ? options.transform(data)
        : data;
    },
  });
};

次に、実際にこのカスタムフックを使用してユーザー情報を取得する例を見てみましょう。

typescript// pages/users/[id].vue

<script setup lang="ts">
/**
 * ユーザー詳細ページ
 * 必要最小限のフィールドのみを取得してpayloadを削減
 */
const route = useRoute()

// 表示に必要なフィールドのみを指定
const { data: user } = await useOptimizedFetch(
  `/api/users/${route.params.id}`,
  {
    pick: ['id', 'name', 'email', 'avatarUrl']
    // 例: APIが返す全フィールド(address, phoneなど)は除外
  }
)
</script>

<template>
  <div>
    <h1>{{ user?.name }}</h1>
    <p>{{ user?.email }}</p>
  </div>
</template>

このアプローチにより、API が多数のフィールドを返す場合でも、クライアントには最小限のデータのみが送信されます。

コード分割の効果的な実装

Nuxt は自動的にページ単位でコード分割を行いますが、さらに細かい制御が必要な場合もあります。動的インポートを活用することで、コンポーネントレベルでの分割が可能です。

重いコンポーネント(グラフライブラリ、リッチエディタなど)は、必要になったタイミングで読み込むようにしましょう。

typescript// components/LazyChart.vue

<script setup lang="ts">
/**
 * 遅延読み込みされるチャートコンポーネント
 * 大きなグラフライブラリを初期バンドルから除外
 */
import { defineAsyncComponent } from 'vue'

// Chart.jsは使用時にのみ読み込まれる
const ChartComponent = defineAsyncComponent(() =>
  import('chart.js/auto').then((module) => {
    // ライブラリの初期化処理
    return module.default
  })
)
</script>

さらに、条件付きでコンポーネントを表示する場合は、<ClientOnly>ディレクティブと組み合わせることで、SSR 時のバンドルサイズも削減できます。

vue<!-- pages/dashboard.vue -->

<template>
  <div>
    <h1>ダッシュボード</h1>

    <!-- クライアントサイドでのみレンダリング -->
    <!-- サーバーサイドのバンドルには含まれない -->
    <ClientOnly>
      <LazyChart :data="chartData" />
      <template #fallback>
        <div>チャートを読み込み中...</div>
      </template>
    </ClientOnly>
  </div>
</template>

プリフェッチの戦略的設定

Nuxt のルーターは、デフォルトでビューポート内のリンクを自動的にプリフェッチします。しかし、全てのリンクをプリフェッチするとサーバー負荷が増大する可能性があります。

選択的プリフェッチにより、ユーザーが訪れる可能性の高いページのみを事前読み込みするのが賢明でしょう。

typescript// nuxt.config.ts

/**
 * プリフェッチ設定の最適化
 * 全自動プリフェッチを無効化し、重要なページのみ明示的に指定
 */
export default defineNuxtConfig({
  experimental: {
    // デフォルトのプリフェッチを無効化
    defaults: {
      nuxtLink: {
        prefetch: false, // 自動プリフェッチをオフ
        prefetchOn: {
          visibility: false, // ビューポート検知も無効
        },
      },
    },
  },
});

重要なページには明示的にプリフェッチを指定します。

vue<!-- components/MainNavigation.vue -->

<template>
  <nav>
    <!-- トップページは常にプリフェッチ -->
    <NuxtLink to="/" :prefetch="true"> ホーム </NuxtLink>

    <!-- よく訪れるページのみプリフェッチ -->
    <NuxtLink to="/dashboard" :prefetch="true">
      ダッシュボード
    </NuxtLink>

    <!-- あまり訪れないページはプリフェッチしない -->
    <NuxtLink to="/settings" :prefetch="false">
      設定
    </NuxtLink>
  </nav>
</template>

下記の図は、プリフェッチの制御フローを示しています。

mermaidflowchart TD
    A["ユーザーがページ表示"] --> B{"リンクが<br/>ビューポート内?"}
    B -->|Yes| C{"prefetch設定<br/>確認"}
    B -->|No| Z["何もしない"]
    C -->|true| D["リソースを<br/>先読み"]
    C -->|false| E["ホバー時のみ<br/>検討"]
    C -->|未指定| F["グローバル設定<br/>に従う"]
    D --> G["次ページ高速表示"]
    E --> H["クリック時に取得"]
    F --> I{"defaults.prefetch"}
    I -->|true| D
    I -->|false| H

    style D fill:#e1ffe1
    style G fill:#c1ffc1

この図から、プリフェッチの判定プロセスが階層的に行われることが理解できます。

具体例

実践的なパフォーマンス監視システムの構築

継続的なチューニングを実現するには、まず現状を把握する必要があります。Web Vitals を測定し、閾値を超えた際に警告を出すシステムを構築しましょう。

以下は、Nuxt 3 で Web Vitals を測定するプラグインの実装例です。

typescript// plugins/webVitals.client.ts

/**
 * Web Vitals測定プラグイン
 * LCP、FID、CLSなどの主要指標を自動収集
 */
import {
  onCLS,
  onFID,
  onLCP,
  onFCP,
  onTTFB,
} from 'web-vitals';

export default defineNuxtPlugin(() => {
  // 測定結果を送信する関数
  const sendToAnalytics = (metric: any) => {
    const body = JSON.stringify({
      name: metric.name,
      value: metric.value,
      rating: metric.rating,
      delta: metric.delta,
      id: metric.id,
    });

    // 分析エンドポイントに送信
    if (navigator.sendBeacon) {
      navigator.sendBeacon('/api/analytics/vitals', body);
    }
  };

  // 各種メトリクスの測定を開始
  onCLS(sendToAnalytics); // Cumulative Layout Shift
  onFID(sendToAnalytics); // First Input Delay
  onLCP(sendToAnalytics); // Largest Contentful Paint
  onFCP(sendToAnalytics); // First Contentful Paint
  onTTFB(sendToAnalytics); // Time to First Byte
});

次に、バンドルサイズを可視化する設定を追加します。

typescript// nuxt.config.ts

/**
 * バンドル分析を有効化する設定
 * ビルド時に各チャンクのサイズを可視化
 */
export default defineNuxtConfig({
  build: {
    analyze: process.env.ANALYZE === 'true',
  },

  vite: {
    build: {
      // チャンクサイズの警告閾値を設定(KB単位)
      chunkSizeWarningLimit: 500,

      rollupOptions: {
        output: {
          // 手動でチャンク分割を制御
          manualChunks: (id) => {
            // node_modulesを個別のチャンクに分割
            if (id.includes('node_modules')) {
              // 大きなライブラリは別チャンクに
              if (id.includes('chart.js')) {
                return 'vendor-chart';
              }
              if (id.includes('lodash')) {
                return 'vendor-lodash';
              }
              return 'vendor';
            }
          },
        },
      },
    },
  },
});

バンドル分析を実行するためのスクリプトを package.json に追加しましょう。

json{
  "scripts": {
    "build": "nuxt build",
    "analyze": "ANALYZE=true nuxt build"
  }
}

payload 最適化の実践例:ページネーション

大量のデータを扱う場合、全データを一度に取得するのではなく、ページネーションを実装して payload を削減します。

typescript// composables/usePaginatedFetch.ts

/**
 * ページネーション付きデータ取得
 * 必要なページのデータのみを取得してpayloadを最小化
 */
export const usePaginatedFetch = <T>(
  baseUrl: string,
  itemsPerPage = 20
) => {
  const currentPage = ref(1);
  const totalPages = ref(0);

  // 現在のページデータのみを取得
  const { data, pending, error, refresh } = useFetch<{
    items: T[];
    total: number;
  }>(
    () => {
      const params = new URLSearchParams({
        page: currentPage.value.toString(),
        limit: itemsPerPage.toString(),
      });
      return `${baseUrl}?${params}`;
    },
    {
      // レスポンスから必要な情報を抽出
      transform: (response) => {
        totalPages.value = Math.ceil(
          response.total / itemsPerPage
        );
        return response;
      },
      // キャッシュキーを設定して重複リクエストを防止
      key: () => `${baseUrl}-page-${currentPage.value}`,
    }
  );

  return {
    items: computed(() => data.value?.items ?? []),
    currentPage,
    totalPages,
    pending,
    error,
    refresh,
  };
};

この Composable を実際のページで使用します。

vue<!-- pages/products/index.vue -->

<script setup lang="ts">
/**
 * 商品一覧ページ
 * ページネーションにより一度に読み込むデータ量を制限
 */
interface Product {
  id: string;
  name: string;
  price: number;
}

const {
  items: products,
  currentPage,
  totalPages,
  pending,
} = usePaginatedFetch<Product>('/api/products', 20);

// ページ変更関数
const changePage = (page: number) => {
  currentPage.value = page;
};
</script>

<template>
  <div>
    <h1>商品一覧</h1>

    <div v-if="pending">読み込み中...</div>

    <ul v-else>
      <li v-for="product in products" :key="product.id">
        {{ product.name }} - ¥{{
          product.price.toLocaleString()
        }}
      </li>
    </ul>

    <!-- ページネーション -->
    <nav>
      <button
        @click="changePage(currentPage - 1)"
        :disabled="currentPage === 1"
      >
        前へ
      </button>
      <span>{{ currentPage }} / {{ totalPages }}</span>
      <button
        @click="changePage(currentPage + 1)"
        :disabled="currentPage === totalPages"
      >
        次へ
      </button>
    </nav>
  </div>
</template>

コード分割の実践例:ルートベース分割

管理画面など特定の機能群をまとめて遅延読み込みすることで、初期バンドルサイズを大幅に削減できます。

typescript// layouts/admin.vue

<script setup lang="ts">
/**
 * 管理画面レイアウト
 * 管理機能に必要なコンポーネントを動的に読み込み
 */
import { defineAsyncComponent } from 'vue'

// 管理画面専用のコンポーネントは遅延読み込み
const AdminSidebar = defineAsyncComponent(() =>
  import('~/components/admin/AdminSidebar.vue')
)

const AdminHeader = defineAsyncComponent(() =>
  import('~/components/admin/AdminHeader.vue')
)

const AdminFooter = defineAsyncComponent(() =>
  import('~/components/admin/AdminFooter.vue')
)
</script>

<template>
  <div class="admin-layout">
    <Suspense>
      <template #default>
        <AdminHeader />
        <div class="admin-content">
          <AdminSidebar />
          <main>
            <slot />
          </main>
        </div>
        <AdminFooter />
      </template>

      <template #fallback>
        <div>管理画面を読み込み中...</div>
      </template>
    </Suspense>
  </div>
</template>

プリフェッチの実践例:ユーザー行動予測

ユーザーの行動パターンを分析し、次に訪れる可能性の高いページを予測してプリフェッチします。

typescript// composables/useSmartPrefetch.ts

/**
 * ユーザー行動に基づくスマートプリフェッチ
 * マウスホバー時に動的にリソースを先読み
 */
export const useSmartPrefetch = () => {
  const prefetchedUrls = new Set<string>();

  /**
   * URLのリソースをプリフェッチ
   */
  const prefetch = async (url: string) => {
    // 既にプリフェッチ済みならスキップ
    if (prefetchedUrls.has(url)) return;

    try {
      // ページコンポーネントとデータを先読み
      await Promise.all([
        // ルートコンポーネントのプリフェッチ
        navigateTo(url, {
          replace: false,
          external: false,
        }),
        // APIデータのプリフェッチ(該当する場合)
        $fetch(url.replace(/^\//, '/api/')),
      ]);

      prefetchedUrls.add(url);
    } catch (e) {
      // エラーは無視(プリフェッチ失敗してもUXに影響なし)
      console.debug('Prefetch failed:', url);
    }
  };

  /**
   * マウスホバー時のプリフェッチハンドラー
   */
  const onLinkHover = (url: string) => {
    // 100ms待ってから実行(意図しないホバーを除外)
    const timeoutId = setTimeout(() => {
      prefetch(url);
    }, 100);

    return () => clearTimeout(timeoutId);
  };

  return {
    prefetch,
    onLinkHover,
    isPrefetched: (url: string) => prefetchedUrls.has(url),
  };
};

この Composable を使用したリンクコンポーネントの実装です。

vue<!-- components/SmartLink.vue -->

<script setup lang="ts">
/**
 * スマートプリフェッチ機能付きリンク
 * ホバー時に自動的にリソースを先読み
 */
interface Props {
  to: string;
  prefetch?: boolean; // プリフェッチを有効にするか
}

const props = withDefaults(defineProps<Props>(), {
  prefetch: true,
});

const { onLinkHover } = useSmartPrefetch();
let cleanup: (() => void) | undefined;

const handleMouseEnter = () => {
  if (props.prefetch) {
    cleanup = onLinkHover(props.to);
  }
};

const handleMouseLeave = () => {
  cleanup?.();
};
</script>

<template>
  <NuxtLink
    :to="to"
    @mouseenter="handleMouseEnter"
    @mouseleave="handleMouseLeave"
  >
    <slot />
  </NuxtLink>
</template>

下記の図は、スマートプリフェッチの動作フローを示しています。

mermaidsequenceDiagram
    participant User as ユーザー
    participant LinkNode as SmartLink
    participant Prefetch as プリフェッチ機能
    participant Server as サーバー

    User->>LinkNode: マウスホバー
    LinkNode->>Prefetch: onLinkHover()
    Note over Prefetch: 100ms待機
    Prefetch->>Server: ページリソース要求
    Prefetch->>Server: APIデータ要求
    Server-->>Prefetch: リソース返却
    Note over Prefetch: キャッシュに保存
    User->>LinkNode: クリック
    LinkNode->>Prefetch: ページ遷移
    Prefetch-->>User: キャッシュから即座に表示

このシーケンス図から、ホバーからクリックまでの間にリソースが取得されることで、スムーズなページ遷移が実現されることが分かります。

継続的監視の実装例

パフォーマンスの劣化を早期に検知するため、CI/CD パイプラインにバンドルサイズのチェックを組み込みましょう。

typescript// scripts/checkBundleSize.ts

/**
 * バンドルサイズチェックスクリプト
 * 閾値を超えた場合はビルドを失敗させる
 */
import { readFileSync, readdirSync, statSync } from 'fs';
import { join } from 'path';

interface SizeLimit {
  path: string;
  maxSize: number; // KB単位
}

// 各種ファイルのサイズ上限を定義
const limits: SizeLimit[] = [
  { path: '.output/public/_nuxt/entry.*.js', maxSize: 200 },
  {
    path: '.output/public/_nuxt/vendor.*.js',
    maxSize: 300,
  },
  { path: '.output/public/_nuxt/*.css', maxSize: 100 },
];

const checkSize = (
  filePath: string,
  maxSize: number
): boolean => {
  try {
    const stats = statSync(filePath);
    const sizeInKB = stats.size / 1024;

    if (sizeInKB > maxSize) {
      console.error(
        `❌ ${filePath}: ${sizeInKB.toFixed(
          2
        )}KB (上限: ${maxSize}KB)`
      );
      return false;
    }

    console.log(`✅ ${filePath}: ${sizeInKB.toFixed(2)}KB`);
    return true;
  } catch (e) {
    console.warn(`⚠️  ${filePath} が見つかりません`);
    return true;
  }
};

// 実際のチェック処理
let allPassed = true;

for (const limit of limits) {
  // グロブパターンに一致するファイルを検索
  // 簡易実装のため、実際はglobライブラリの使用を推奨
  const passed = checkSize(limit.path, limit.maxSize);
  allPassed = allPassed && passed;
}

if (!allPassed) {
  console.error('\n❌ バンドルサイズが上限を超えています');
  process.exit(1);
}

console.log(
  '\n✅ 全てのバンドルサイズチェックに合格しました'
);

このスクリプトを GitHub Actions で実行する設定例です。

yaml# .github/workflows/performance.yml

name: Performance Check

on:
  pull_request:
    branches: [main]

jobs:
  bundle-size:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'yarn'

      - name: Install dependencies
        run: yarn install --frozen-lockfile

      - name: Build
        run: yarn build

      - name: Check bundle size
        run: yarn tsx scripts/checkBundleSize.ts

      # Web Vitalsのチェック(Lighthouse CI)
      - name: Run Lighthouse CI
        uses: treosh/lighthouse-ci-action@v9
        with:
          urls: |
            http://localhost:3000
            http://localhost:3000/products
          budgetPath: ./lighthouse-budget.json
          uploadArtifacts: true

まとめ

Nuxt アプリケーションのパフォーマンス運用において、payload 最適化、コード分割、プリフェッチの 3 つの要素は密接に関連しています。これらを適切に組み合わせることで、初期ロード時間の短縮とページ遷移の高速化を実現できるでしょう。

payload 最適化では、API レスポンスから必要なフィールドのみを抽出し、クライアントに送信するデータ量を最小限に抑えました。useFetchの transform オプションやカスタム Composable を活用することで、シンプルに実装できます。

コード分割については、動的インポートと<ClientOnly>コンポーネントを組み合わせることで、初期バンドルサイズを大幅に削減できることを確認しました。特に重いライブラリやコンポーネントは、必要になるまで読み込みを遅延させるのが効果的です。

プリフェッチの戦略では、全自動プリフェッチを無効化し、重要なページのみを選択的に先読みする方法を紹介しました。ユーザーの行動予測に基づくスマートプリフェッチにより、体感速度をさらに向上させられます。

継続的なチューニングのためには、Web Vitals の測定とバンドルサイズの監視を自動化することが重要です。CI/CD パイプラインに組み込むことで、パフォーマンス劣化を早期に検知できるでしょう。

これらの手法を段階的に導入していくことで、ユーザー体験の向上とサーバーコストの削減を両立できます。パフォーマンスは一度最適化すれば終わりではなく、継続的な改善が求められる領域です。定期的な測定と改善のサイクルを確立し、快適なアプリケーションを提供し続けましょう。

関連リンク