לדלג לתוכן

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.