לדלג לתוכן

10.5 - Performance Optimization - פתרון

פתרון - אופטימיזציית ביצועים - Performance Optimization

פתרון תרגיל 1

import Image from 'next/image';

interface GalleryImage {
  src: string;
  title: string;
  blurDataURL?: string;
}

function Gallery({ images }: { images: GalleryImage[] }) {
  return (
    <div className="gallery">
      {images.map((img, i) => (
        <div key={i} className="gallery-item">
          <div style={{ position: 'relative', aspectRatio: '4/3' }}>
            <Image
              src={img.src}
              alt={img.title}
              fill
              sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
              priority={i < 6}
              placeholder={img.blurDataURL ? 'blur' : 'empty'}
              blurDataURL={img.blurDataURL}
              style={{ objectFit: 'cover', borderRadius: '8px' }}
            />
          </div>
          <p>{img.title}</p>
        </div>
      ))}
    </div>
  );
}
.gallery {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  gap: 20px;
}

.gallery-item {
  overflow: hidden;
}

.gallery-item p {
  margin-top: 8px;
  font-size: 14px;
}

שיפורים:
- next/image עם fill ו-objectFit: cover - אופטימיזציה אוטומטית לפורמט WebP
- priority ל-6 התמונות הראשונות (above the fold), שאר התמונות עם lazy loading אוטומטי
- sizes מדויק - הדפדפן מוריד תמונה בגודל המתאים למסך
- aspectRatio: 4/3 - שומר מקום ומונע CLS
- placeholder="blur" עם blurDataURL - מציג גרסה מטושטשת בזמן טעינה


פתרון תרגיל 2

'use client';

import dynamic from 'next/dynamic';
import { useState, Suspense } from 'react';

// every module loads dynamically only when needed
const DataTable = dynamic(() => import('./DataTable'), {
  loading: () => <LoadingPlaceholder height="400px" text="טוען טבלה..." />,
});

const ChartLibrary = dynamic(() => import('./ChartLibrary'), {
  loading: () => <LoadingPlaceholder height="400px" text="טוען גרף..." />,
  ssr: false, // charts don't need SSR
});

const MarkdownEditor = dynamic(() => import('./MarkdownEditor'), {
  loading: () => <LoadingPlaceholder height="500px" text="טוען עורך..." />,
  ssr: false, // the editor doesn't need SSR
});

const PdfExporter = dynamic(() => import('./PdfExporter'), {
  loading: () => <LoadingPlaceholder height="200px" text="טוען מייצא PDF..." />,
  ssr: false,
});

function LoadingPlaceholder({ height, text }: { height: string; text: string }) {
  return (
    <div
      style={{
        height,
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        backgroundColor: '#f3f4f6',
        borderRadius: '8px',
      }}
    >
      <p>{text}</p>
    </div>
  );
}

type TabType = 'table' | 'chart' | 'editor' | 'export';

function Dashboard() {
  const [activeTab, setActiveTab] = useState<TabType>('table');

  const tabs: { key: TabType; label: string }[] = [
    { key: 'table', label: 'טבלה' },
    { key: 'chart', label: 'גרף' },
    { key: 'editor', label: 'עורך' },
    { key: 'export', label: 'ייצוא' },
  ];

  return (
    <div>
      <nav>
        {tabs.map((tab) => (
          <button
            key={tab.key}
            onClick={() => setActiveTab(tab.key)}
            style={{ fontWeight: activeTab === tab.key ? 'bold' : 'normal' }}
          >
            {tab.label}
          </button>
        ))}
      </nav>

      <div style={{ marginTop: '20px' }}>
        {activeTab === 'table' && <DataTable />}
        {activeTab === 'chart' && <ChartLibrary />}
        {activeTab === 'editor' && <MarkdownEditor />}
        {activeTab === 'export' && <PdfExporter />}
      </div>
    </div>
  );
}

כעת במקום לטעון 530KB (200+150+100+80) מיד, נטען רק 80KB (DataTable) בטעינה ראשונית, ושאר המודולים נטענים לפי דרישה.


פתרון תרגיל 3

// replacements:
// lodash (71KB) -> native methods
// moment (67KB + locale) -> date-fns (tree-shakeable, ~5KB per function)
// uuid (3KB) -> crypto.randomUUID() (native, 0KB)
// axios (13KB) -> fetch (native, 0KB)

import { isAfter, subDays, format } from 'date-fns';
import { he } from 'date-fns/locale';

interface Item {
  name: string;
  date: string;
  [key: string]: any;
}

function processData(items: Item[]) {
  const thirtyDaysAgo = subDays(new Date(), 30);

  return items
    // lodash filter -> native filter
    .filter((item) => isAfter(new Date(item.date), thirtyDaysAgo))
    // lodash sortBy -> native sort
    .sort((a, b) => a.name.localeCompare(b.name))
    // lodash map -> native map, uuid -> crypto.randomUUID
    .map((item) => ({
      ...item,
      id: crypto.randomUUID(),
      formattedDate: format(new Date(item.date), 'dd MMMM yyyy', { locale: he }),
    }));
}

// axios -> native fetch
async function fetchData() {
  const response = await fetch('/api/data');
  if (!response.ok) throw new Error('Failed to fetch');
  return response.json();
}

חיסכון:
- lodash: 71KB -> 0KB (שימוש ב-native methods)
- moment: ~67KB -> ~5KB (date-fns עם tree shaking, רק הפונקציות שמשתמשים בהן)
- uuid: 3KB -> 0KB (crypto.randomUUID הוא native)
- axios: 13KB -> 0KB (fetch הוא native)
- סה"כ חיסכון: ~149KB מגודל ה-bundle


פתרון תרגיל 4

'use client';

import { useState, useMemo, useCallback, memo } from 'react';
import { FixedSizeList as List } from 'react-window';

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

// item component wrapped in memo
const TodoItem = memo(function TodoItem({
  todo,
  onToggle,
  onDelete,
}: {
  todo: Todo;
  onToggle: (id: number) => void;
  onDelete: (id: number) => void;
}) {
  return (
    <li style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '8px' }}>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => onToggle(todo.id)}
      />
      <span style={{ textDecoration: todo.completed ? 'line-through' : 'none', flex: 1 }}>
        {todo.text}
      </span>
      <button onClick={() => onDelete(todo.id)}>מחק</button>
    </li>
  );
});

// stats component wrapped in memo
const Stats = memo(function Stats({
  total,
  completed,
  active,
}: {
  total: number;
  completed: number;
  active: number;
}) {
  return (
    <div>
      <span>סה"כ: {total}</span>
      <span> | הושלמו: {completed}</span>
      <span> | פעילות: {active}</span>
    </div>
  );
});

function TodoApp() {
  const [todos, setTodos] = useState<Todo[]>(generateTodos(1000));
  const [newTodo, setNewTodo] = useState('');
  const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all');

  // useMemo - filtering computed only when dependencies change
  const filteredTodos = useMemo(() => {
    return todos.filter((todo) => {
      if (filter === 'active') return !todo.completed;
      if (filter === 'completed') return todo.completed;
      return true;
    });
  }, [todos, filter]);

  // useMemo - stats computed only when the list changes
  const stats = useMemo(() => {
    const completed = todos.filter((t) => t.completed).length;
    return {
      total: todos.length,
      completed,
      active: todos.length - completed,
    };
  }, [todos]);

  // useCallback - functions aren't recreated
  const toggleTodo = useCallback((id: number) => {
    setTodos((prev) =>
      prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
    );
  }, []);

  const deleteTodo = useCallback((id: number) => {
    setTodos((prev) => prev.filter((t) => t.id !== id));
  }, []);

  const addTodo = useCallback(() => {
    setNewTodo((current) => {
      if (!current.trim()) return current;
      setTodos((prev) => [
        ...prev,
        { id: Date.now(), text: current, completed: false },
      ]);
      return '';
    });
  }, []);

  // virtualization - only visible items are rendered
  const Row = useCallback(
    ({ index, style }: { index: number; style: React.CSSProperties }) => {
      const todo = filteredTodos[index];
      return (
        <div style={style}>
          <TodoItem todo={todo} onToggle={toggleTodo} onDelete={deleteTodo} />
        </div>
      );
    },
    [filteredTodos, toggleTodo, deleteTodo]
  );

  return (
    <div>
      <h1>רשימת משימות</h1>

      <div>
        <input value={newTodo} onChange={(e) => setNewTodo(e.target.value)} />
        <button onClick={addTodo}>הוסף</button>
      </div>

      <Stats total={stats.total} completed={stats.completed} active={stats.active} />

      <div>
        <button onClick={() => setFilter('all')}>הכל</button>
        <button onClick={() => setFilter('active')}>פעילות</button>
        <button onClick={() => setFilter('completed')}>הושלמו</button>
      </div>

      <List
        height={500}
        itemCount={filteredTodos.length}
        itemSize={50}
        width="100%"
      >
        {Row}
      </List>
    </div>
  );
}

פתרון תרגיל 5

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        // JS/CSS files with a hash - cache for a year, immutable
        source: '/_next/static/(.*)',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=31536000, immutable',
          },
        ],
      },
      {
        // optimized images - cache for a month
        source: '/_next/image(.*)',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=2592000, stale-while-revalidate=86400',
          },
        ],
      },
      {
        // static images - cache for a month
        source: '/images/(.*)',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=2592000',
          },
        ],
      },
    ];
  },
};
// app/page.tsx - homepage - refresh every 5 minutes
export const revalidate = 300;

export default async function HomePage() {
  const headlines = await fetch('https://api.news.com/headlines', {
    next: { revalidate: 300 },
  }).then((r) => r.json());

  return (
    <main>
      <h1>חדשות אחרונות</h1>
      {headlines.map((h: any) => (
        <article key={h.id}>
          <h2>{h.title}</h2>
        </article>
      ))}
    </main>
  );
}
// app/articles/[slug]/page.tsx - article page - refresh every hour
export const revalidate = 3600;

export default async function ArticlePage({
  params,
}: {
  params: { slug: string };
}) {
  const article = await fetch(
    `https://api.news.com/articles/${params.slug}`,
    { next: { revalidate: 3600 } }
  ).then((r) => r.json());

  return (
    <article>
      <h1>{article.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: article.content }} />
    </article>
  );
}
// app/api/data/route.ts - API with stale-while-revalidate
import { NextResponse } from 'next/server';

export async function GET() {
  const data = await fetchFromDatabase();

  return NextResponse.json(data, {
    headers: {
      'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',
    },
  });
}

פתרון תרגיל 6

// app/layout.tsx
import { Inter, Rubik } from 'next/font/google';
import localFont from 'next/font/local';

// Hebrew font
const rubik = Rubik({
  subsets: ['hebrew', 'latin'],
  display: 'swap',
  variable: '--font-rubik',
  fallback: ['Arial', 'sans-serif'],
  weight: ['400', '700'],
});

// English font
const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
  fallback: ['Helvetica', 'sans-serif'],
  weight: ['400', '500', '700'],
});

// code font - local
const firaCode = localFont({
  src: [
    { path: '../fonts/FiraCode-Regular.woff2', weight: '400', style: 'normal' },
  ],
  display: 'swap',
  variable: '--font-code',
  fallback: ['Consolas', 'Monaco', 'monospace'],
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html
      lang="he"
      dir="rtl"
      className={`${rubik.variable} ${inter.variable} ${firaCode.variable}`}
    >
      <body>{children}</body>
    </html>
  );
}
/* globals.css */
body {
  font-family: var(--font-rubik), Arial, sans-serif;
}

/* English headings */
.title-en {
  font-family: var(--font-inter), Helvetica, sans-serif;
}

/* code blocks */
code, pre {
  font-family: var(--font-code), Consolas, Monaco, monospace;
}

שיפורים:
- Open Sans הוסר (מיותר - Rubik ו-Inter מכסים את כל הצרכים)
- next/font מוריד את הפונטים בזמן build ומגיש אותם מקומית
- display: 'swap' מציג טקסט מיד עם fallback font
- CSS Variables מאפשרים שימוש גמיש
- רק ה-subsets הנדרשים נטענים (hebrew, latin)
- fallback fonts מוגדרים למניעת CLS


תשובות לשאלות

1. loading="lazy" (ברירת מחדל ב-next/image) אומר לדפדפן לטעון את התמונה רק כשהיא מתקרבת ל-viewport. priority גורם לתמונה להיטען מיד עם preload, ללא lazy loading. משתמשים ב-priority לתמונות שנמצאות above the fold (תמונת hero, לוגו), ו-lazy loading לתמונות שמתחת ל-fold.

2. Tree Shaking הוא תהליך שבו ה-bundler מסיר קוד שלא משתמשים בו. זה עובד רק עם ES Modules כי import/export הם סטטיים - הכלי יודע בזמן build בדיוק מה מיובא. ב-CommonJS, require() יכול להיות דינמי (בתוך if, עם משתנה), אז הכלי לא יכול לדעת מה בשימוש ומה לא.

3. React.memo עוטף קומפוננטה ומונע רינדור מחדש כשה-props לא השתנו. useMemo שומר ערך מחושב בזיכרון ומחשב מחדש רק כשהתלויות משתנות - משמש לחישובים יקרים. useCallback שומר פונקציה בזיכרון ויוצר reference חדש רק כשהתלויות משתנות - משמש כשמעבירים פונקציות כ-props לקומפוננטות עם memo.

4. stale-while-revalidate היא אסטרטגיית cache שמחזירה מיד את הגרסה השמורה (stale) למשתמש, ובמקביל מרעננת את הנתון ברקע. המשתמש מקבל תשובה מיידית (חווית משתמש מצוינת), והבקשה הבאה תקבל נתון מעודכן.

5. וירטואליזציה חשובה כי היא מצמצמת דרמטית את מספר אלמנטי ה-DOM - במקום 10,000 שורות, רק 20 נמצאות בזיכרון. זה משפר זמן רינדור, צריכת זיכרון ו-INP. החיסרון: חיפוש בדף (Ctrl+F) לא מוצא אלמנטים שלא מרונדרים, גלילה עלולה להרגיש "מקרטעת" אם הפריטים כבדים, ונגישות עלולה להיפגע (קוראי מסך לא רואים את כל הרשימה).