Zum Hauptinhalt springen

[Lv1] Bildladungsoptimierung: Vier Ebenen des Lazy Load

Durch eine vierstufige Lazy-Loading-Strategie für Bilder wurde der Bilder-Traffic der ersten Ansicht von 60 MB auf 2 MB reduziert, mit einer Ladezeitverbesserung von 85%.


Problemhintergrund (Situation)

Stellen Sie sich vor, Sie scrollen auf dem Handy durch eine Webseite. Der Bildschirm zeigt nur 10 Bilder, aber die Seite lädt alle 500 Bilder komplett. Ihr Handy würde einfrieren und das Datenvolumen würde sofort 50 MB verbrauchen.

Tatsächliche Projektsituation:

Statistiken einer Startseite
- 300+ Thumbnails (je 150-300 KB)
- 50+ Werbebanner
- Wenn alles geladen wird: 300 x 200 KB = 60 MB+ Bilddaten

Tatsächliche Probleme
- Erste Ansicht zeigt nur 8-12 Bilder
- Benutzer scrollt möglicherweise nur bis Bild 30 und verlässt die Seite
- Restliche 270 Bilder werden völlig umsonst geladen (Traffic-Verschwendung + Verlangsamung)

Auswirkungen
- Erstladezeit: 15-20 Sekunden
- Datenverbrauch: 60 MB+ (Nutzerbeschwerden)
- Seitenruckeln: Scrollen nicht flüssig
- Absprungrate: 42% (sehr hoch)

Optimierungsziel (Task)

  1. Nur Bilder im sichtbaren Bereich laden
  2. Bilder vorladen, die kurz vor dem Viewport stehen (50 px vorher mit dem Laden beginnen)
  3. Parallelität kontrollieren (vermeiden, dass zu viele Bilder gleichzeitig geladen werden)
  4. Ressourcenverschwendung durch schnelles Wechseln verhindern
  5. Bilder-Traffic der ersten Ansicht < 3 MB

Lösung (Action)

v-lazy-load.ts Implementierung

Vier Ebenen des Image Lazy Load

Erste Ebene: Viewport-Sichtbarkeitserkennung (IntersectionObserver)

// Observer erstellen, der überwacht, ob ein Bild den Viewport betritt
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// Bild ist im sichtbaren Bereich
// Laden des Bildes starten
}
});
},
{
rootMargin: '50px 0px', // 50 px vorher mit dem Laden beginnen (Vorladen)
threshold: 0.1, // Auslösen, sobald 10% sichtbar sind
}
);
  • Verwendet die native IntersectionObserver API des Browsers (Performance weit überlegen gegenüber Scroll-Events)
  • rootMargin: "50px" -> Wenn das Bild noch 50 px unterhalb ist, beginnt bereits das Laden; wenn der Benutzer dort ankommt, ist es bereits fertig (fühlbar flüssiger)
  • Bilder außerhalb des Viewports werden nicht geladen

Zweite Ebene: Parallelitätskontrollmechanismus (Queue-Management)

class LazyLoadQueue {
private loadingCount = 0
private maxConcurrent = 6 // Maximal 6 Bilder gleichzeitig laden
private queue: (() => void)[] = []

enqueue(loadFn: () => void) {
if (this.loadingCount < this.maxConcurrent) {
this.executeLoad(loadFn) // Platz frei, sofort laden
} else {
this.queue.push(loadFn) // Kein Platz, in Warteschlange einreihen
}
}
}
  • Auch wenn 20 Bilder gleichzeitig den Viewport betreten, werden nur 6 gleichzeitig geladen
  • Vermeidet "Kaskaden-Laden", das den Browser blockiert (Chrome erlaubt standardmäßig maximal 6 gleichzeitige Anfragen)
  • Nach Abschluss wird automatisch das nächste aus der Warteschlange verarbeitet
Benutzer scrollt schnell zum Ende -> 30 Bilder gleichzeitig ausgelöst
Ohne Queue-Management: 30 Anfragen gleichzeitig -> Browser friert ein
Mit Queue-Management: Erste 6 laden -> nach Abschluss nächste 6 -> flüssig

Dritte Ebene: Lösung des Ressourcen-Race-Condition-Problems (Versionskontrolle)

// Versionsnummer beim Laden setzen
el.setAttribute('data-version', Date.now().toString());

// Version nach dem Laden überprüfen
img.onload = () => {
const currentVersion = img.getAttribute('data-version');
if (loadVersion === currentVersion) {
// Version stimmt überein, Bild anzeigen
} else {
// Version stimmt nicht überein, Benutzer hat bereits gewechselt, nicht anzeigen
}
};

Praxisbeispiel:

Benutzeraktionen:

1. Klickt auf Kategorie "Nachrichten" -> Laden von 100 Bildern ausgelöst (Version 1001)
2. 0,5 Sekunden später klickt auf "Aktionen" -> Laden von 80 Bildern ausgelöst (Version 1002)
3. Nachrichtenbilder sind erst 1 Sekunde später fertig geladen

Ohne Versionskontrolle: Nachrichtenbilder werden angezeigt (falsch!)
Mit Versionskontrolle: Versionsprüfung ergibt Unstimmigkeit, Nachrichtenbilder werden verworfen (korrekt!)

Vierte Ebene: Platzhalter-Strategie (Base64 transparentes Bild)

// Standardmäßig 1x1 transparentes SVG anzeigen, um Layout-Verschiebung zu vermeiden
el.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIi...';

// Echte Bild-URL in data-src speichern
el.setAttribute('data-src', realImageUrl);
  • Verwendet Base64-kodiertes transparentes SVG (nur 100 Bytes)
  • Vermeidet CLS (Cumulative Layout Shift)
  • Benutzer sieht kein "plötzlich erscheinendes Bild"

Optimierungsergebnis (Result)

Vor der Optimierung:

Bilder der ersten Ansicht: Alle 300 Bilder auf einmal (60 MB)
Ladezeit: 15-20 Sekunden
Scroll-Flüssigkeit: starkes Ruckeln
Absprungrate: 42%

Nach der Optimierung:

Bilder der ersten Ansicht: Nur 8-12 Bilder (2 MB) -97%
Ladezeit: 2-3 Sekunden +85%
Scroll-Flüssigkeit: flüssig (60 fps)
Absprungrate: 28% -33%

Konkrete Daten:

  • Bilder-Traffic der ersten Ansicht: 60 MB -> 2 MB (Reduktion um 97%)
  • Bildladezeit: 15 Sekunden -> 2 Sekunden (Verbesserung um 85%)
  • Seiten-Scroll-FPS: Von 20-30 auf 55-60
  • Speicherverbrauch: Reduktion um 65% (nicht geladene Bilder belegen keinen Speicher)

Technische Indikatoren:

  • IntersectionObserver Performance: weit überlegen gegenüber traditionellem Scroll-Event (CPU-Nutzung um 80% reduziert)
  • Effekt der Parallelitätskontrolle: Verhindert Anfragenblockierung des Browsers
  • Trefferquote der Versionskontrolle: 99,5% (äußerst selten falsche Bilder)

Interview-Schwerpunkte

Häufige Erweiterungsfragen:

  1. F: Warum nicht einfach das loading="lazy"-Attribut verwenden? A: Das native loading="lazy" hat einige Einschränkungen:

    • Keine Kontrolle über die Vorladeentfernung (Browser entscheidet)
    • Keine Kontrolle über die Parallelitätsmenge
    • Kein Umgang mit Versionskontrolle (Problem bei schnellem Wechsel)
    • Ältere Browser unterstützen es nicht

    Eine benutzerdefinierte Directive bietet feinere Kontrolle, geeignet für unsere komplexen Szenarien.

  2. F: Was ist am IntersectionObserver besser als an Scroll-Events? A:

    // Traditionelles Scroll-Event
    window.addEventListener('scroll', () => {
    // Wird bei jedem Scrollen ausgelöst (60 Mal/Sekunde)
    // Muss Elementposition berechnen (getBoundingClientRect)
    // Kann erzwungenen Reflow verursachen (Performance-Killer)
    });

    // IntersectionObserver
    const observer = new IntersectionObserver(callback);
    // Wird nur ausgelöst, wenn Element den Viewport betritt/verlässt
    // Native Browser-Optimierung, blockiert nicht den Hauptthread
    // 80% Performance-Verbesserung
  3. F: Woher kommt das 6-Bilder-Limit bei der Parallelitätskontrolle? A: Basiert auf dem HTTP/1.1 Same-Origin-Parallelitätslimit des Browsers:

    • Chrome/Firefox: Maximal 6 gleichzeitige Verbindungen pro Domain
    • Anfragen über dem Limit werden in die Warteschlange gestellt
    • HTTP/2 erlaubt mehr, aber aus Kompatibilitätsgründen halten wir bei 6
    • Reale Tests: 6 gleichzeitige Bilder sind das optimale Gleichgewicht zwischen Performance und Erlebnis
  4. F: Warum Timestamp statt UUID für die Versionskontrolle? A:

    • Timestamp: Date.now() (einfach, ausreichend, sortierbar)
    • UUID: crypto.randomUUID() (strenger, aber Over-Engineering)
    • Unser Szenario: Timestamp ist ausreichend eindeutig (Millisekundenebene)
    • Performance-Überlegung: Timestamp-Generierung ist schneller
  5. F: Wie wird mit fehlgeschlagenem Bildladen umgegangen? A: Mehrstufiges Fallback implementiert:

    img.onerror = () => {
    if (retryCount < 3) {
    // 1. 3 Mal wiederholen
    setTimeout(() => reload(), 1000 * retryCount);
    } else {
    // 2. Standardbild anzeigen
    img.src = '/images/game-placeholder.png';
    }
    };
  6. F: Gibt es CLS-Probleme (Cumulative Layout Shift)? A: Drei Strategien zur Vermeidung:

    <!-- 1. Standard-Platzhalter-SVG -->
    <img src="data:image/svg+xml..." />

    <!-- 2. CSS aspect-ratio für festes Verhältnis -->
    <img style="aspect-ratio: 16/9;" />

    <!-- 3. Skeleton Screen -->
    <div class="skeleton-box"></div>

    Endgültiger CLS-Score: < 0,1 (ausgezeichnet)