11.7 מונוריפו וביצועים מתקדמים פתרון
פתרון - מונוריפו וביצועים מתקדמים¶
פתרון תרגיל 1 - הקמת מונוריפו עם Turborepo¶
// package.json (root)
{
"name": "my-monorepo",
"private": true,
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test",
"type-check": "turbo run type-check",
"format": "prettier --write \"**/*.{ts,tsx,json,md}\""
},
"devDependencies": {
"prettier": "^3.2.0",
"turbo": "^2.0.0"
}
}
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["build"]
},
"type-check": {
"dependsOn": ["^build"]
}
}
}
// packages/ui/package.json
{
"name": "@myapp/ui",
"version": "0.1.0",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"build": "tsc",
"lint": "eslint src/",
"type-check": "tsc --noEmit"
},
"devDependencies": {
"@myapp/config": "workspace:*",
"typescript": "^5.4.0"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
}
// packages/ui/src/index.ts
export { Button, type ButtonProps } from './Button';
export { Input, type InputProps } from './Input';
export { Card, type CardProps } from './Card';
export { Modal, type ModalProps } from './Modal';
// packages/ui/src/Button.tsx
import { type ButtonHTMLAttributes } from 'react';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md' | 'lg';
}
export function Button({ variant = 'primary', size = 'md', children, ...props }: ButtonProps) {
return (
<button className={`btn btn-${variant} btn-${size}`} {...props}>
{children}
</button>
);
}
// packages/utils/src/index.ts
export { formatDate, formatRelativeTime } from './date';
export { formatCurrency, formatNumber } from './number';
export { cn } from './cn';
// packages/utils/src/cn.ts
export function cn(...classes: (string | undefined | false | null)[]): string {
return classes.filter(Boolean).join(' ');
}
// packages/utils/src/date.ts
export function formatDate(date: Date, locale = 'he-IL'): string {
return new Intl.DateTimeFormat(locale, { dateStyle: 'long' }).format(date);
}
export function formatRelativeTime(date: Date, locale = 'he-IL'): string {
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
const diffMs = date.getTime() - Date.now();
const diffMin = Math.round(diffMs / 60000);
if (Math.abs(diffMin) < 60) return rtf.format(diffMin, 'minute');
const diffHour = Math.round(diffMin / 60);
if (Math.abs(diffHour) < 24) return rtf.format(diffHour, 'hour');
return rtf.format(Math.round(diffHour / 24), 'day');
}
// packages/config/tsconfig/base.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}
פתרון תרגיל 2 - Web Worker לעיבוד נתונים¶
// workers/dataProcessor.worker.ts
interface DataItem {
id: number;
name: string;
category: string;
value: number;
}
type WorkerMessage =
| { type: 'sort'; data: DataItem[]; sortBy: keyof DataItem; order: 'asc' | 'desc' }
| { type: 'filter'; data: DataItem[]; searchTerm: string }
| { type: 'stats'; data: DataItem[] }
| { type: 'group'; data: DataItem[]; groupBy: keyof DataItem };
self.addEventListener('message', (event: MessageEvent<WorkerMessage>) => {
const msg = event.data;
switch (msg.type) {
case 'sort': {
const sorted = [...msg.data].sort((a, b) => {
const valA = a[msg.sortBy];
const valB = b[msg.sortBy];
const comparison = valA > valB ? 1 : valA < valB ? -1 : 0;
return msg.order === 'asc' ? comparison : -comparison;
});
self.postMessage({ type: 'sort', result: sorted });
break;
}
case 'filter': {
const term = msg.searchTerm.toLowerCase();
const filtered = msg.data.filter(
(item) =>
item.name.toLowerCase().includes(term) ||
item.category.toLowerCase().includes(term)
);
self.postMessage({ type: 'filter', result: filtered });
break;
}
case 'stats': {
const values = msg.data.map((item) => item.value);
const sorted = [...values].sort((a, b) => a - b);
const sum = values.reduce((a, b) => a + b, 0);
self.postMessage({
type: 'stats',
result: {
count: values.length,
average: sum / values.length,
median: sorted[Math.floor(sorted.length / 2)],
min: sorted[0],
max: sorted[sorted.length - 1],
sum,
},
});
break;
}
case 'group': {
const groups: Record<string, DataItem[]> = {};
for (const item of msg.data) {
const key = String(item[msg.groupBy]);
if (!groups[key]) groups[key] = [];
groups[key].push(item);
}
self.postMessage({ type: 'group', result: groups });
break;
}
}
});
// hooks/useDataProcessor.ts
import { useEffect, useRef, useState, useCallback } from 'react';
interface DataItem {
id: number;
name: string;
category: string;
value: number;
}
interface Stats {
count: number;
average: number;
median: number;
min: number;
max: number;
sum: number;
}
function useDataProcessor(data: DataItem[]) {
const workerRef = useRef<Worker | null>(null);
const [processedData, setProcessedData] = useState<DataItem[]>(data);
const [stats, setStats] = useState<Stats | null>(null);
const [groups, setGroups] = useState<Record<string, DataItem[]> | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
useEffect(() => {
const worker = new Worker(
new URL('../workers/dataProcessor.worker.ts', import.meta.url)
);
worker.addEventListener('message', (event) => {
const { type, result } = event.data;
setIsProcessing(false);
switch (type) {
case 'sort':
case 'filter':
setProcessedData(result);
break;
case 'stats':
setStats(result);
break;
case 'group':
setGroups(result);
break;
}
});
workerRef.current = worker;
return () => worker.terminate();
}, []);
const sort = useCallback((sortBy: keyof DataItem, order: 'asc' | 'desc') => {
setIsProcessing(true);
workerRef.current?.postMessage({ type: 'sort', data, sortBy, order });
}, [data]);
const filter = useCallback((searchTerm: string) => {
setIsProcessing(true);
workerRef.current?.postMessage({ type: 'filter', data, searchTerm });
}, [data]);
const calculateStats = useCallback(() => {
setIsProcessing(true);
workerRef.current?.postMessage({ type: 'stats', data });
}, [data]);
const groupBy = useCallback((field: keyof DataItem) => {
setIsProcessing(true);
workerRef.current?.postMessage({ type: 'group', data, groupBy: field });
}, [data]);
return { processedData, stats, groups, isProcessing, sort, filter, calculateStats, groupBy };
}
export default useDataProcessor;
פתרון תרגיל 3 - רשימה וירטואלית מתקדמת¶
'use client';
import { useRef, useState, useMemo, useCallback } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
interface Contact {
id: string;
name: string;
email: string;
phone: string;
avatar: string;
address?: string;
}
function VirtualContactList({ contacts }: { contacts: Contact[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const filteredContacts = useMemo(() => {
if (!search) return contacts;
const term = search.toLowerCase();
return contacts.filter(
(c) =>
c.name.toLowerCase().includes(term) ||
c.email.toLowerCase().includes(term)
);
}, [contacts, search]);
const virtualizer = useVirtualizer({
count: filteredContacts.length,
getScrollElement: () => parentRef.current,
estimateSize: (index) =>
filteredContacts[index].address ? 100 : 70,
overscan: 10,
});
const toggleSelect = useCallback((id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
const scrollToTop = () => virtualizer.scrollToIndex(0, { behavior: 'smooth' });
const scrollToBottom = () => virtualizer.scrollToIndex(filteredContacts.length - 1, { behavior: 'smooth' });
return (
<div>
{/* חיפוש */}
<div style={{ padding: '12px', borderBottom: '1px solid #eee' }}>
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="חיפוש אנשי קשר..."
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #ccc' }}
/>
<p style={{ margin: '8px 0 0', fontSize: '0.85em', color: '#666' }}>
מוצגים {filteredContacts.length} מתוך {contacts.length} | נבחרו {selected.size}
</p>
</div>
{/* כפתורי גלילה */}
<div style={{ display: 'flex', gap: '8px', padding: '8px' }}>
<button onClick={scrollToTop}>לראש</button>
<button onClick={scrollToBottom}>לסוף</button>
</div>
{/* רשימה וירטואלית */}
<div ref={parentRef} style={{ height: '500px', overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const contact = filteredContacts[virtualItem.index];
const isSelected = selected.has(contact.id);
return (
<div
key={contact.id}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
padding: '12px 16px',
borderBottom: '1px solid #f0f0f0',
backgroundColor: isSelected ? '#e3f2fd' : 'white',
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSelect(contact.id)}
aria-label={`בחר ${contact.name}`}
/>
<div
style={{
width: '40px',
height: '40px',
borderRadius: '50%',
backgroundColor: '#ddd',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{contact.name[0]}
</div>
<div style={{ flex: 1 }}>
<strong>{contact.name}</strong>
<div style={{ fontSize: '0.85em', color: '#666' }}>
{contact.email} | {contact.phone}
</div>
{contact.address && (
<div style={{ fontSize: '0.8em', color: '#999', marginTop: '2px' }}>
{contact.address}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
export default VirtualContactList;
פתרון תרגיל 4 - Intersection Observer מתקדם¶
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
function useIntersectionObserver(options?: IntersectionObserverInit) {
const [isVisible, setIsVisible] = useState(false);
const [hasBeenVisible, setHasBeenVisible] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new IntersectionObserver(([entry]) => {
setIsVisible(entry.isIntersecting);
if (entry.isIntersecting) setHasBeenVisible(true);
}, options);
observer.observe(element);
return () => observer.disconnect();
}, [options]);
return { ref, isVisible, hasBeenVisible };
}
interface ImageData {
id: string;
url: string;
alt: string;
}
function LazyImage({ src, alt }: { src: string; alt: string }) {
const { ref, hasBeenVisible } = useIntersectionObserver({
rootMargin: '200px',
threshold: 0,
});
const [loaded, setLoaded] = useState(false);
return (
<div ref={ref} style={{ minHeight: '250px', position: 'relative' }}>
{hasBeenVisible ? (
<img
src={src}
alt={alt}
onLoad={() => setLoaded(true)}
style={{
width: '100%',
height: '250px',
objectFit: 'cover',
borderRadius: '8px',
opacity: loaded ? 1 : 0,
transition: 'opacity 0.5s ease',
}}
/>
) : (
<div style={{ width: '100%', height: '250px', backgroundColor: '#f0f0f0', borderRadius: '8px' }} />
)}
</div>
);
}
function ImageGallery() {
const [images, setImages] = useState<ImageData[]>([]);
const [loadedCount, setLoadedCount] = useState(0);
const [page, setPage] = useState(1);
const [showBackToTop, setShowBackToTop] = useState(false);
// אינדיקטור גלילה
const { ref: scrollIndicator, isVisible: isBottom } = useIntersectionObserver({
threshold: 0.5,
});
// כפתור חזרה למעלה
const { ref: topRef, isVisible: isAtTop } = useIntersectionObserver();
useEffect(() => {
setShowBackToTop(!isAtTop);
}, [isAtTop]);
// טעינת תמונות ראשונית
useEffect(() => {
loadMoreImages();
}, []);
// Infinite scroll
useEffect(() => {
if (isBottom) loadMoreImages();
}, [isBottom]);
const loadMoreImages = useCallback(() => {
const newImages: ImageData[] = Array.from({ length: 12 }, (_, i) => ({
id: `img-${page}-${i}`,
url: `https://picsum.photos/400/250?random=${page * 12 + i}`,
alt: `תמונה ${page * 12 + i + 1}`,
}));
setImages((prev) => [...prev, ...newImages]);
setLoadedCount((prev) => prev + 12);
setPage((prev) => prev + 1);
}, [page]);
return (
<div>
<div ref={topRef} />
<h2>גלריית תמונות</h2>
<p>נטענו {loadedCount} תמונות</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '16px' }}>
{images.map((img) => (
<LazyImage key={img.id} src={img.url} alt={img.alt} />
))}
</div>
{/* אינדיקטור infinite scroll */}
<div ref={scrollIndicator} style={{ height: '50px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<p>טוען עוד...</p>
</div>
{/* כפתור חזרה למעלה */}
{showBackToTop && (
<button
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
style={{
position: 'fixed',
bottom: '20px',
insetInlineEnd: '20px',
padding: '12px 16px',
borderRadius: '50%',
border: 'none',
backgroundColor: '#333',
color: 'white',
cursor: 'pointer',
fontSize: '1.2em',
}}
aria-label="חזרה למעלה"
>
^
</button>
)}
</div>
);
}
export default ImageGallery;
פתרון תרגיל 5 - אסטרטגיות Prefetching¶
// hooks/usePrefetch.ts
import { useCallback, useRef } from 'react';
import { useRouter } from 'next/navigation';
function usePrefetch() {
const router = useRouter();
const prefetchedRoutes = useRef(new Set<string>());
const prefetchRoute = useCallback(
(path: string) => {
if (prefetchedRoutes.current.has(path)) return;
router.prefetch(path);
prefetchedRoutes.current.add(path);
},
[router]
);
const preloadImage = useCallback((src: string) => {
const link = document.createElement('link');
link.rel = 'preload';
link.as = 'image';
link.href = src;
document.head.appendChild(link);
}, []);
const preconnect = useCallback((url: string) => {
const existing = document.querySelector(`link[rel="preconnect"][href="${url}"]`);
if (existing) return;
const link = document.createElement('link');
link.rel = 'preconnect';
link.href = url;
link.crossOrigin = 'anonymous';
document.head.appendChild(link);
}, []);
return { prefetchRoute, preloadImage, preconnect };
}
export default usePrefetch;
// components/ProductCard.tsx
'use client';
import { useCallback } from 'react';
import usePrefetch from '@/hooks/usePrefetch';
interface Product {
id: string;
name: string;
image: string;
price: number;
}
function ProductCard({ product }: { product: Product }) {
const { prefetchRoute, preloadImage } = usePrefetch();
const handleMouseEnter = useCallback(() => {
prefetchRoute(`/product/${product.id}`);
preloadImage(product.image);
}, [product.id, product.image, prefetchRoute, preloadImage]);
return (
<a
href={`/product/${product.id}`}
onMouseEnter={handleMouseEnter}
onFocus={handleMouseEnter}
style={{ display: 'block', textDecoration: 'none', color: 'inherit' }}
>
<div style={{ border: '1px solid #eee', borderRadius: '8px', overflow: 'hidden' }}>
<img src={product.image} alt={product.name} loading="lazy" style={{ width: '100%' }} />
<div style={{ padding: '12px' }}>
<h3>{product.name}</h3>
<p>{product.price} ש"ח</p>
</div>
</div>
</a>
);
}
export default ProductCard;
// app/layout.tsx - הגדרות preconnect ו-dns-prefetch
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="he" dir="rtl">
<head>
<link rel="preconnect" href="https://api.myapp.com" />
<link rel="preconnect" href="https://cdn.myapp.com" crossOrigin="anonymous" />
<link rel="dns-prefetch" href="https://fonts.googleapis.com" />
<link rel="dns-prefetch" href="https://analytics.myapp.com" />
<link rel="preload" href="/fonts/heebo.woff2" as="font" type="font/woff2" crossOrigin="anonymous" />
{/* סקריפטים לא קריטיים - defer */}
<script src="https://analytics.myapp.com/script.js" defer />
</head>
<body>{children}</body>
</html>
);
}
פתרון תרגיל 6 - דשבורד ביצועים¶
'use client';
import { useState, useEffect } from 'react';
interface PerformanceMetrics {
fcp: number | null;
lcp: number | null;
cls: number | null;
fid: number | null;
ttfb: number | null;
pageLoad: number | null;
}
function usePerformanceMetrics() {
const [metrics, setMetrics] = useState<PerformanceMetrics>({
fcp: null, lcp: null, cls: null, fid: null, ttfb: null, pageLoad: null,
});
useEffect(() => {
// FCP
const fcpObserver = new PerformanceObserver((list) => {
const entry = list.getEntries().find((e) => e.name === 'first-contentful-paint');
if (entry) setMetrics((prev) => ({ ...prev, fcp: Math.round(entry.startTime) }));
});
fcpObserver.observe({ type: 'paint', buffered: true });
// LCP
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
setMetrics((prev) => ({ ...prev, lcp: Math.round(lastEntry.startTime) }));
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
// CLS
let clsValue = 0;
const clsObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries() as any[]) {
if (!entry.hadRecentInput) {
clsValue += entry.value;
}
}
setMetrics((prev) => ({ ...prev, cls: Math.round(clsValue * 1000) / 1000 }));
});
clsObserver.observe({ type: 'layout-shift', buffered: true });
// FID
const fidObserver = new PerformanceObserver((list) => {
const entry = list.getEntries()[0] as any;
setMetrics((prev) => ({ ...prev, fid: Math.round(entry.processingStart - entry.startTime) }));
});
fidObserver.observe({ type: 'first-input', buffered: true });
// Navigation timing
window.addEventListener('load', () => {
setTimeout(() => {
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
if (nav) {
setMetrics((prev) => ({
...prev,
ttfb: Math.round(nav.responseStart - nav.requestStart),
pageLoad: Math.round(nav.loadEventEnd - nav.startTime),
}));
}
}, 0);
});
return () => {
fcpObserver.disconnect();
lcpObserver.disconnect();
clsObserver.disconnect();
fidObserver.disconnect();
};
}, []);
return metrics;
}
function PerformanceDashboard() {
const metrics = usePerformanceMetrics();
const getColor = (value: number | null, good: number, poor: number) => {
if (value === null) return '#999';
if (value <= good) return '#0d7c3e';
if (value <= poor) return '#ff9800';
return '#c31432';
};
const metricCards = [
{ label: 'FCP', value: metrics.fcp, unit: 'ms', good: 1800, poor: 3000 },
{ label: 'LCP', value: metrics.lcp, unit: 'ms', good: 2500, poor: 4000 },
{ label: 'CLS', value: metrics.cls, unit: '', good: 0.1, poor: 0.25 },
{ label: 'FID', value: metrics.fid, unit: 'ms', good: 100, poor: 300 },
{ label: 'TTFB', value: metrics.ttfb, unit: 'ms', good: 800, poor: 1800 },
{ label: 'זמן טעינה', value: metrics.pageLoad, unit: 'ms', good: 3000, poor: 5000 },
];
return (
<div style={{ padding: '24px' }}>
<h2>דשבורד ביצועים</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
{metricCards.map((metric) => (
<div
key={metric.label}
style={{
padding: '20px',
borderRadius: '8px',
border: '1px solid #eee',
textAlign: 'center',
}}
>
<h3 style={{ margin: '0 0 8px', fontSize: '0.9em', color: '#666' }}>
{metric.label}
</h3>
<p
style={{
fontSize: '2em',
fontWeight: 'bold',
margin: 0,
color: getColor(metric.value, metric.good, metric.poor),
}}
>
{metric.value !== null ? `${metric.value}${metric.unit}` : '...'}
</p>
</div>
))}
</div>
</div>
);
}
export default PerformanceDashboard;
תשובות לשאלות¶
1. יתרונות וחסרונות מונוריפו:
יתרונות: שיתוף קוד פשוט, ניהול תלויות מרכזי, שינויים אטומיים על פני מספר פרויקטים, CI/CD מאוחד, סטנדרטיזציה. חסרונות: repo גדול שלוקח זמן לשכפל, CI עלול לרוץ על פרויקטים שלא השתנו (Turborepo פותר את זה), צוותים שונים עלולים להפריע אחד לשני, מורכבות בניהול הרשאות.
2. dependsOn ב-turbo.json:
dependsOn: ["^build"] - הסימן ^ אומר "הרץ build על כל ה-packages שאני תלוי בהם". אם apps/web תלוי ב-packages/ui, אז build של web ימתין ש-build של ui יסתיים. dependsOn: ["build"] (ללא ^) אומר "הרץ build על אותו package קודם" - כלומר תלות פנימית בתוך אותו package.
3. Web Workers - מתי כן ומתי לא:
כדאי כאשר: חישוב שלוקח מעל 50ms (עיבוד נתונים, הצפנה, פענוח JSON גדול), מיון מערכים ענקיים. לא כדאי כאשר: פעולות DOM (Workers לא יכולים לגשת ל-DOM), חישובים קצרים (ה-overhead של יצירת Worker גדול מהחיסכון), תקשורת עם הרבה נתונים (סריאליזציה יקרה).
4. preload, prefetch ו-preconnect:
- preload - טוען משאב שנחוץ בדף הנוכחי, בעדיפות גבוהה. שימוש: פונט קריטי, תמונת hero.
<link rel="preload" href="font.woff2" as="font"> - prefetch - טוען משאב שייתכן שיהיה נחוץ בניווט הבא, בעדיפות נמוכה. שימוש: דף הבא שהמשתמש כנראה ילך אליו.
<link rel="prefetch" href="/next-page"> - preconnect - פותח חיבור (DNS, TCP, TLS) לשרת מראש, ללא טעינת תוכן. שימוש: APIs, CDNs שנדע שנפנה אליהם.
<link rel="preconnect" href="https://api.example.com">
5. גלילה וירטואלית:
גלילה וירטואלית חשובה כי רינדור אלפי אלמנטים ל-DOM גורם לזיכרון רב ולביצועים גרועים. הטכניקה: במקום ליצור אלמנט DOM לכל פריט ברשימה, נוצרים רק האלמנטים הנראים על המסך (בדרך כלל 10-20). כשהמשתמש גולל, האלמנטים מתעדכנים עם תוכן חדש. מיכל חיצוני עם גובה מלא (כאילו כל הפריטים קיימים) שומר על סרגל הגלילה. התוצאה: ביצועים חלקים גם עם 100,000 פריטים.