はじめに
Next.jsでのパフォーマンス向上には、バンドルサイズの最適化が欠かせません。バンドルサイズが大きくなると、ページの初期ロードが遅くなり、Core Web Vitalsのスコアにも悪影響を与えます。
この記事では、バンドルサイズを効果的に削減する具体的な手法を、コード例を交えて解説します。
バンドルサイズの影響
バンドルサイズがパフォーマンスに与える影響を理解しましょう。
| バンドルサイズ | 3G回線での読み込み | ユーザー体験 |
|---|---|---|
| 100KB以下 | 1秒未満 | 優秀 |
| 100-300KB | 1-3秒 | 良好 |
| 300-500KB | 3-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.js | 329KB | date-fns | 13KB |
| lodash | 72KB | lodash-es (個別) | 〜1KB |
| axios | 14KB | ky | 4KB |
| uuid | 9KB | nanoid | 1KB |
| classnames | 2KB | clsx | 0.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でパフォーマンスを監視
これらの手法を組み合わせることで、大幅なバンドルサイズの削減が可能です。