core-web-vitals について
ページの読み込み、操作への応答、視覚的な安定性を改善する開発者向けスキルです。Core Web Vitalsの診断方法、チェックリスト、最適化の指針を提供します。
主な機能
- 01LCPの最適化ガイダンス
- 02INPの応答性改善ガイダンス
- 03CLSの安定性改善ガイダンス
- 04PerformanceObserverによるデバッグ
- 05ラボ・実ユーザー測定の参照情報
活用シーン
- 01最大コンテンツの表示遅延の診断
- 02クリックやキー入力後の遅延削減
- 03予期しないレイアウトシフトの防止
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ tech-leads-club/ agent-skills --skill core-web-vitalsインストール後、Claude Code で/core-web-vitalsを入力して読み込まれたか確認します。
同梱ファイルreferences/LCP.md
詳細
Core Web Vitals の最適化
Google 検索のランキングとユーザーエクスペリエンスに影響する、3 つの Core Web Vitals 指標に的を絞った最適化です。
3 つの指標
| 指標 | 測定内容 | 良好 | 要改善 | 不良 |
|---|---|---|---|---|
| LCP | 読み込み | ≤ 2.5s | 2.5s – 4s | > 4s |
| INP | インタラクティブ性 | ≤ 200ms | 200ms – 500ms | > 500ms |
| CLS | 視覚的安定性 | ≤ 0.1 | 0.1 – 0.25 | > 0.25 |
Google は 75 パーセンタイル で測定します。つまり、ページ訪問の 75% が「良好」の基準を満たす必要があります。
LCP:Largest Contentful Paint
LCP は、画面に表示される最大のコンテンツ要素が描画されるタイミングを測定します。通常、対象となるのは次の要素です。
- ヒーロー画像または動画
- 大きなテキストブロック
- 背景画像
<svg>要素
LCP のよくある問題
1. サーバー応答が遅い(TTFB > 800ms)
Fix: CDN, caching, optimized backend, edge rendering
2. レンダリングを妨げるリソース
<link rel="stylesheet" href="/all-styles.css" />
<style>
/* Critical above-fold CSS */
</style>
<link rel="preload" href="/styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'" />
3. リソースの読み込みが遅い
<img src="/hero.jpg" alt="Hero" />
<link rel="preload" href="/hero.webp" as="image" fetchpriority="high" />
<img src="/hero.webp" alt="Hero" fetchpriority="high" />
4. クライアント側のレンダリングによる遅延
// ❌ Content loads after JavaScript
useEffect(() => {
fetch('/api/hero-text')
.then((r) => r.json())
.then(setHeroText)
}, [])
// ✅ Server-side or static rendering
// Use SSR, SSG, or streaming to send HTML with content
export async function getServerSideProps() {
const heroText = await fetchHeroText()
return { props: { heroText } }
}
LCP 最適化チェックリスト
- [ ] TTFB < 800ms (use CDN, edge caching)
- [ ] LCP image preloaded with fetchpriority="high"
- [ ] LCP image optimized (WebP/AVIF, correct size)
- [ ] Critical CSS inlined (< 14KB)
- [ ] No render-blocking JavaScript in <head>
- [ ] Fonts don't block text rendering (font-display: swap)
- [ ] LCP element in initial HTML (not JS-rendered)
LCP 要素の特定
// Find your LCP element
new PerformanceObserver((list) => {
const entries = list.getEntries()
const lastEntry = entries[entries.length - 1]
console.log('LCP element:', lastEntry.element)
console.log('LCP time:', lastEntry.startTime)
}).observe({ type: 'largest-contentful-paint', buffered: true })
INP:Interaction to Next Paint
INP は、ページ訪問中のすべての操作(クリック、タップ、キー入力)に対する応答性を測定します。最も応答が遅い操作を報告します(トラフィックの多いページでは 98 パーセンタイル)。
INP の内訳
INP の合計 = 入力遅延 + 処理時間 + 表示遅延
| フェーズ | 目標 | 最適化 |
|---|---|---|
| 入力遅延 | < 50ms | メインスレッドのブロックを削減 |
| 処理 | < 100ms | イベントハンドラーを最適化 |
| 表示 | < 50ms | レンダリング処理を最小限に抑制 |
INP のよくある問題
1. 長時間のタスクがメインスレッドをブロックする
// ❌ Long synchronous task
function processLargeArray(items) {
items.forEach((item) => expensiveOperation(item))
}
// ✅ Break into chunks with yielding
async function processLargeArray(items) {
const CHUNK_SIZE = 100
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
const chunk = items.slice(i, i + CHUNK_SIZE)
chunk.forEach((item) => expensiveOperation(item))
// Yield to main thread
await new Promise((r) => setTimeout(r, 0))
// Or use scheduler.yield() when available
}
}
2. 負荷の大きいイベントハンドラー
// ❌ All work in handler
button.addEventListener('click', () => {
// Heavy computation
const result = calculateComplexThing()
// DOM updates
updateUI(result)
// Analytics
trackEvent('click')
})
// ✅ Prioritize visual feedback
button.addEventListener('click', () => {
// Immediate visual feedback
button.classList.add('loading')
// Defer non-critical work
requestAnimationFrame(() => {
const result = calculateComplexThing()
updateUI(result)
})
// Use requestIdleCallback for analytics
requestIdleCallback(() => trackEvent('click'))
})
3. サードパーティスクリプト
// ❌ Eagerly loaded, blocks interactions
;<script src="https://heavy-widget.com/widget.js"></script>
// ✅ Lazy loaded on interaction or visibility
const loadWidget = () => {
import('https://heavy-widget.com/widget.js').then((widget) => widget.init())
}
button.addEventListener('click', loadWidget, { once: true })
4. 過剰な再レンダリング(React/Vue)
// ❌ Re-renders entire tree
function App() {
const [count, setCount] = useState(0)
return (
<div>
<Counter count={count} />
<ExpensiveComponent /> {/* Re-renders on every count change */}
</div>
)
}
// ✅ Memoized expensive components
const MemoizedExpensive = React.memo(ExpensiveComponent)
function App() {
const [count, setCount] = useState(0)
return (
<div>
<Counter count={count} />
<MemoizedExpensive />
</div>
)
}
INP 最適化チェックリスト
- [ ] No tasks > 50ms on main thread
- [ ] Event handlers complete quickly (< 100ms)
- [ ] Visual feedback provided immediately
- [ ] Heavy work deferred with requestIdleCallback
- [ ] Third-party scripts don't block interactions
- [ ] Debounced input handlers where appropriate
- [ ] Web Workers for CPU-intensive operations
INP のデバッグ
// Identify slow interactions
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 200) {
console.warn('Slow interaction:', {
type: entry.name,
duration: entry.duration,
processingStart: entry.processingStart,
processingEnd: entry.processingEnd,
target: entry.target,
})
}
}
}).observe({ type: 'event', buffered: true, durationThreshold: 16 })
CLS:Cumulative Layout Shift
CLS は予期しないレイアウトシフトを測定します。ユーザー操作がないのに、フレーム間で表示要素の位置が変わると、シフトが発生します。
CLS の計算式: impact fraction × distance fraction
CLS のよくある原因
1. 寸法が指定されていない画像
<img src="photo.jpg" alt="Photo" />
<img src="photo.jpg" alt="Photo" width="800" height="600" />
<img src="photo.jpg" alt="Photo" style="aspect-ratio: 4/3; width: 100%;" />
2. 広告、埋め込みコンテンツ、iframe
<iframe src="https://ad-network.com/ad"></iframe>
<div style="min-height: 250px;">
<iframe src="https://ad-network.com/ad" height="250"></iframe>
</div>
<div style="aspect-ratio: 16/9;">
<iframe src="https://youtube.com/embed/..." style="width: 100%; height: 100%;"></iframe>
</div>
3. 動的に挿入されるコンテンツ
// ❌ Inserts content above viewport
notifications.prepend(newNotification)
// ✅ Insert below viewport or use transform
const insertBelow = viewport.bottom < newNotification.top
if (insertBelow) {
notifications.prepend(newNotification)
} else {
// Animate in without shifting
newNotification.style.transform = 'translateY(-100%)'
notifications.prepend(newNotification)
requestAnimationFrame(() => {
newNotification.style.transform = ''
})
}
4. FOUT を引き起こす Web フォント
/* ❌ Font swap shifts text */
@font-face {
font-family: 'Custom';
src: url('custom.woff2') format('woff2');
}
/* ✅ Optional font (no shift if slow) */
@font-face {
font-family: 'Custom';
src: url('custom.woff2') format('woff2');
font-display: optional;
}
/* ✅ Or match fallback metrics */
@font-face {
font-family: 'Custom';
src: url('custom.woff2') format('woff2');
font-display: swap;
size-adjust: 105%; /* Match fallback size */
ascent-override: 95%;
descent-override: 20%;
}
5. レイアウトを引き起こすアニメーション
/* ❌ Animates layout properties */
.animate {
transition:
height 0.3s,
width 0.3s;
}
/* ✅ Use transform instead */
.animate {
transition: transform 0.3s;
}
.animate.expanded {
transform: scale(1.2);
}
CLS 最適化チェックリスト
- [ ] All images have width/height or aspect-ratio
- [ ] All videos/embeds have reserved space
- [ ] Ads have min-height containers
- [ ] Fonts use font-display: optional or matched metrics
- [ ] Dynamic content inserted below viewport
- [ ] Animations use transform/opacity only
- [ ] No content injected above existing content
CLS のデバッグ
// Track layout shifts
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
console.log('Layout shift:', entry.value)
entry.sources?.forEach((source) => {
console.log(' Shifted element:', source.node)
console.log(' Previous rect:', source.previousRect)
console.log(' Current rect:', source.currentRect)
})
}
}
}).observe({ type: 'layout-shift', buffered: true })
測定ツール
ラボテスト
- Chrome DevTools → Performance パネル、Lighthouse
- WebPageTest → 詳細なウォーターフォール、フィルムストリップ
- Lighthouse CLI →
npx lighthouse <url>
フィールドデータ(実ユーザー)
- Chrome User Experience Report (CrUX) → BigQuery または API
- Search Console → Core Web Vitals レポート
- web-vitals library → 分析ツールに送信
import { onLCP, onINP, onCLS } from 'web-vitals'
function sendToAnalytics({ name, value, rating }) {
gtag('event', name, {
event_category: 'Web Vitals',
value: Math.round(name === 'CLS' ? value * 1000 : value),
event_label: rating,
})
}
onLCP(sendToAnalytics)
onINP(sendToAnalytics)
onCLS(sendToAnalytics)
フレームワーク別の簡単な修正
Next.js
// LCP: Use next/image with priority
import Image from 'next/image'
;<Image src="/hero.jpg" priority fill alt="Hero" />
// INP: Use dynamic imports
const HeavyComponent = dynamic(() => import('./Heavy'), { ssr: false })
// CLS: Image component handles dimensions automatically
React
// LCP: Preload in head
;<link rel="preload" href="/hero.jpg" as="image" fetchpriority="high" />
// INP: Memoize and useTransition
const [isPending, startTransition] = useTransition()
startTransition(() => setExpensiveState(newValue))
// CLS: Always specify dimensions in img tags
Vue/Nuxt
<NuxtImg src="/hero.jpg" preload loading="eager" />
<component :is="() => import('./Heavy.vue')" />
<img :style="{ aspectRatio: '16/9' }" />