Lazy Loading von Bildern mit Vue.js-Direktiven und Intersection Observer

Avatar of Mateusz Rybczonek
Mateusz Rybczonek am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Wenn ich an Web-Performance denke, kommt mir als Erstes in den Sinn, wie Bilder im Allgemeinen die letzten Elemente sind, die auf einer Seite erscheinen. Heute können Bilder ein großes Problem für die Leistung darstellen, was bedauerlich ist, da die Geschwindigkeit, mit der eine Website geladen wird, direkten Einfluss darauf hat, ob Benutzer erfolgreich das tun, wofür sie auf die Seite gekommen sind (denken Sie an Konversionsraten).

Erst kürzlich hat Rahul Nanwani einen ausführlichen Leitfaden zum Lazy Loading von Bildern verfasst. Ich möchte das gleiche Thema behandeln, aber aus einem anderen Ansatz: mit Datenattributen, Intersection Observer und benutzerdefinierten Direktiven in Vue.js.

Dies wird uns im Grunde zwei Dinge lösen lassen

  1. Speichern Sie die src des Bildes, das wir laden möchten, ohne es überhaupt zu laden.
  2. Erkennen Sie, wann das Bild für den Benutzer sichtbar wird, und lösen Sie die Anforderung zum Laden des Bildes aus.

Das gleiche grundlegende Lazy-Loading-Konzept, aber eine andere Vorgehensweise.

Ich habe ein Beispiel erstellt, das auf einem Beispiel von Benjamin Taylor in seinem Blogbeitrag basiert. Es enthält eine Liste von zufälligen Artikeln, die jeweils eine kurze Beschreibung, ein Bild und einen Link zur Quelle des Artikels enthalten. Wir werden den Prozess der Erstellung einer Komponente durchgehen, die für die Anzeige dieser Liste zuständig ist, einen Artikel rendert und das Bild für einen bestimmten Artikel per Lazy Loading lädt.

Lassen Sie uns faul werden! Oder zumindest diese Komponente Stück für Stück zerlegen.

Schritt 1: Erstellen der ImageItem-Komponente in Vue

Lassen Sie uns zunächst eine Komponente erstellen, die ein Bild anzeigt (aber noch ohne Lazy Loading). Wir nennen diese Datei ImageItem.vue. Im Vorlagenbereich der Komponente verwenden wir ein figure-Tag, das unser Bild enthält – das Bild-Tag selbst erhält das src-Attribut, das auf die Quell-URL für die Bilddatei zeigt.

<template>
  <figure class="image__wrapper">
    <img
      class="image__item"
      :src="source"
      alt="random image"
    >
  </figure>
</template>

Im Skriptteil der Komponente erhalten wir die Prop source, die wir für die src-URL des angezeigten Bildes verwenden.

export default {
  name: "ImageItem",
  props: {
    source: {
      type: String,
      required: true
    }
  }
};

Das ist alles in Ordnung und wird das Bild wie gewohnt rendern. Wenn wir es hier belassen, wird das Bild sofort geladen, ohne auf das Rendern der gesamten Komponente zu warten. Das ist nicht das, was wir wollen, also gehen wir zum nächsten Schritt.

Schritt 2: Verhindern, dass das Bild beim Erstellen der Komponente geladen wird

Es mag ein wenig seltsam klingen, dass wir etwas verhindern wollen, das wir anzeigen möchten, aber es geht darum, es zur *richtigen Zeit* zu laden, anstatt es unbegrenzt zu blockieren. Um zu verhindern, dass das Bild geladen wird, müssen wir das src-Attribut aus dem img-Tag entfernen. Wir müssen es aber immer noch irgendwo speichern, damit wir es verwenden können, wenn wir es wollen. Ein guter Ort, um diese Informationen zu speichern, ist ein data-Attribut. Diese ermöglichen es uns, Informationen auf standardmäßigen, semantischen HTML-Elementen zu speichern. Tatsächlich sind Sie vielleicht schon daran gewöhnt, sie als JavaScript-Selektoren zu verwenden.

In diesem Fall passen sie perfekt zu unseren Bedürfnissen!

<!--ImageItem.vue-->
<template>
  <figure class="image__wrapper">
    <img
      class="image__item"
      :data-url="source" // yay for data attributes!
      alt="random image"
    >
  </figure>
</template>

Damit wird unser Bild *nicht* geladen, da keine Quell-URL zum Abrufen vorhanden ist.

Das ist ein guter Anfang, aber noch nicht ganz das, was wir wollen. Wir wollen unser Bild unter bestimmten Bedingungen laden. Wir können das Laden des Bildes anfordern, indem wir das src-Attribut durch die im data-url-Attribut gespeicherte Bildquelle ersetzen. Das ist der einfache Teil. Die eigentliche Herausforderung besteht darin, herauszufinden, wann wir es durch die tatsächliche Quelle ersetzen.

Unser Ziel ist es, das Laden an die Position des Bildschirms des Benutzers zu koppeln. Wenn der Benutzer also zu einem Punkt scrollt, an dem das Bild sichtbar wird, wird es dort geladen.

Wie können wir erkennen, ob das Bild sichtbar ist oder nicht? Das ist unser nächster Schritt.

Schritt 3: Erkennen, wann das Bild für den Benutzer sichtbar ist

Sie haben vielleicht Erfahrung damit, JavaScript zu verwenden, um zu erkennen, wann ein Element sichtbar ist. Sie haben vielleicht auch Erfahrung damit, auf skriptlastige Lösungen zu stoßen.

Zum Beispiel könnten wir Ereignisse und Ereignisbehandler verwenden, um die Scroll-Position, den Offset-Wert, die Elementhöhe und die Viewport-Höhe zu erkennen und dann zu berechnen, ob ein Bild im Viewport ist oder nicht. Aber das klingt schon ziemlich kompliziert, oder?

Aber es könnte schlimmer kommen. Dies hat direkte Auswirkungen auf die Leistung. Diese Berechnungen würden bei jedem Scroll-Ereignis ausgelöst. Noch schlimmer, stellen Sie sich ein paar Dutzend Bilder vor, die jeweils bei jedem Scroll-Ereignis neu berechnen müssen, ob sie sichtbar sind oder nicht. *Wahnsinn!*

Intersection Observer zur Rettung! Dies bietet eine sehr effiziente Möglichkeit, zu erkennen, ob ein Element im Viewport sichtbar ist. Insbesondere ermöglicht es Ihnen, einen Callback zu konfigurieren, der ausgelöst wird, wenn ein Element – das sogenannte Target – mit dem Geräte-Viewport oder einem angegebenen Element interagiert.

Also, was müssen wir tun, um es zu verwenden? Ein paar Dinge

  • einen neuen Intersection Observer erstellen
  • das zu lazy ladende Element auf Sichtbarkeitsänderungen überwachen
  • das Element laden, wenn es im Viewport ist (indem src durch unsere data-url ersetzt wird)
  • die Überwachung der Sichtbarkeit stoppen (unobserve), nachdem der Ladevorgang abgeschlossen ist

Vue.js bietet benutzerdefinierte Direktiven, um all diese Funktionalitäten zu bündeln und sie nach Bedarf zu verwenden, so oft wir sie benötigen. Das ist unser nächster Schritt.

Schritt 4: Erstellen einer benutzerdefinierten Vue-Direktive

Was ist eine benutzerdefinierte Direktive? Die Dokumentation von Vue beschreibt sie als eine Möglichkeit, auf niedriger Ebene auf den DOM zuzugreifen. Zum Beispiel, um ein Attribut eines bestimmten DOM-Elements zu ändern, was in unserem Fall das Ändern des src-Attributs eines img-Elements sein könnte. Perfekt!

Wir werden dies gleich Schritt für Schritt durchgehen, aber hier ist, was wir in Bezug auf den Code haben:

export default {
  inserted: el => {
    function loadImage() {
      const imageElement = Array.from(el.children).find(
      el => el.nodeName === "IMG"
      );
      if (imageElement) {
        imageElement.addEventListener("load", () => {
          setTimeout(() => el.classList.add("loaded"), 100);
        });
        imageElement.addEventListener("error", () => console.log("error"));
        imageElement.src = imageElement.dataset.url;
      }
    }

    function handleIntersect(entries, observer) {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          loadImage();
          observer.unobserve(el);
        }
      });
    }

    function createObserver() {
      const options = {
        root: null,
        threshold: "0"
      };
      const observer = new IntersectionObserver(handleIntersect, options);
      observer.observe(el);
    }
    if (window["IntersectionObserver"]) {
      createObserver();
    } else {
      loadImage();
    }
  }
};

Okay, lass uns diesen Schritt für Schritt angehen.

Die Hook-Funktion ermöglicht es uns, eine benutzerdefinierte Logik zu einem bestimmten Zeitpunkt im Lebenszyklus eines gebundenen Elements auszuführen. Wir verwenden den inserted-Hook, da er aufgerufen wird, wenn das gebundene Element in seinen Elternknoten eingefügt wurde (dies garantiert, dass der Elternknoten vorhanden ist). Da wir die Sichtbarkeit eines Elements in Bezug auf seinen Elternteil (oder einen beliebigen Vorfahren) überwachen möchten, müssen wir diesen Hook verwenden.

export default {
  inserted: el => {
    ...
  }
}

Die Funktion loadImage ist dafür verantwortlich, den src-Wert durch data-url zu ersetzen. Darin haben wir Zugriff auf unser Element (el), auf das wir die Direktive anwenden. Wir können das img aus diesem Element extrahieren.

Als Nächstes prüfen wir, ob das Bild existiert, und fügen, falls ja, einen Listener hinzu, der eine Callback-Funktion auslöst, wenn das Laden abgeschlossen ist. Dieser Callback ist dafür verantwortlich, den Spinner auszublenden und die Animation (Fade-in-Effekt) zum Bild hinzuzufügen, indem eine CSS-Klasse verwendet wird. Wir fügen auch einen zweiten Listener hinzu, der aufgerufen wird, wenn die URL fehlschlägt.

Schließlich ersetzen wir die src unseres img-Elements durch die Quell-URL des Bildes und zeigen es an!

function loadImage() {
  const imageElement = Array.from(el.children).find(
    el => el.nodeName === "IMG"
  );
  if (imageElement) {
    imageElement.addEventListener("load", () => {
      setTimeout(() => el.classList.add("loaded"), 100);
    });
    imageElement.addEventListener("error", () => console.log("error"));
    imageElement.src = imageElement.dataset.url;
  }
}

Wir verwenden die handleIntersect-Funktion von Intersection Observer, die dafür verantwortlich ist, loadImage auszulösen, wenn bestimmte Bedingungen erfüllt sind. Insbesondere wird sie ausgelöst, wenn Intersection Observer erkennt, dass das Element in den Viewport oder ein übergeordnetes Komponentenelement eintritt.

Die Funktion hat Zugriff auf entries, was ein Array aller vom Observer beobachteten Elemente ist, und auf den observer selbst. Wir iterieren durch entries und prüfen, ob ein einzelner Eintrag für unseren Benutzer sichtbar wird mit isIntersecting – und lösen die Funktion loadImage aus, wenn dies der Fall ist. Sobald das Bild angefordert wurde, unobserve wir das Element (entfernen es aus der Beobachtungsliste des Observers), was verhindert, dass das Bild erneut geladen wird. Und wieder. Und wieder. Und...

function handleIntersect(entries, observer) {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      loadImage();
      observer.unobserve(el);
    }
  });
}

Das letzte Teil ist die createObserver-Funktion. Dieser Kerl ist dafür verantwortlich, unseren Intersection Observer zu erstellen und ihn an unser Element anzuhängen. Der IntersectionObserver-Konstruktor akzeptiert einen Callback (unsere handleIntersect-Funktion), der ausgelöst wird, wenn das beobachtete Element den angegebenen threshold überschreitet, und das options-Objekt, das unsere Observer-Optionen enthält.

Wo wir gerade vom options-Objekt sprechen: Es verwendet root als unser Referenzobjekt, das wir verwenden, um die Sichtbarkeit unseres beobachteten Elements zu basieren. Es kann jeder Vorfahre des Objekts oder unser Browser-Viewport sein, wenn wir null übergeben. Das Objekt gibt auch einen threshold-Wert an, der von 0 bis 1 reichen kann und uns sagt, bei welchem Prozentsatz der Ziel-Sichtbarkeit der observer-Callback ausgeführt werden soll, wobei 0 bedeutet, dass er ausgeführt wird, sobald auch nur ein Pixel sichtbar ist, und 1 bedeutet, dass das gesamte Element sichtbar sein muss.

Und dann, nach der Erstellung des Intersection Observers, hängen wir ihn mit der Methode observe an unser Element an.

function createObserver() {
  const options = {
    root: null,
    threshold: "0"
  };
  const observer = new IntersectionObserver(handleIntersect, options);
  observer.observe(el);
}

Schritt 5: Registrieren der Direktive

Um unsere neu erstellte Direktive verwenden zu können, müssen wir sie zuerst registrieren. Es gibt zwei Möglichkeiten, dies zu tun: global (überall in der App verfügbar) oder lokal (auf Komponentenebene).

Globale Registrierung

Für die globale Registrierung importieren wir unsere Direktive und verwenden die Methode Vue.directive, um den Namen, unter dem wir unsere Direktive aufrufen möchten, und die Direktive selbst zu übergeben. Dadurch können wir jedem Element in unserem Code ein v-lazyload-Attribut hinzufügen.

// main.js
import Vue from "vue";
import App from "./App";
import LazyLoadDirective from "./directives/LazyLoadDirective";

Vue.config.productionTip = false;

Vue.directive("lazyload", LazyLoadDirective);

new Vue({
  el: "#app",
  components: { App },
  template: "<App/>"
});

Lokale Registrierung

Wenn wir unsere Direktive nur in einer bestimmten Komponente verwenden und den Zugriff darauf einschränken möchten, können wir die Direktive lokal registrieren. Dazu müssen wir die Direktive innerhalb der Komponente importieren, die sie verwenden wird, und sie im directives-Objekt registrieren. Dies gibt uns die Möglichkeit, ein v-lazyload-Attribut zu jedem Element in dieser Komponente hinzuzufügen.

import LazyLoadDirective from "./directives/LazyLoadDirective";

export default {
  directives: {
    lazyload: LazyLoadDirective
  }
}

Schritt 6: Verwenden einer Direktive in der ImageItem-Komponente

Nachdem unsere Direktive registriert ist, können wir sie verwenden, indem wir v-lazyload zum übergeordneten Element hinzufügen, das unser Bild enthält (in unserem Fall das figure-Tag).

<template>
  <figure v-lazyload class="image__wrapper">
    <ImageSpinner
      class="image__spinner"
    />
    <img
      class="image__item"
      :data-url="source"
      alt="random image"
    >
  </figure>
</template>

Browser-Unterstützung

Wir wären nachlässig, wenn wir keine Anmerkung zur Browserunterstützung machen würden. Obwohl die Intersection Observer API nicht von *allen* Browsern unterstützt wird, deckt sie (zum Zeitpunkt des Schreibens) 73 % der Benutzer ab.

Diese Daten zur Browserunterstützung stammen von Caniuse, wo es weitere Details gibt. Eine Zahl bedeutet, dass der Browser die Funktion ab dieser Version unterstützt.

Desktop

ChromeFirefoxIEEdgeSafari
5855Nein1612.1

Mobil / Tablet

Android ChromeAndroid FirefoxAndroidiOS Safari
12712712712.2-12.5

Nicht schlecht. Gar nicht schlecht.

Aber! Da wir bedenken, dass wir Bildern für *alle* Benutzer anzeigen *möchten* (denken Sie daran, dass die Verwendung von data-url das Laden des Bildes überhaupt verhindert), müssen wir unserer Direktive noch ein weiteres Element hinzufügen. Insbesondere müssen wir prüfen, ob der Browser Intersection Observer unterstützt, und wenn nicht, stattdessen loadImage auslösen. Das wird unser Fallback sein.

if (window["IntersectionObserver"]) {
    createObserver();
} else {
    loadImage();
}

Zusammenfassung

Lazy Loading von Bildern kann die Seitenleistung *erheblich* verbessern, da es das von Bildern verursachte Gewicht der Seite reduziert und sie erst lädt, wenn der Benutzer sie tatsächlich benötigt.

Für diejenigen, die noch nicht davon überzeugt sind, ob sich Lazy Loading lohnt, hier ein paar Rohdaten aus dem einfachen Beispiel, das wir verwendet haben. Die Liste enthält 11 Artikel mit einem Bild pro Artikel. Das sind insgesamt 11 Bilder (Rechnen!). Es sind nicht gerade *wahnsinnig* viele Bilder, aber wir können trotzdem damit arbeiten.

Hier ist, was wir beim Rendern aller 11 Bilder ohne Lazy Loading über eine 3G-Verbindung erhalten

Die 11 Bildanforderungen tragen zu einer Gesamtseitengröße von 3,2 MB bei. *Hui.*

Hier ist die gleiche Seite, die Lazy Loading einsetzt

Wie bitte? Nur eine Anfrage für ein Bild. Unsere Seite ist jetzt 1,4 MB. Wir haben 10 Anfragen gespart und die *Seitengröße um 56 % reduziert*.

Ist es ein einfaches und isoliertes Beispiel? Ja, aber die Zahlen sprechen immer noch für sich. Hoffentlich finden Sie Lazy Loading als effektive Methode im Kampf gegen überladene Seiten und diese spezifische Herangehensweise mit Vue und Intersection Observer ist nützlich.