לדלג לתוכן

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 פריטים.