10.4 Core Web Vitals פתרון
פתרון - מדדי ביצועים - Core Web Vitals¶
פתרון תרגיל 1¶
הבעיות שזוהו:
- הדף הוא Client Component - התוכן נטען רק אחרי שה-JS רץ
- תמונת ה-hero נטענת אחרי קריאת API (שרשרת: JS > API > image)
- אין priority לתמונת ה-hero
- אין width/height לתמונה
- לא משתמשים ב-next/image
// app/page.tsx - Server Component (ברירת מחדל)
import Image from 'next/image';
import { Suspense } from 'react';
// נתוני hero נטענים בשרת
async function getHeroData() {
const res = await fetch('https://api.example.com/hero', {
next: { revalidate: 3600 },
});
return res.json();
}
export default async function HomePage() {
const heroData = await getHeroData();
return (
<main>
<section className="hero">
<Image
src={heroData.image}
alt={heroData.title}
width={1920}
height={1080}
priority
sizes="100vw"
className="hero-image"
/>
<h1>{heroData.title}</h1>
<p>{heroData.subtitle}</p>
</section>
<Suspense fallback={<div style={{ minHeight: '400px' }}>טוען מוצרים...</div>}>
<TopProducts />
</Suspense>
</main>
);
}
שינויים:
- הדף הפך ל-Server Component - ה-HTML מגיע מהשרת עם כל התוכן
- נתוני ה-hero נטענים בשרת, לא בלקוח
- תמונה עם priority - נטענת מיד ללא lazy loading
- תמונה עם sizes ו-dimensions - הדפדפן יודע את הגודל מראש
- next/image דואג לאופטימיזציה אוטומטית (WebP, srcset)
- שימוש ב-Suspense לתוכן שמתחת (לא חוסם את ה-LCP)
פתרון תרגיל 2¶
'use client';
import { useState, useDeferredValue, useMemo, useCallback } from 'react';
interface Item {
id: number;
name: string;
category: string;
description: string;
}
const allItems: Item[] = generateItems(10000);
const ITEMS_PER_PAGE = 50; // וירטואליזציה - מציגים רק 50 פריטים
export default function SearchPage() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query); // טכניקה 1: deferred value
const [visibleCount, setVisibleCount] = useState(ITEMS_PER_PAGE);
// טכניקה 2: memoization של הסינון
const filteredItems = useMemo(() => {
if (!deferredQuery) return allItems;
const lowerQuery = deferredQuery.toLowerCase();
return allItems.filter(
(item) =>
item.name.toLowerCase().includes(lowerQuery) ||
item.description.toLowerCase().includes(lowerQuery)
);
}, [deferredQuery]);
// טכניקה 3: וירטואליזציה - מציגים רק חלק מהפריטים
const visibleItems = filteredItems.slice(0, visibleCount);
const isStale = query !== deferredQuery;
const handleLoadMore = useCallback(() => {
setVisibleCount((prev) => prev + ITEMS_PER_PAGE);
}, []);
return (
<div>
<input
value={query}
onChange={(e) => {
setQuery(e.target.value);
setVisibleCount(ITEMS_PER_PAGE); // אתחול כשמחפשים
}}
placeholder="חיפוש..."
/>
<p style={{ opacity: isStale ? 0.5 : 1 }}>
{filteredItems.length} תוצאות
</p>
<ul>
{visibleItems.map((item) => (
<li key={item.id}>
<h3>{item.name}</h3>
<p>{item.category}</p>
<p>{item.description}</p>
</li>
))}
</ul>
{visibleCount < filteredItems.length && (
<button onClick={handleLoadMore}>
הצג עוד ({filteredItems.length - visibleCount} נותרו)
</button>
)}
</div>
);
}
שלוש טכניקות שהשתמשנו בהן:
1. useDeferredValue - ה-input מתעדכן מיד, אבל הסינון מתעדכן בעדיפות נמוכה
2. useMemo - הסינון מתבצע רק כשה-query משתנה, לא בכל רינדור
3. וירטואליזציה ידנית - מציגים רק 50 פריטים בכל פעם, כפתור "הצג עוד" לשאר
פתרון תרגיל 3¶
הגורמים ל-CLS שזוהו:
1. פרסומת שנטענת אחרי 2 שניות ונכנסת בין H1 לטקסט
2. תמונת המאמר ללא width/height
3. תגובות שנטענות ודוחפות תוכן
4. אין font loading strategy
// app/articles/[slug]/page.tsx
import Image from 'next/image';
import { Suspense } from 'react';
// Server Component - נתונים נטענים בשרת
async function getArticle(slug: string) {
const res = await fetch(`https://api.example.com/articles/${slug}`);
return res.json();
}
export default async function ArticlePage({ params }: { params: { slug: string } }) {
const article = await getArticle(params.slug);
return (
<main>
<h1>{article.title}</h1>
{/* 1. שמירת מקום לפרסומת - תמיד מוקצה גובה */}
<div className="ad-slot" style={{ minHeight: '90px', backgroundColor: '#f9fafb' }}>
<Suspense fallback={<div style={{ height: '90px' }} />}>
<AdBanner />
</Suspense>
</div>
<p>{article.paragraphs[0]}</p>
<p>{article.paragraphs[1]}</p>
{/* 2. תמונה עם מימדים מוגדרים */}
<Image
src={article.image}
alt={article.imageAlt}
width={800}
height={450}
sizes="(max-width: 768px) 100vw, 800px"
/>
<p>{article.paragraphs[2]}</p>
{/* 3. תגובות עם skeleton בגובה קבוע */}
<section style={{ minHeight: '400px' }}>
<h2>תגובות</h2>
<Suspense fallback={<CommentsSkeleton />}>
<Comments articleId={params.slug} />
</Suspense>
</section>
</main>
);
}
function CommentsSkeleton() {
return (
<div style={{ minHeight: '400px' }}>
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
style={{
height: '100px',
marginBottom: '16px',
backgroundColor: '#f3f4f6',
borderRadius: '8px',
}}
/>
))}
</div>
);
}
// app/layout.tsx - טעינת פונטים ללא FOUT
import { Rubik } from 'next/font/google';
const rubik = Rubik({
subsets: ['hebrew', 'latin'],
display: 'swap',
fallback: ['Arial', 'sans-serif'],
});
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="he" dir="rtl" className={rubik.className}>
<body>{children}</body>
</html>
);
}
תיקונים שבוצעו:
1. פרסומת: הקצנו minHeight: 90px לקונטיינר שלה, כך שגם לפני שהיא נטענת, המקום שמור
2. תמונת מאמר: next/image עם width ו-height מפורשים
3. תגובות: minHeight: 400px ו-skeleton loader באותו גובה
4. פונטים: next/font עם display: 'swap' מונע FOUT
פתרון תרגיל 4¶
// lib/webVitals.ts
import { onCLS, onINP, onLCP, onFCP, onTTFB, type Metric } from 'web-vitals';
export interface VitalMetric {
name: string;
value: number;
rating: 'good' | 'needs-improvement' | 'poor';
id: string;
page: string;
timestamp: number;
}
function createMetricPayload(metric: Metric): VitalMetric {
return {
name: metric.name,
value: metric.value,
rating: metric.rating,
id: metric.id,
page: window.location.pathname,
timestamp: Date.now(),
};
}
export function reportAllVitals(callback?: (metric: VitalMetric) => void) {
const handler = (metric: Metric) => {
const payload = createMetricPayload(metric);
// שליחה לשרת
navigator.sendBeacon(
'/api/vitals',
JSON.stringify(payload)
);
// callback אופציונלי
callback?.(payload);
};
onCLS(handler);
onINP(handler);
onLCP(handler);
onFCP(handler);
onTTFB(handler);
}
// app/components/WebVitalsReporter.tsx
'use client';
import { useEffect, useState } from 'react';
import { reportAllVitals, VitalMetric } from '@/lib/webVitals';
export function WebVitalsReporter() {
useEffect(() => {
reportAllVitals();
}, []);
return null;
}
// app/api/vitals/route.ts
import { NextRequest, NextResponse } from 'next/server';
interface StoredMetric {
name: string;
value: number;
rating: string;
page: string;
timestamp: number;
}
// בפרודקשן - שימוש במסד נתונים
const metricsStore: StoredMetric[] = [];
export async function POST(request: NextRequest) {
const metric = await request.json();
metricsStore.push({
name: metric.name,
value: metric.value,
rating: metric.rating,
page: metric.page,
timestamp: metric.timestamp,
});
return NextResponse.json({ success: true });
}
export async function GET() {
const metricNames = ['LCP', 'INP', 'CLS', 'FCP', 'TTFB'];
const summary: Record<string, { avg: number; p75: number; count: number }> = {};
for (const name of metricNames) {
const values = metricsStore
.filter((m) => m.name === name)
.map((m) => m.value)
.sort((a, b) => a - b);
if (values.length > 0) {
const avg = values.reduce((a, b) => a + b, 0) / values.length;
const p75Index = Math.floor(values.length * 0.75);
summary[name] = {
avg: Math.round(avg * 100) / 100,
p75: values[p75Index],
count: values.length,
};
}
}
return NextResponse.json(summary);
}
// app/admin/vitals/page.tsx
interface MetricSummary {
avg: number;
p75: number;
count: number;
}
function getColor(name: string, value: number): string {
const thresholds: Record<string, [number, number]> = {
LCP: [2500, 4000],
INP: [200, 500],
CLS: [0.1, 0.25],
FCP: [1800, 3000],
TTFB: [800, 1800],
};
const [good, poor] = thresholds[name] || [0, 0];
if (value <= good) return '#22c55e'; // ירוק
if (value <= poor) return '#f59e0b'; // כתום
return '#ef4444'; // אדום
}
function formatValue(name: string, value: number): string {
if (name === 'CLS') return value.toFixed(3);
return `${Math.round(value)} ms`;
}
export default async function VitalsDashboard() {
const res = await fetch('http://localhost:3000/api/vitals', {
cache: 'no-store',
});
const data: Record<string, MetricSummary> = await res.json();
return (
<main>
<h1>דשבורד Core Web Vitals</h1>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '20px' }}>
{Object.entries(data).map(([name, summary]) => (
<div
key={name}
style={{
border: '1px solid #e5e7eb',
borderRadius: '12px',
padding: '24px',
textAlign: 'center',
}}
>
<h2>{name}</h2>
<p
style={{
fontSize: '48px',
fontWeight: 'bold',
color: getColor(name, summary.p75),
}}
>
{formatValue(name, summary.p75)}
</p>
<p style={{ color: '#6b7280' }}>P75</p>
<hr />
<p>ממוצע: {formatValue(name, summary.avg)}</p>
<p>מדידות: {summary.count}</p>
</div>
))}
</div>
</main>
);
}
פתרון תרגיל 5¶
// utils/performanceReport.ts
interface PerformanceReport {
dns: number;
tcp: number;
ttfb: number;
domComplete: number;
fullLoad: number;
slowResources: { name: string; duration: number; type: string }[];
layoutShifts: { value: number; startTime: number }[];
totalCLS: number;
}
export function getPerformanceReport(): PerformanceReport {
const navigation = performance.getEntriesByType(
'navigation'
)[0] as PerformanceNavigationTiming;
// זמני רשת
const dns = navigation.domainLookupEnd - navigation.domainLookupStart;
const tcp = navigation.connectEnd - navigation.connectStart;
const ttfb = navigation.responseStart - navigation.requestStart;
const domComplete = navigation.domComplete - navigation.fetchStart;
const fullLoad = navigation.loadEventEnd - navigation.fetchStart;
// משאבים איטיים
const resources = performance.getEntriesByType(
'resource'
) as PerformanceResourceTiming[];
const slowResources = resources
.filter((r) => r.duration > 500)
.map((r) => ({
name: r.name,
duration: Math.round(r.duration),
type: r.initiatorType,
}))
.sort((a, b) => b.duration - a.duration);
// Layout shifts
const layoutShifts: { value: number; startTime: number }[] = [];
let totalCLS = 0;
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const layoutShift = entry as PerformanceEntry & {
hadRecentInput: boolean;
value: number;
};
if (!layoutShift.hadRecentInput) {
layoutShifts.push({
value: layoutShift.value,
startTime: layoutShift.startTime,
});
totalCLS += layoutShift.value;
}
}
});
observer.observe({ type: 'layout-shift', buffered: true });
return {
dns: Math.round(dns),
tcp: Math.round(tcp),
ttfb: Math.round(ttfb),
domComplete: Math.round(domComplete),
fullLoad: Math.round(fullLoad),
slowResources,
layoutShifts,
totalCLS: Math.round(totalCLS * 1000) / 1000,
};
}
export function printPerformanceReport() {
// ממתינים לטעינה מלאה
window.addEventListener('load', () => {
// ממתינים עוד קצת כדי שכל ה-layout shifts יירשמו
setTimeout(() => {
const report = getPerformanceReport();
console.group('דוח ביצועי דף');
console.log(`DNS Lookup: ${report.dns}ms`);
console.log(`TCP Connection: ${report.tcp}ms`);
console.log(`TTFB: ${report.ttfb}ms`);
console.log(`DOM Complete: ${report.domComplete}ms`);
console.log(`Full Load: ${report.fullLoad}ms`);
if (report.slowResources.length > 0) {
console.group(`משאבים איטיים (${report.slowResources.length})`);
report.slowResources.forEach((r) => {
console.log(`${r.type}: ${r.name} - ${r.duration}ms`);
});
console.groupEnd();
}
console.log(`Total CLS: ${report.totalCLS}`);
if (report.layoutShifts.length > 0) {
console.group(`Layout Shifts (${report.layoutShifts.length})`);
report.layoutShifts.forEach((s) => {
console.log(`Value: ${s.value.toFixed(4)} at ${Math.round(s.startTime)}ms`);
});
console.groupEnd();
}
console.groupEnd();
}, 3000);
});
}
פתרון תרגיל 6¶
// app/products/[id]/page.tsx - Server Component
import Image from 'next/image';
import { Suspense } from 'react';
import { Metadata } from 'next';
import { AddToCartButton } from './AddToCartButton';
interface Product {
id: string;
name: string;
description: string;
image: string;
price: number;
}
async function getProduct(id: string): Promise<Product> {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: { revalidate: 60 },
});
return res.json();
}
export async function generateMetadata({
params,
}: {
params: { id: string };
}): Promise<Metadata> {
const product = await getProduct(params.id);
return {
title: product.name,
description: product.description,
};
}
export default async function ProductPage({
params,
}: {
params: { id: string };
}) {
const product = await getProduct(params.id);
return (
<main style={{ maxWidth: '1200px', margin: '0 auto', padding: '20px 16px' }}>
<article style={{ display: 'flex', gap: '40px', flexWrap: 'wrap' }}>
{/* תמונה עם dimensions מוגדרים - מונע CLS */}
<div style={{ flex: '1 1 400px' }}>
<Image
src={product.image}
alt={product.name}
width={600}
height={600}
priority
sizes="(max-width: 768px) 100vw, 50vw"
style={{ width: '100%', height: 'auto', borderRadius: '8px' }}
/>
</div>
<div style={{ flex: '1 1 400px' }}>
<h1 style={{ fontSize: '28px', marginBottom: '16px' }}>{product.name}</h1>
<p style={{ fontSize: '16px', lineHeight: 1.6, color: '#4b5563' }}>
{product.description}
</p>
<p style={{ fontSize: '32px', fontWeight: 'bold', margin: '20px 0' }}>
{product.price} ₪
</p>
{/* Client Component רק לכפתור */}
<AddToCartButton productId={product.id} price={product.price} />
</div>
</article>
{/* ביקורות - lazy loaded עם skeleton */}
<section style={{ marginTop: '40px', minHeight: '300px' }}>
<h2 style={{ fontSize: '22px', marginBottom: '16px' }}>ביקורות</h2>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={params.id} />
</Suspense>
</section>
{/* מוצרים קשורים - lazy loaded עם skeleton */}
<section style={{ marginTop: '40px', minHeight: '200px' }}>
<h2 style={{ fontSize: '22px', marginBottom: '16px' }}>מוצרים קשורים</h2>
<Suspense fallback={<RelatedSkeleton />}>
<RelatedProducts productId={params.id} />
</Suspense>
</section>
</main>
);
}
// קומפוננטות Server נפרדות לנתונים משניים
async function Reviews({ productId }: { productId: string }) {
const reviews = await fetch(`https://api.example.com/products/${productId}/reviews`).then(
(r) => r.json()
);
return (
<ul style={{ listStyle: 'none', padding: 0 }}>
{reviews.map((r: any) => (
<li key={r.id} style={{ display: 'flex', gap: '12px', marginBottom: '16px' }}>
<Image
src={r.authorAvatar}
alt={`תמונת ${r.author}`}
width={50}
height={50}
style={{ borderRadius: '50%' }}
/>
<div>
<strong>{r.author}</strong>
<p>{r.text}</p>
</div>
</li>
))}
</ul>
);
}
async function RelatedProducts({ productId }: { productId: string }) {
const related = await fetch(`https://api.example.com/products/${productId}/related`).then(
(r) => r.json()
);
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: '16px' }}>
{related.map((r: any) => (
<a key={r.id} href={`/products/${r.id}`} style={{ textDecoration: 'none', color: 'inherit' }}>
<Image src={r.image} alt={r.name} width={200} height={200} />
<p>{r.name}</p>
</a>
))}
</div>
);
}
function ReviewsSkeleton() {
return (
<div>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} style={{ height: '80px', marginBottom: '16px', backgroundColor: '#f3f4f6', borderRadius: '8px' }} />
))}
</div>
);
}
function RelatedSkeleton() {
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: '16px' }}>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} style={{ height: '240px', backgroundColor: '#f3f4f6', borderRadius: '8px' }} />
))}
</div>
);
}
// app/products/[id]/AddToCartButton.tsx
'use client';
import { useState } from 'react';
export function AddToCartButton({ productId, price }: { productId: string; price: number }) {
const [isAdding, setIsAdding] = useState(false);
const [added, setAdded] = useState(false);
async function handleAddToCart() {
setIsAdding(true);
// הגיון פשוט - ללא חישוב כבד על ה-main thread
await fetch('/api/cart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: productId, price }),
});
setIsAdding(false);
setAdded(true);
}
return (
<button
onClick={handleAddToCart}
disabled={isAdding || added}
style={{
padding: '16px 32px',
fontSize: '18px',
fontWeight: 'bold',
minHeight: '48px',
width: '100%',
backgroundColor: added ? '#22c55e' : '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: isAdding ? 'wait' : 'pointer',
}}
>
{added ? 'נוסף לסל' : isAdding ? 'מוסיף...' : 'הוסף לסל'}
</button>
);
}
מה שופר:
- LCP: הדף הוא Server Component - HTML מגיע מהשרת עם תוכן מלא. תמונה ראשית עם priority
- INP: הכפתור קל - רק קריאת API פשוטה. אין חישוב כבד. Client Component רק לכפתור
- CLS: כל התמונות עם width/height. אזורים ל-reviews ו-related עם minHeight ו-skeleton
תשובות לשאלות¶
1. FID מדד רק את ההשהיה של האינטראקציה הראשונה בדף, בעוד INP מודד את כל האינטראקציות לאורך כל הביקור ומדווח על האינטראקציה האיטית ביותר (בפרקטילה ה-98). Google החליף כי FID לא תפס אינטראקציות איטיות שקרו אחרי הטעינה הראשונית, ולא כלל את זמן העיבוד והרינדור - רק את ההשהיה.
2. Layout shift שקורה בעקבות פעולת משתמש (כמו קליק על כפתור שפותח אקורדיון) לא נספר ל-CLS, בתנאי שהוא קורה תוך 500 מ"ש מהאינטראקציה. הרציונל: המשתמש ציפה לשינוי. רק שינויים בלתי צפויים נספרים.
3. LCP יכול להיות מצוין (1.5 שניות) אם יש תמונת hero שנטענת מהר, אבל חווית המשתמש עדיין גרועה אם: INP גבוה (כפתורים לא מגיבים), CLS גבוה (האתר "קופץ"), או התוכן מתחת ל-fold נטען באיטיות. LCP מודד רק את החלק העליון של הדף בטעינה ראשונית.
4. Lab Data נמדד בסביבת בדיקה מבוקרת (כמו Lighthouse) - שימושי לאבחון ודיבוג כי ניתן לשחזר. Field Data נמדד ממשתמשים אמיתיים (CrUX - Chrome User Experience Report) - משקף את המציאות עם מגוון מכשירים ותנאי רשת. שניהם חשובים: Lab למציאת בעיות ותיקונן, Field למדידת ההשפעה האמיתית.
5. startTransition מסמן עדכון state כלא דחוף. React מעדכן קודם את ה-UI הדחוף (כמו ערך ה-input) ורק אחר כך מעבד את העדכון הלא דחוף (כמו תוצאות סינון). זה מאפשר לדפדפן לצייר את העדכון הדחוף מיד, מה שמקצר את הזמן בין האינטראקציה לציור - ומשפר INP.