7.6 דפוסי תכנון וביצועים פתרון
פתרון - דפוסי תכנון וביצועים - Design Patterns and Performance¶
פתרון תרגיל 1 - Compound Components - Select מותאם¶
import { createContext, useContext, useState, useRef, useEffect, useCallback } from "react";
interface SelectContextType {
value: string | null;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
selectValue: (value: string) => void;
highlightedIndex: number;
setHighlightedIndex: (index: number) => void;
}
const SelectContext = createContext<SelectContextType | null>(null);
function useSelect() {
const ctx = useContext(SelectContext);
if (!ctx) throw new Error("Must be used within Select");
return ctx;
}
interface SelectProps {
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
disabled?: boolean;
children: React.ReactNode;
}
function Select({ value, onChange, placeholder, disabled, children }: SelectProps) {
const [internalValue, setInternalValue] = useState<string | null>(value ?? null);
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const selectValue = useCallback(
(val: string) => {
setInternalValue(val);
onChange?.(val);
setIsOpen(false);
},
[onChange]
);
useEffect(() => {
if (!isOpen) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const handler = (e: KeyboardEvent) => {
switch (e.key) {
case "Escape":
setIsOpen(false);
break;
case "ArrowDown":
e.preventDefault();
setHighlightedIndex((prev) => prev + 1);
break;
case "ArrowUp":
e.preventDefault();
setHighlightedIndex((prev) => Math.max(prev - 1, 0));
break;
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [isOpen]);
return (
<SelectContext.Provider
value={{
value: internalValue,
isOpen,
setIsOpen: disabled ? () => {} : setIsOpen,
selectValue,
highlightedIndex,
setHighlightedIndex,
}}
>
<div ref={containerRef} style={{ position: "relative", display: "inline-block" }}>
{children}
</div>
</SelectContext.Provider>
);
}
function Trigger({ children }: { children?: React.ReactNode }) {
const { value, isOpen, setIsOpen } = useSelect();
return (
<button
onClick={() => setIsOpen(!isOpen)}
style={{
padding: "8px 16px",
border: "1px solid #ddd",
borderRadius: "4px",
minWidth: "150px",
textAlign: "right",
cursor: "pointer",
}}
>
{children || value || "בחר..."}
<span style={{ float: "left" }}>{isOpen ? "▲" : "▼"}</span>
</button>
);
}
function Options({ children }: { children: React.ReactNode }) {
const { isOpen } = useSelect();
if (!isOpen) return null;
return (
<ul
style={{
position: "absolute",
top: "100%",
left: 0,
right: 0,
backgroundColor: "white",
border: "1px solid #ddd",
borderRadius: "4px",
listStyle: "none",
padding: "4px 0",
margin: "4px 0 0",
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
zIndex: 100,
}}
>
{children}
</ul>
);
}
function Option({ value, children }: { value: string; children: React.ReactNode }) {
const { value: selectedValue, selectValue } = useSelect();
const isSelected = selectedValue === value;
return (
<li
onClick={() => selectValue(value)}
style={{
padding: "8px 16px",
cursor: "pointer",
backgroundColor: isSelected ? "#e3f2fd" : "transparent",
fontWeight: isSelected ? "bold" : "normal",
}}
>
{children}
</li>
);
}
Select.Trigger = Trigger;
Select.Options = Options;
Select.Option = Option;
// שימוש
function App() {
const [color, setColor] = useState("");
return (
<div>
<Select value={color} onChange={setColor} placeholder="בחר צבע">
<Select.Trigger>{color || "בחר צבע..."}</Select.Trigger>
<Select.Options>
<Select.Option value="red">אדום</Select.Option>
<Select.Option value="blue">כחול</Select.Option>
<Select.Option value="green">ירוק</Select.Option>
</Select.Options>
</Select>
<p>נבחר: {color}</p>
</div>
);
}
הסבר:
- ה-state המשותף (ערך נבחר, פתוח/סגור) מנוהל ב-context דרך קומפוננטת Select
- כל תת-קומפוננטה ניגשת ל-context ומגיבה בהתאם
- ניווט מקלדת מטופל ברמת ה-Select דרך event listener גלובלי
פתרון תרגיל 2 - HOC מתקדם¶
import { useRef, useEffect, ComponentType } from "react";
// withLogger HOC
function withLogger<P extends object>(
WrappedComponent: ComponentType<P>,
componentName?: string
) {
const displayName = componentName || WrappedComponent.displayName || WrappedComponent.name || "Unknown";
return function LoggedComponent(props: P) {
const renderCount = useRef(0);
const startTime = performance.now();
renderCount.current++;
useEffect(() => {
const renderTime = performance.now() - startTime;
console.log(
`[${displayName}] Render #${renderCount.current} (${renderTime.toFixed(2)}ms)`,
{ props }
);
});
return <WrappedComponent {...props} />;
};
}
// withPermission HOC
function withPermission<P extends object>(
WrappedComponent: ComponentType<P>,
requiredPermission: string
) {
return function PermissionComponent(props: P) {
// הנחה שיש הוק useAuth שמספק permissions
const { user } = useAuth();
const hasPermission = user?.permissions?.includes(requiredPermission);
if (!hasPermission) {
return (
<div style={{ padding: "20px", textAlign: "center", color: "#999" }}>
<h3>אין הרשאה</h3>
<p>אין לך הרשאת "{requiredPermission}" לצפות בתוכן זה</p>
</div>
);
}
return <WrappedComponent {...props} />;
};
}
// שימוש - הרכבת שני HOC-ים
function AdminPanel() {
return (
<div>
<h1>פאנל ניהול</h1>
<p>תוכן למנהלים בלבד</p>
</div>
);
}
const EnhancedAdminPanel = withLogger(
withPermission(AdminPanel, "admin"),
"AdminPanel"
);
הסבר:
- withLogger עוקב אחרי כל רנדר, מדפיס מספר רנדר, זמן רנדר ו-props
- withPermission בודק הרשאות לפני רנדור הקומפוננטה
- ההרכבה של שני ה-HOC-ים: קודם בודקים הרשאה, ואז מלוגגים
פתרון תרגיל 3 - Render Props - Data Fetcher¶
import { useState, useEffect, useRef, useCallback } from "react";
interface DataFetcherProps<T> {
url: string;
pollInterval?: number;
render: (props: {
data: T | null;
loading: boolean;
error: string | null;
refetch: () => void;
}) => React.ReactNode;
}
function DataFetcher<T>({ url, pollInterval, render }: DataFetcherProps<T>) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<number | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : "שגיאה");
} finally {
setLoading(false);
}
}, [url]);
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
if (pollInterval && pollInterval > 0) {
intervalRef.current = window.setInterval(fetchData, pollInterval);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}
}, [pollInterval, fetchData]);
return <>{render({ data, loading, error, refetch: fetchData })}</>;
}
// שימוש - תצוגת כרטיסים
function UserCards() {
return (
<DataFetcher<{ id: number; name: string }[]>
url="/api/users"
render={({ data, loading, error, refetch }) => {
if (loading) return <p>טוען משתמשים...</p>;
if (error) return <p>שגיאה: {error} <button onClick={refetch}>נסה שוב</button></p>;
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "16px" }}>
{data?.map((user) => (
<div key={user.id} style={{ padding: "16px", border: "1px solid #ddd" }}>
<h3>{user.name}</h3>
</div>
))}
</div>
);
}}
/>
);
}
// שימוש - תצוגת טבלה
function UserTable() {
return (
<DataFetcher<{ id: number; name: string; email: string }[]>
url="/api/users"
pollInterval={30000}
render={({ data, loading, error, refetch }) => {
if (loading) return <p>טוען...</p>;
if (error) return <p>שגיאה: {error}</p>;
return (
<table>
<thead>
<tr><th>שם</th><th>אימייל</th></tr>
</thead>
<tbody>
{data?.map((user) => (
<tr key={user.id}>
<td>{user.name}</td>
<td>{user.email}</td>
</tr>
))}
</tbody>
</table>
);
}}
/>
);
}
הסبר:
- ה-DataFetcher מנהל את כל לוגיקת השליפה ומעביר את התוצאות ל-render prop
- כל קומפוננטה יכולה להציג את אותם נתונים בצורה שונה
- pollInterval מאפשר שליפה חוזרת אוטומטית
פתרון תרגיל 4 - אופטימיזציית ביצועים¶
import { useState, useMemo, useCallback, memo } from "react";
interface Product {
id: number;
name: string;
price: number;
}
// בעיה 1: ProductItem לא עטוף ב-memo
// בעיה 2: style חדש בכל רנדר
// בעיה 3: onSelect חדש בכל רנדר (inline function)
// בעיה 4: filteredProducts ו-stats מחושבים בכל רנדר
// בעיה 5: שינוי theme מרנדר את כל הרשימה
// תיקון
interface ProductItemProps {
product: Product;
isSelected: boolean;
onSelect: (id: number) => void;
isDark: boolean;
}
const ProductItem = memo(function ProductItem({
product,
isSelected,
onSelect,
isDark,
}: ProductItemProps) {
console.log(`Rendering: ${product.name}`);
return (
<li
onClick={() => onSelect(product.id)}
style={{
backgroundColor: isDark ? "#333" : "#fff",
color: isDark ? "#fff" : "#333",
fontWeight: isSelected ? "bold" : "normal",
padding: "8px",
cursor: "pointer",
}}
>
{product.name} - {product.price} ש"ח
</li>
);
});
function generateProducts(count: number): Product[] {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
name: `מוצר ${i + 1}`,
price: Math.floor(Math.random() * 1000) + 10,
}));
}
function ProductDashboard() {
const [products] = useState(() => generateProducts(1000));
const [search, setSearch] = useState("");
const [sortBy, setSortBy] = useState<"name" | "price">("name");
const [selectedId, setSelectedId] = useState<number | null>(null);
const [theme, setTheme] = useState("light");
// תיקון: useMemo לחישוב הסינון והמיון
const filteredProducts = useMemo(() => {
return products
.filter((p) => p.name.toLowerCase().includes(search.toLowerCase()))
.sort((a, b) => {
if (sortBy === "name") return a.name.localeCompare(b.name);
return a.price - b.price;
});
}, [products, search, sortBy]);
// תיקון: useMemo לסטטיסטיקות
const stats = useMemo(() => {
if (filteredProducts.length === 0) {
return { total: 0, avgPrice: 0, maxPrice: 0 };
}
const total = filteredProducts.length;
const avgPrice =
filteredProducts.reduce((sum, p) => sum + p.price, 0) / total;
const maxPrice = Math.max(...filteredProducts.map((p) => p.price));
return { total, avgPrice, maxPrice };
}, [filteredProducts]);
// תיקון: useCallback לפונקציית הבחירה
const handleSelect = useCallback((id: number) => {
setSelectedId(id);
}, []);
const isDark = theme === "dark";
return (
<div className={theme}>
<button onClick={() => setTheme((t) => (t === "light" ? "dark" : "light"))}>
החלף ערכת נושא
</button>
<input value={search} onChange={(e) => setSearch(e.target.value)} />
<select value={sortBy} onChange={(e) => setSortBy(e.target.value as "name" | "price")}>
<option value="name">שם</option>
<option value="price">מחיר</option>
</select>
<div>
סה"כ: {stats.total}, ממוצע: {stats.avgPrice.toFixed(0)}, מקסימום: {stats.maxPrice}
</div>
<ul>
{filteredProducts.map((product) => (
<ProductItem
key={product.id}
product={product}
isSelected={product.id === selectedId}
onSelect={handleSelect}
isDark={isDark}
/>
))}
</ul>
</div>
);
}
הבעיות שזוהו ותוקנו:
1. ProductItem לא היה עטוף ב-memo - כל רנדר של ההורה גרם לרנדר של כל הפריטים
2. style={{}} יוצר אובייקט חדש בכל רנדר - memo לא יעזור. במקום זה, העברנו isDark כ-boolean
3. onSelect={()=>...} יוצר פונקציה חדשה בכל רנדר - החלפנו ב-useCallback
4. הסינון, המיון והסטטיסטיקות חושבו בכל רנדר - עטפנו ב-useMemo
5. הפרדנו בין isDark (boolean) ל-style object כדי שמעבר theme ישנה רק ערך פרימיטיבי
פתרון תרגיל 5 - Compound Components - Form Builder¶
import { createContext, useContext, useState, useCallback } from "react";
type Validator = (value: string) => string | null;
interface FormContextType {
values: Record<string, string>;
errors: Record<string, string>;
touched: Record<string, boolean>;
setValue: (name: string, value: string) => void;
setTouched: (name: string) => void;
registerField: (name: string, validators: Validator[]) => void;
isValid: boolean;
handleSubmit: (onSubmit: (values: Record<string, string>) => void) => (e: React.FormEvent) => void;
}
const FormContext = createContext<FormContextType | null>(null);
function useFormContext() {
const ctx = useContext(FormContext);
if (!ctx) throw new Error("Must be used within Form");
return ctx;
}
function Form({
children,
initialValues = {},
}: {
children: React.ReactNode;
initialValues?: Record<string, string>;
}) {
const [values, setValues] = useState<Record<string, string>>(initialValues);
const [errors, setErrors] = useState<Record<string, string>>({});
const [touched, setTouchedState] = useState<Record<string, boolean>>({});
const [validators, setValidators] = useState<Record<string, Validator[]>>({});
const validate = useCallback(
(name: string, value: string) => {
const fieldValidators = validators[name] || [];
for (const validator of fieldValidators) {
const error = validator(value);
if (error) return error;
}
return null;
},
[validators]
);
const setValue = useCallback(
(name: string, value: string) => {
setValues((prev) => ({ ...prev, [name]: value }));
const error = validate(name, value);
setErrors((prev) => {
if (error) return { ...prev, [name]: error };
const { [name]: _, ...rest } = prev;
return rest;
});
},
[validate]
);
const setTouched = useCallback((name: string) => {
setTouchedState((prev) => ({ ...prev, [name]: true }));
}, []);
const registerField = useCallback((name: string, fieldValidators: Validator[]) => {
setValidators((prev) => ({ ...prev, [name]: fieldValidators }));
}, []);
const isValid = Object.keys(errors).length === 0;
const handleSubmit = useCallback(
(onSubmit: (values: Record<string, string>) => void) => {
return (e: React.FormEvent) => {
e.preventDefault();
// ולידציה על כל השדות
const allErrors: Record<string, string> = {};
for (const [name, value] of Object.entries(values)) {
const error = validate(name, value);
if (error) allErrors[name] = error;
}
if (Object.keys(allErrors).length > 0) {
setErrors(allErrors);
const allTouched: Record<string, boolean> = {};
Object.keys(values).forEach((k) => (allTouched[k] = true));
setTouchedState(allTouched);
return;
}
onSubmit(values);
};
},
[values, validate]
);
return (
<FormContext.Provider
value={{ values, errors, touched, setValue, setTouched, registerField, isValid, handleSubmit }}
>
{children}
</FormContext.Provider>
);
}
function Field({ children }: { children: React.ReactNode }) {
return <div style={{ marginBottom: "16px" }}>{children}</div>;
}
function Input({
name,
label,
type = "text",
validators = [],
}: {
name: string;
label: string;
type?: string;
validators?: Validator[];
}) {
const { values, errors, touched, setValue, setTouched, registerField } = useFormContext();
useEffect(() => {
registerField(name, validators);
}, [name, registerField, validators]);
return (
<div>
<label>{label}</label>
<input
type={type}
value={values[name] || ""}
onChange={(e) => setValue(name, e.target.value)}
onBlur={() => setTouched(name)}
/>
{touched[name] && errors[name] && (
<p style={{ color: "red", fontSize: "12px" }}>{errors[name]}</p>
)}
</div>
);
}
function Submit({ children }: { children: React.ReactNode }) {
const { isValid } = useFormContext();
return (
<button type="submit" disabled={!isValid}>
{children}
</button>
);
}
function FormError() {
const { errors, touched } = useFormContext();
const visibleErrors = Object.entries(errors).filter(([key]) => touched[key]);
if (visibleErrors.length === 0) return null;
return (
<div style={{ color: "red", padding: "8px", border: "1px solid red", borderRadius: "4px" }}>
<p>יש לתקן את השגיאות הבאות:</p>
<ul>
{visibleErrors.map(([, error]) => (
<li key={error}>{error}</li>
))}
</ul>
</div>
);
}
Form.Field = Field;
Form.Input = Input;
Form.Submit = Submit;
Form.Error = FormError;
// ולידטורים
const required = (msg = "שדה חובה"): Validator => (value) => (value ? null : msg);
const minLength = (min: number): Validator => (value) =>
value.length >= min ? null : `לפחות ${min} תווים`;
const email: Validator = (value) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? null : "אימייל לא תקין";
// שימוש
function CreateUserForm() {
return (
<Form initialValues={{ name: "", email: "", role: "" }}>
<form>
<h2>יצירת משתמש</h2>
<Form.Error />
<Form.Field>
<Form.Input name="name" label="שם" validators={[required(), minLength(2)]} />
</Form.Field>
<Form.Field>
<Form.Input name="email" label="אימייל" type="email" validators={[required(), email]} />
</Form.Field>
<Form.Submit>צור משתמש</Form.Submit>
</form>
</Form>
);
}
הסبר:
- Form מנהל את כל ה-state המרכזי (values, errors, touched, validators)
- כל Input נרשם עם ה-validators שלו דרך registerField
- ולידציה רצה על כל שינוי ועל submit
- Form.Error מציג סיכום שגיאות
פתרון תרגיל 6 - רשימה וירטואלית¶
import { useState, useRef, useEffect, useMemo, useCallback } from "react";
interface VirtualListProps {
items: { id: number; text: string; height: number }[];
containerHeight: number;
overscan?: number;
}
function VirtualList({ items, containerHeight, overscan = 5 }: VirtualListProps) {
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
// חישוב מיקומים
const itemPositions = useMemo(() => {
const positions: { top: number; height: number }[] = [];
let currentTop = 0;
for (const item of items) {
positions.push({ top: currentTop, height: item.height });
currentTop += item.height;
}
return positions;
}, [items]);
const totalHeight = useMemo(() => {
if (itemPositions.length === 0) return 0;
const last = itemPositions[itemPositions.length - 1];
return last.top + last.height;
}, [itemPositions]);
// מצא פריטים נראים
const visibleItems = useMemo(() => {
const start = scrollTop;
const end = scrollTop + containerHeight;
let startIndex = 0;
for (let i = 0; i < itemPositions.length; i++) {
if (itemPositions[i].top + itemPositions[i].height > start) {
startIndex = i;
break;
}
}
let endIndex = startIndex;
for (let i = startIndex; i < itemPositions.length; i++) {
if (itemPositions[i].top > end) {
endIndex = i;
break;
}
endIndex = i + 1;
}
// buffer נוסף
startIndex = Math.max(0, startIndex - overscan);
endIndex = Math.min(items.length, endIndex + overscan);
return { startIndex, endIndex };
}, [scrollTop, containerHeight, itemPositions, items.length, overscan]);
const handleScroll = useCallback(() => {
if (containerRef.current) {
setScrollTop(containerRef.current.scrollTop);
}
}, []);
const renderedCount = visibleItems.endIndex - visibleItems.startIndex;
return (
<div>
<p>
מרונדר {renderedCount} מתוך {items.length} פריטים
</p>
<div
ref={containerRef}
onScroll={handleScroll}
style={{
height: containerHeight,
overflow: "auto",
border: "1px solid #ddd",
}}
>
<div style={{ height: totalHeight, position: "relative" }}>
{items.slice(visibleItems.startIndex, visibleItems.endIndex).map((item, i) => {
const index = visibleItems.startIndex + i;
const pos = itemPositions[index];
return (
<div
key={item.id}
style={{
position: "absolute",
top: pos.top,
height: pos.height,
width: "100%",
boxSizing: "border-box",
padding: "8px",
borderBottom: "1px solid #eee",
backgroundColor: index % 2 === 0 ? "#f9f9f9" : "white",
}}
>
{item.text}
</div>
);
})}
</div>
</div>
</div>
);
}
// שימוש
function App() {
const items = useMemo(
() =>
Array.from({ length: 10000 }, (_, i) => ({
id: i,
text: `פריט ${i + 1} - ${Math.random().toString(36).slice(2, 8)}`,
height: 30 + Math.floor(Math.random() * 30), // גבהים שונים 30-60px
})),
[]
);
return (
<div>
<h2>רשימה וירטואלית - 10,000 פריטים</h2>
<VirtualList items={items} containerHeight={400} overscan={5} />
</div>
);
}
הסבר:
- מחשבים מראש את מיקום (top) וגובה של כל פריט
- לפי scrollTop, מוצאים אילו פריטים נראים על המסך
- מרנדרים רק את הפריטים הנראים + buffer (overscan)
- כל פריט ממוקם ב-position: absolute לפי ה-top שלו
- div חיצוני עם גובה כולל יוצר את אפקט הגלילה
תשובות לשאלות¶
-
יתרון Compound Components: הם מספקים API גמיש - המשתמש שולט במבנה ובסדר של הקומפוננטות. לעומת זאת, קומפוננטה אחת עם הרבה props נהיית קשה לתחזוקה ("mega component"). Compound Components גם מאפשרים לדלג על חלקים לא רלוונטיים ולהוסיף תוכן מותאם בין החלקים.
-
Render Props מול הוקים: Render Props עדיין שימושי כשהלוגיקה קשורה ל-rendering עצמו (למשל, קומפוננטה שמודדת גודל ילד ומעבירה אותו), כשצריכים שליטה על מה שמרונדר מההורה (composition), או בספריות שרוצות לתמוך גם בגרסאות ישנות של ריאקט.
-
React.memo מול useMemo: React.memo עוטף קומפוננטה שלמה ומונע רנדר מחדש שלה. useMemo שומר תוצאת חישוב בתוך קומפוננטה. React.memo = "אל תרנדר את הקומפוננטה הזו מחדש". useMemo = "אל תחשב את הערך הזה מחדש".
-
השוואת Props ב-memo: React.memo משתמש בהשוואה רדודה (shallow comparison) כברירת מחדל. זה אומר שאובייקטים ומערכים חדשים (גם עם אותו תוכן) נחשבים שונים. אפשר להעביר פונקציית השוואה מותאמת כפרמטר שני.
-
Trade-off של virtualization: יתרונות: ביצועים טובים עם רשימות ארוכות. חסרונות: מורכבות במימוש, בעיות עם גלילה חלקה, חיפוש בדפדפן (Ctrl+F) לא עובד על פריטים שלא מרונדרים, נגישות מורכבת יותר, ו-SEO בעייתי. לא כדאי להשתמש עם פחות מ-100-200 פריטים כי ה-overhead לא מצדיק.