Documentation Next.js

はじめに

Next.jsでのパフォーマンス向上には、バンドルサイズの最適化が欠かせません。バンドルサイズが大きくなると、ページの初期ロードが遅くなり、Core Web Vitalsのスコアにも悪影響を与えます。

この記事では、バンドルサイズを効果的に削減する具体的な手法を、コード例を交えて解説します。

バンドルサイズの影響

バンドルサイズがパフォーマンスに与える影響を理解しましょう。

バンドルサイズ3G回線での読み込みユーザー体験
100KB以下1秒未満優秀
100-300KB1-3秒良好
300-500KB3-5秒要改善
500KB以上5秒以上問題あり

バンドルサイズの分析

@next/bundle-analyzerのセットアップ

まず、バンドルサイズを可視化するツールをインストールします。

npm install -D @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
});

/** @type {import('next').NextConfig} */
const nextConfig = {
  // その他の設定
};

module.exports = withBundleAnalyzer(nextConfig);
// package.json
{
  "scripts": {
    "analyze": "ANALYZE=true npm run build",
    "analyze:server": "ANALYZE=true BUNDLE_ANALYZE=server npm run build",
    "analyze:browser": "ANALYZE=true BUNDLE_ANALYZE=browser npm run build"
  }
}

分析結果の読み方

npm run analyze

実行後、ブラウザで分析結果が表示されます。注目すべきポイント:

  • 赤色の大きなブロック: 最適化の優先対象
  • node_modules内の大きなパッケージ: 軽量な代替を検討
  • 複数回インポートされているモジュール: 共通化を検討

Dynamic Importによるコード分割

基本的な使い方

// components/HeavyChart.tsx
'use client';

import dynamic from 'next/dynamic';

// 重いチャートライブラリを動的にインポート
const Chart = dynamic(() => import('recharts').then((mod) => mod.LineChart), {
  loading: () => (
    <div className="h-64 w-full animate-pulse bg-gray-200 rounded" />
  ),
  ssr: false, // クライアントサイドのみでレンダリング
});

export function HeavyChart({ data }: { data: any[] }) {
  return (
    <Chart width={600} height={300} data={data}>
      {/* チャートの内容 */}
    </Chart>
  );
}

条件付きインポート

// components/ConditionalFeature.tsx
'use client';

import { useState, useEffect } from 'react';
import dynamic from 'next/dynamic';

// 管理者のみが使用する機能を動的にインポート
const AdminPanel = dynamic(() => import('./AdminPanel'), {
  loading: () => <p>管理パネルを読み込み中...</p>,
});

export function ConditionalFeature({ isAdmin }: { isAdmin: boolean }) {
  // 管理者でない場合はインポートすらしない
  if (!isAdmin) {
    return null;
  }

  return <AdminPanel />;
}

名前付きエクスポートの動的インポート

// 名前付きエクスポートを動的にインポートする場合
const SpecificComponent = dynamic(
  () => import('../components/MultiExport').then((mod) => mod.SpecificComponent),
  { ssr: false }
);

// 複数のコンポーネントを一度にインポート
const { ComponentA, ComponentB } = await import('../components/MultiExport');

Tree Shakingの最適化

効果的なインポート方法

// ❌ 悪い例:ライブラリ全体をインポート
import _ from 'lodash';
const result = _.debounce(fn, 300);

// ✅ 良い例:必要な関数のみインポート
import debounce from 'lodash/debounce';
const result = debounce(fn, 300);

// ✅ さらに良い例:軽量な代替ライブラリを使用
import { debounce } from 'lodash-es';
// または
import debounce from 'just-debounce-it'; // 0.3KB

アイコンライブラリの最適化

// ❌ 悪い例:すべてのアイコンをインポート
import * as Icons from 'lucide-react';

// ✅ 良い例:必要なアイコンのみインポート
import { Home, Settings, User } from 'lucide-react';

// ✅ さらに良い例:動的インポートでさらに最適化
const DynamicIcon = dynamic(
  () => import('lucide-react').then((mod) => mod.Home),
  { ssr: false }
);

日付ライブラリの最適化

// ❌ 悪い例:moment.js(300KB+)
import moment from 'moment';

// ✅ 良い例:date-fns(必要な関数のみインポート)
import { format, parseISO } from 'date-fns';
import { ja } from 'date-fns/locale';

const formattedDate = format(parseISO('2024-01-01'), 'yyyy年MM月dd日', {
  locale: ja,
});

// ✅ さらに良い例:Day.js(2KB)
import dayjs from 'dayjs';
import 'dayjs/locale/ja';

dayjs.locale('ja');
const formattedDate = dayjs('2024-01-01').format('YYYY年MM月DD日');

未使用コードの削除

depcheckで未使用パッケージを検出

# depcheckをインストール
npx depcheck

# 出力例
Unused dependencies
* lodash
* moment

Unused devDependencies
* @types/lodash

next.config.jsでのモジュール除外

// next.config.js
module.exports = {
  webpack: (config, { isServer }) => {
    // 不要なロケールを除外
    config.plugins.push(
      new webpack.IgnorePlugin({
        resourceRegExp: /^\.\/locale$/,
        contextRegExp: /moment$/,
      })
    );

    return config;
  },
};

画像・フォントの最適化

next/imageの活用

// components/OptimizedImage.tsx
import Image from 'next/image';

export function OptimizedImage() {
  return (
    <Image
      src="/hero.jpg"
      alt="Hero image"
      width={1200}
      height={600}
      // 遅延読み込み(デフォルトで有効)
      loading="lazy"
      // 画面に入る前にプリロード
      priority={false}
      // 画質を調整(デフォルト75)
      quality={80}
      // レスポンシブサイズ
      sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
      // プレースホルダー
      placeholder="blur"
      blurDataURL="data:image/jpeg;base64,..."
    />
  );
}

フォントの最適化

// app/layout.tsx
import { Inter, Noto_Sans_JP } from 'next/font/google';

// サブセット化されたフォント
const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
});

const notoSansJP = Noto_Sans_JP({
  subsets: ['latin'],
  weight: ['400', '700'], // 必要なウェイトのみ
  display: 'swap',
  variable: '--font-noto-sans-jp',
});

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja" className={`${inter.variable} ${notoSansJP.variable}`}>
      <body>{children}</body>
    </html>
  );
}

CSSの最適化

Tailwind CSSのPurge設定

// tailwind.config.js
module.exports = {
  content: [
    './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
    './src/components/**/*.{js,ts,jsx,tsx,mdx}',
    './src/app/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  // 使用していないプラグインを無効化
  corePlugins: {
    preflight: true,
    container: false, // 使わない場合は無効化
  },
};

CSS Modulesの活用

// components/Button.module.css
.button {
  padding: 0.5rem 1rem;
  border-radius: 0.25rem;
}

.primary {
  background-color: blue;
  color: white;
}

// components/Button.tsx
import styles from './Button.module.css';

export function Button({ variant = 'primary', children }) {
  return (
    <button className={`${styles.button} ${styles[variant]}`}>
      {children}
    </button>
  );
}

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

next/scriptの使用

// app/layout.tsx
import Script from 'next/script';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body>
        {children}

        {/* Google Analytics - 遅延読み込み */}
        <Script
          src="https://www.googletagmanager.com/gtag/js?id=GA_ID"
          strategy="lazyOnload"
        />
        <Script id="google-analytics" strategy="lazyOnload">
          {`
            window.dataLayer = window.dataLayer || [];
            function gtag(){dataLayer.push(arguments);}
            gtag('js', new Date());
            gtag('config', 'GA_ID');
          `}
        </Script>

        {/* インタラクション後に読み込み */}
        <Script
          src="https://widget.example.com/chat.js"
          strategy="afterInteractive"
        />
      </body>
    </html>
  );
}

軽量な代替ライブラリ

重いライブラリを軽量な代替に置き換えましょう。

元のライブラリサイズ代替ライブラリサイズ
moment.js329KBdate-fns13KB
lodash72KBlodash-es (個別)〜1KB
axios14KBky4KB
uuid9KBnanoid1KB
classnames2KBclsx0.3KB
react-icons全体lucide-react (個別)〜1KB

代替ライブラリの導入例

// ❌ Before: axios (14KB)
import axios from 'axios';
const { data } = await axios.get('/api/data');

// ✅ After: ky (4KB) または fetch
import ky from 'ky';
const data = await ky.get('/api/data').json();

// または組み込みのfetchを使用
const response = await fetch('/api/data');
const data = await response.json();
// ❌ Before: uuid (9KB)
import { v4 as uuidv4 } from 'uuid';
const id = uuidv4();

// ✅ After: nanoid (1KB)
import { nanoid } from 'nanoid';
const id = nanoid();

// または crypto.randomUUID()(ブラウザ組み込み)
const id = crypto.randomUUID();

ビルド設定の最適化

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  // SWC minifier(高速な圧縮)
  swcMinify: true,

  // 本番ビルドでのソースマップを無効化
  productionBrowserSourceMaps: false,

  // 実験的機能
  experimental: {
    // 最適化されたパッケージインポート
    optimizePackageImports: ['lucide-react', '@heroicons/react'],
  },

  // モジュールの外部化
  transpilePackages: ['some-esm-package'],

  webpack: (config, { dev, isServer }) => {
    // 本番ビルドでのみ最適化
    if (!dev && !isServer) {
      config.optimization.splitChunks = {
        chunks: 'all',
        minSize: 20000,
        maxSize: 244000,
        cacheGroups: {
          default: false,
          vendors: false,
          // フレームワークを分離
          framework: {
            name: 'framework',
            test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
            priority: 40,
            enforce: true,
          },
          // 共通モジュールを分離
          commons: {
            name: 'commons',
            minChunks: 2,
            priority: 20,
          },
          // ライブラリを分離
          lib: {
            test: /[\\/]node_modules[\\/]/,
            name(module) {
              const packageName = module.context.match(
                /[\\/]node_modules[\\/](.*?)([\\/]|$)/
              )[1];
              return `npm.${packageName.replace('@', '')}`;
            },
            priority: 30,
            minChunks: 1,
            reuseExistingChunk: true,
          },
        },
      };
    }

    return config;
  },
};

module.exports = nextConfig;

パフォーマンス計測

Lighthouse CIの設定

# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [push]

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

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build

      - name: Run Lighthouse CI
        run: |
          npm install -g @lhci/cli
          lhci autorun
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
// lighthouserc.js
module.exports = {
  ci: {
    collect: {
      startServerCommand: 'npm run start',
      url: ['http://localhost:3000'],
      numberOfRuns: 3,
    },
    assert: {
      assertions: {
        'categories:performance': ['error', { minScore: 0.9 }],
        'first-contentful-paint': ['error', { maxNumericValue: 2000 }],
        'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
      },
    },
    upload: {
      target: 'temporary-public-storage',
    },
  },
};

まとめ

バンドルサイズ最適化のポイントをまとめます。

  • 分析から始める: @next/bundle-analyzerでボトルネックを特定
  • 動的インポート: 必要なときにのみモジュールを読み込む
  • Tree Shaking: 個別インポートで不要なコードを排除
  • 軽量な代替: 重いライブラリを軽量版に置き換え
  • 継続的な計測: Lighthouse CIでパフォーマンスを監視

これらの手法を組み合わせることで、大幅なバンドルサイズの削減が可能です。

参考文献

円