はじめに
この記事は、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:
- URL: https://www.webpagetest.org/
- 詳細なウォーターフォール分析
- 複数ロケーションからのテスト
ベースライン設定:
# 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%)
主な改善施策:
- 画像最適化(PNG → WebP/AVIF): 9.5MB → 1.5MB
- サードパーティスクリプト管理: @nuxt/scripts統合
- バンドルサイズ削減: Code Splitting + Tree Shaking
- SSG + Payload Extraction: TTFB 80%削減
- 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: サードパーティスクリプトの最適化
実施内容:
- トリガーの変更:
manual→onNuxtReady
// nuxt.config.ts
scripts: {
registry: {
clarity: {
id: 'xxxxxx',
trigger: 'onNuxtReady' // Nuxt完全準備後に読み込み
},
googleAnalytics: {
id: 'G-XXXXXX',
trigger: 'onNuxtReady' // Nuxt完全準備後に読み込み
}
}
}
- 手動読み込みコードの削除:
// 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-snatcher→001)
// 統計情報(固定値)
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%+)
実装のポイント:
- map()の確実性:
.only()メソッドは期待通りに動作しない場合があるmap()による明示的なフィールド選択が安全
- body除外の効果:
- 記事本文(body)は最も大きなフィールド
- リスト表示では不要なので除外効果が大きい
- Brotli圧縮の威力:
- JSONのような繰り返しの多いテキストデータは圧縮率が高い
- 3.0 MB → 657 KB(約22%に圧縮)
- SearchModalの例外:
- 検索機能では
bodyフィールドが必要 - 用途に応じてフィールド選択を変える
- 検索機能では
修正ファイル:
app/app.vue- 手動スクリプト読み込みコード削除app/components/blog/BlogIndexPage.vue- bodyフィールド除外app/components/biblio/BiblioIndexPage.vue- bodyフィールド除外、統計情報最適化app/components/content/home/HomeLanding.vue- bodyフィールド除外nuxt.config.ts- Scripts trigger変更、圧縮設定追加
コスト:
- 開発時間: 約4時間
- リスク: 低(段階的にテスト)
- 保守性への影響: 中(map()処理の追加)
10. まとめと次のステップ
核心的な学び
- 測定駆動: ベースラインを設定し、改善効果を定量化
- 優先順位付け: 費用対効果の高い施策から実施(画像最適化が最大効果)
- 段階的改善: 一度にすべてを変えず、測定しながら進める
- 継続的モニタリング: CI/CD統合で品質を維持
- Real User Monitoring: 実際のユーザー体験を追跡
費用対効果が高い施策 Top 5
- 画像最適化(効果: 大、工数: 中)
- WebP/AVIF変換で50-80%削減
- レスポンシブ画像でモバイル最適化
- サードパーティスクリプト管理(効果: 大、工数: 小)
- @nuxt/scriptsで一元管理
- 開発環境で無効化
- SSG/Hybrid Rendering(効果: 大、工数: 小)
- 静的ページの事前生成
- TTFBを80%削減
- Code Splitting(効果: 中、工数: 小)
- 初期バンドル50%削減
- ルートベースの自動分割
- 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でお寄せください。
