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';
// כל מודול נטען דינמית רק כשצריך
const DataTable = dynamic(() => import('./DataTable'), {
loading: () => <LoadingPlaceholder height="400px" text="טוען טבלה..." />,
});
const ChartLibrary = dynamic(() => import('./ChartLibrary'), {
loading: () => <LoadingPlaceholder height="400px" text="טוען גרף..." />,
ssr: false, // גרפים לא צריכים SSR
});
const MarkdownEditor = dynamic(() => import('./MarkdownEditor'), {
loading: () => <LoadingPlaceholder height="500px" text="טוען עורך..." />,
ssr: false, // עורך לא צריך 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¶
// החלפות:
// 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;
}
// קומפוננטת פריט ב-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>
);
});
// קומפוננטת סטטיסטיקות ב-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 - סינון מחושב רק כשתלויות משתנות
const filteredTodos = useMemo(() => {
return todos.filter((todo) => {
if (filter === 'active') return !todo.completed;
if (filter === 'completed') return todo.completed;
return true;
});
}, [todos, filter]);
// useMemo - סטטיסטיקות מחושבות רק כשהרשימה משתנה
const stats = useMemo(() => {
const completed = todos.filter((t) => t.completed).length;
return {
total: todos.length,
completed,
active: todos.length - completed,
};
}, [todos]);
// useCallback - פונקציות לא נוצרות מחדש
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 '';
});
}, []);
// וירטואליזציה - רק הפריטים הנראים מרונדרים
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 עם hash - cache לשנה, immutable
source: '/_next/static/(.*)',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
},
],
},
{
// תמונות מותאמות - cache לחודש
source: '/_next/image(.*)',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=2592000, stale-while-revalidate=86400',
},
],
},
{
// תמונות סטטיות - cache לחודש
source: '/images/(.*)',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=2592000',
},
],
},
];
},
};
// app/page.tsx - דף הבית - רענון כל 5 דקות
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 - דף מאמר - רענון כל שעה
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 עם 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';
// פונט עברי
const rubik = Rubik({
subsets: ['hebrew', 'latin'],
display: 'swap',
variable: '--font-rubik',
fallback: ['Arial', 'sans-serif'],
weight: ['400', '700'],
});
// פונט אנגלי
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
fallback: ['Helvetica', 'sans-serif'],
weight: ['400', '500', '700'],
});
// פונט קוד - מקומי
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;
}
/* כותרות באנגלית */
.title-en {
font-family: var(--font-inter), Helvetica, sans-serif;
}
/* בלוקי קוד */
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) לא מוצא אלמנטים שלא מרונדרים, גלילה עלולה להרגיש "מקרטעת" אם הפריטים כבדים, ונגישות עלולה להיפגע (קוראי מסך לא רואים את כל הרשימה).