Inline Bildvorschau mit Sharp, BlurHash und Lambda-Funktionen

Avatar of Adam Rackis
Adam Rackis am

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

Hassen Sie es nicht, wenn Sie eine Website oder Webanwendung laden, einige Inhalte angezeigt werden und *dann* einige Bilder geladen werden – was dazu führt, dass sich der Inhalt verschiebt? Das nennt man *Content Reflow* und kann zu einer unglaublich ärgerlichen Benutzererfahrung für Besucher führen.

Ich habe zuvor über die Lösung dieses Problems mit Reacts Suspense geschrieben, das verhindert, dass die Benutzeroberfläche geladen wird, bis die Bilder eintreffen. Dies löst das Problem des Content Reflow, aber auf Kosten der Leistung. Der Benutzer wird daran gehindert, überhaupt Inhalte zu sehen, bis die Bilder eintreffen.

Wäre es nicht schön, wenn wir das Beste aus beiden Welten haben könnten: Content Reflow verhindern und gleichzeitig den Benutzer nicht auf die Bilder warten lassen? Dieser Beitrag führt Sie durch die Generierung von verschwommenen Bildvorschauen und deren sofortige Anzeige, wobei die echten Bilder über der Vorschau gerendert werden, sobald sie eintreffen.

Meinen Sie also progressive JPEGs?

Sie fragen sich vielleicht, ob ich über progressive JPEGs sprechen werde, die eine alternative Kodierung sind, bei der Bilder zunächst – in voller Größe und verschwommen – gerendert werden und sich dann allmählich verbessern, wenn die Daten eintreffen, bis alles korrekt gerendert ist.

Dies scheint eine großartige Lösung zu sein, bis Sie sich mit einigen Details befassen. Das erneute Kodieren Ihrer Bilder als progressive JPEGs ist relativ einfach; es gibt Plugins für Sharp, die das für Sie erledigen. Leider müssen Sie immer noch auf *einige* Bytes Ihrer Bilder warten, bis überhaupt eine verschwommene Vorschau Ihres Bildes angezeigt wird. Zu diesem Zeitpunkt wird sich Ihr Inhalt verschieben und an die Größe der Vorschau des Bildes anpassen.

Sie suchen vielleicht nach einer Art Ereignis, das anzeigt, dass eine erste Vorschau des Bildes geladen wurde, aber derzeit existiert keine, und die Workarounds sind... nicht ideal.

Sehen wir uns zwei Alternativen dafür an.

Die Bibliotheken, die wir verwenden werden

Bevor wir beginnen, möchte ich die Versionen der Bibliotheken hervorheben, die ich für diesen Beitrag verwenden werde

  • Jimp Version 0.16.1
  • BlurHash Version 1.1.5
  • Sharp Version 0.30.3

Eigene Vorschauen erstellen

Die meisten von uns sind es gewohnt, ``-Tags zu verwenden, indem sie ein `src`-Attribut angeben, das eine URL zu einem Ort im Internet ist, an dem sich unser Bild befindet. Wir können aber auch eine Base64-Kodierung eines Bildes bereitstellen und diese direkt einfügen. Das würden wir *normalerweise* nicht tun wollen, da diese Base64-Strings für Bilder riesig werden können und das Einbetten in unsere JavaScript-Bundles zu ernsthaften Bloats führen kann.

Aber was wäre, wenn wir, während wir unsere Bilder verarbeiten (zum Ändern der Größe, Anpassen der Qualität usw.), auch eine qualitativ minderwertige, verschwommene Version unseres Bildes erstellen und die Base64-Kodierung *davon* nehmen? Die Größe dieser Base64-Bildvorschau wird deutlich kleiner sein. Wir könnten diesen Vorschau-String speichern, ihn in unser JavaScript-Bundle einfügen und ihn inline anzeigen, bis unser echtes Bild geladen ist. Dies führt dazu, dass eine verschwommene Vorschau unseres Bildes sofort angezeigt wird, während das Bild geladen wird. Wenn das echte Bild geladen ist, können wir die Vorschau ausblenden und das echte Bild anzeigen.

Sehen wir mal, wie das geht.

Unsere Vorschau generieren

Fürs Erste betrachten wir Jimp, das keine Abhängigkeiten von Dingen wie `node-gyp` hat und in einer Lambda installiert und verwendet werden kann.

Hier ist eine Funktion (ohne Fehlerbehandlung und Protokollierung), die Jimp verwendet, um ein Bild zu verarbeiten, es zu skalieren und dann eine verschwommene Vorschau des Bildes zu erstellen

function resizeImage(src, maxWidth, quality) {
  return new Promise<ResizeImageResult>(res => {
    Jimp.read(src, async function (err, image) {
      if (image.bitmap.width > maxWidth) {
        image.resize(maxWidth, Jimp.AUTO);
      }
      image.quality(quality);

      const previewImage = image.clone();
      previewImage.quality(25).blur(8);
      const preview = await previewImage.getBase64Async(previewImage.getMIME());

      res({ STATUS: "success", image, preview });
    });
  });
}

Für diesen Beitrag werde ich dieses Bild von Flickr Commons verwenden

Photo of the Big Boy statue holding a burger.

Und hier sieht die Vorschau aus

Blurry version of the Big Boy statue.

Wenn Sie es genauer betrachten möchten, hier ist die gleiche Vorschau in einem CodeSandbox.

Offensichtlich ist diese Vorschau-Kodierung nicht klein, aber auch unser Bild ist nicht klein; kleinere Bilder erzeugen kleinere Vorschauen. Messen und profilieren Sie für Ihren eigenen Anwendungsfall, um zu sehen, wie praktikabel diese Lösung ist.

Jetzt können wir diese Bildvorschau von unserer Datenschicht senden, zusammen mit der tatsächlichen Bild-URL und allen anderen zugehörigen Daten. Wir können die Bildvorschau sofort anzeigen, und wenn das tatsächliche Bild geladen ist, tauschen wir es aus. Hier ist ein (vereinfachter) React-Code dazu

const Landmark = ({ url, preview = "" }) => {
    const [loaded, setLoaded] = useState(false);
    const imgRef = useRef<HTMLImageElement>(null);
  
    useEffect(() => {
      // make sure the image src is added after the onload handler
      if (imgRef.current) {
        imgRef.current.src = url;
      }
    }, [url, imgRef, preview]);
  
    return (
      <>
        <Preview loaded={loaded} preview={preview} />
        <img
          ref={imgRef}
          onLoad={() => setTimeout(() => setLoaded(true), 3000)}
          style={{ display: loaded ? "block" : "none" }}
        />
      </>
    );
  };
  
  const Preview: FunctionComponent<LandmarkPreviewProps> = ({ preview, loaded }) => {
    if (loaded) {
      return null;
    } else if (typeof preview === "string") {
      return <img key="landmark-preview" alt="Landmark preview" src={preview} style={{ display: "block" }} />;
    } else {
      return <PreviewCanvas preview={preview} loaded={loaded} />;
    }
  };

Machen Sie sich noch keine Gedanken über die `PreviewCanvas`-Komponente. Und machen Sie sich keine Gedanken darüber, dass Dinge wie eine sich ändernde URL nicht berücksichtigt werden.

Beachten Sie, dass wir die `src` der Bildkomponente nach dem `onLoad`-Handler setzen, um sicherzustellen, dass er ausgelöst wird. Wir zeigen die Vorschau an, und wenn das echte Bild geladen ist, tauschen wir es ein.

Verbesserung der Dinge mit BlurHash

Update: Seitdem ich dies geschrieben habe, würde ich Blurhash nicht mehr empfehlen. Es erfordert clientseitiges JavaScript und ``-Tags zur Anzeige der Vorschau. Das macht es extrem unfreundlich für SSR-basierte Web-Frameworks wie Next und SvelteKit.

Stattdessen würde ich plaiceholder empfehlen. Es verwendet Sharp als Abhängigkeit, daher sind die speziellen Lambda-Installationsanweisungen immer noch relevant. Mir gefällt die Base64-Option, die eine extrem kleine Base64-Vorschau generiert. Sie müssen immer noch die tatsächliche Größe verfolgen, wie wir im Artikel tun, und dann die Vorschau hochskalieren. Nachdem Sie dies getan und einen Weichzeichner angewendet haben, sieht das Endergebnis ungefähr so gut aus wie Blurhash. Das Beste daran. Es ist vollständig SSR-freundlich. Tatsächlich können Sie die Vorschau unter dem echten Bild mit CSS anzeigen. Das führt dazu, dass die Vorschau angezeigt wird, bis das echte Bild eintrifft. Dann übernimmt sie und verwendet nur HTML und CSS, ohne clientseitiges JavaScript.

Hier ist eine Svelte-Komponente, die ich mit SvelteKit geschrieben habe, um genau das zu tun. Diese Komponente läuft auf dem Server und erledigt die Vorschau und den Austausch noch vor der Hydration oder sogar, wenn JavaScript deaktiviert ist.

Code anzeigen
<script lang="ts">
  import type { PreviewPacket } from "$data/types";

  export let url: string | null = null;
  export let preview: string | PreviewPacket | null;

  $: previewString = preview == null ? "" : typeof preview === "string" ? preview : preview.b64;
  $: sizingStyle = preview != null && typeof preview === "object" ? `width:${preview.w}px;height:${preview.h}px` : "";
</script>

<div>
  <img alt="Book cover preview" src={previewString} style={sizingStyle} class="preview" />
  <img alt="Book cover" src={url} class="image" />
</div>

<style>
  div {
    display: inline-grid;
    grid-template-areas: "content";
    overflow: hidden;
  }
  div > * {
    grid-area: content;
  }

  .preview {
    z-index: 1;
    filter: blur(5px);
  }
  .image {
    z-index: 2;
  }
</style>

Der ursprüngliche Inhalt dieses Abschnitts des Artikels folgt.


Die zuvor gezeigte Bildvorschau ist möglicherweise nicht klein genug, um sie mit unserem JavaScript-Bundle zu versenden. Und diese Base64-Strings werden nicht gut gegzip't. Je nachdem, wie viele dieser Bilder Sie haben, ist dies möglicherweise ausreichend oder auch nicht. Aber wenn Sie die Dinge noch kleiner komprimieren möchten und bereit sind, etwas mehr Aufwand zu betreiben, gibt es eine wunderbare Bibliothek namens BlurHash.

BlurHash generiert unglaublich kleine Vorschauen mithilfe der Base83-Kodierung. Die Base83-Kodierung ermöglicht es, mehr Informationen in weniger Bytes zu quetschen, was dazu beiträgt, die Vorschauen so klein zu halten. 83 mag eine willkürliche Zahl erscheinen, aber das README gibt Aufschluss darüber

Erstens scheint 83 etwa die Anzahl der niedrig-ASCII-Zeichen zu sein, die sicher in JSON, HTML und Shells verwendet werden können.

Zweitens ist 83 * 83 sehr nah an, und etwas mehr als, 19 * 19 * 19, was es ideal macht, drei AC-Komponenten in zwei Zeichen zu kodieren.

Das README gibt auch an, wie Signal und Mastodon BlurHash verwenden.

Sehen wir es in Aktion.

`blurhash`-Vorschauen generieren

Dafür müssen wir die Sharp-Bibliothek verwenden.


Hinweis

Um Ihre `blurhash`-Vorschauen zu generieren, werden Sie wahrscheinlich eine Art serverlose Funktion verwenden, um Ihre Bilder zu verarbeiten und die Vorschauen zu generieren. Ich werde AWS Lambda verwenden, aber jede Alternative sollte funktionieren.

Seien Sie nur vorsichtig bei den maximalen Größenbeschränkungen. Die Binärdateien, die Sharp installiert, erhöhen die Größe der serverlosen Funktion um etwa 9 MB.

Um diesen Code in einer AWS Lambda auszuführen, müssen Sie die Bibliothek wie folgt installieren

"install-deps": "npm i && SHARP_IGNORE_GLOBAL_LIBVIPS=1 npm i --arch=x64 --platform=linux sharp"

Und stellen Sie sicher, dass Sie keine Art von Bündelung durchführen, um sicherzustellen, dass alle Binärdateien an Ihre Lambda gesendet werden. Dies wirkt sich auf die Größe des Lambda-Deployments aus. Sharp allein wird etwa 9 MB betragen, was für Kaltstartzeiten nicht gut ist. Der Code, den Sie unten sehen werden, befindet sich in einer Lambda, die nur periodisch ausgeführt wird (ohne dass die Benutzeroberfläche darauf wartet) und `blurhash`-Vorschauen generiert.


Dieser Code betrachtet die Größe des Bildes und erstellt eine `blurhash`-Vorschau

import { encode, isBlurhashValid } from "blurhash";
const sharp = require("sharp");

export async function getBlurhashPreview(src) {
  const image = sharp(src);
  const dimensions = await image.metadata();

  return new Promise(res => {
    const { width, height } = dimensions;

    image
      .raw()
      .ensureAlpha()
      .toBuffer((err, buffer) => {
        const blurhash = encode(new Uint8ClampedArray(buffer), width, height, 4, 4);
        if (isBlurhashValid(blurhash)) {
          return res({ blurhash, w: width, h: height });
        } else {
          return res(null);
        }
      });
  });
}

Auch hier habe ich zur besseren Übersichtlichkeit alle Fehlerbehandlungen und Protokollierungen entfernt. Erwähnenswert ist der Aufruf von `ensureAlpha`. Dies stellt sicher, dass jedes Pixel 4 Bytes hat, jeweils eines für RGB und Alpha.

Jimp fehlt diese Methode, weshalb wir Sharp verwenden. Wenn jemand etwas anderes weiß, bitte kommentieren Sie.

Beachten Sie auch, dass wir nicht nur den Vorschau-String, sondern auch die Abmessungen des Bildes speichern, was in Kürze Sinn ergeben wird.

Die eigentliche Arbeit geschieht hier

const blurhash = encode(new Uint8ClampedArray(buffer), width, height, 4, 4);

Wir rufen die `encode`-Methode von `blurhash` auf und übergeben ihr unser Bild und die Abmessungen des Bildes. Die letzten beiden Argumente sind `componentX` und `componentY`, die nach meinem Verständnis der Dokumentation steuern, wie viele Durchläufe `blurhash` auf unserem Bild durchführt und immer mehr Details hinzufügt. Die zulässigen Werte sind 1 bis 9 (einschließlich). Nach meinen eigenen Tests ist 4 ein guter Mittelweg, der die besten Ergebnisse liefert.

Sehen wir uns an, was das für dasselbe Bild ergibt

{
  "blurhash" : "UAA]{ox^0eRiO_bJjdn~9#M_=|oLIUnzxtNG",
  "w" : 276,
  "h" : 400
}

Das ist unglaublich klein! Der Kompromiss ist, dass die *Verwendung* dieser Vorschau etwas aufwendiger ist.

Im Grunde müssen wir die `decode`-Methode von `blurhash` aufrufen und unsere Bildvorschau in einem `canvas`-Tag rendern. Dies ist das, was die `PreviewCanvas`-Komponente zuvor getan hat und warum wir sie gerendert haben, wenn der Typ unserer Vorschau kein String war: Unsere `blurhash`-Vorschauen verwenden ein ganzes Objekt – das nicht nur den Vorschau-String, sondern auch die Bildabmessungen enthält.

Sehen wir uns unsere `PreviewCanvas`-Komponente an

const PreviewCanvas: FunctionComponent<CanvasPreviewProps> = ({ preview }) => {
    const canvasRef = useRef<HTMLCanvasElement>(null);
  
    useLayoutEffect(() => {
      const pixels = decode(preview.blurhash, preview.w, preview.h);
      const ctx = canvasRef.current.getContext("2d");
      const imageData = ctx.createImageData(preview.w, preview.h);
      imageData.data.set(pixels);
      ctx.putImageData(imageData, 0, 0);
    }, [preview]);
  
    return <canvas ref={canvasRef} width={preview.w} height={preview.h} />;
  };

Hier passiert nicht allzu viel. Wir dekodieren unsere Vorschau und rufen dann einige ziemlich spezifische Canvas-APIs auf.

Sehen wir uns an, wie die Bildvorschauen aussehen

In gewisser Weise ist es weniger detailliert als unsere vorherigen Vorschauen. Aber ich habe festgestellt, dass sie auch etwas flüssiger und weniger pixelig sind. Und sie nehmen nur einen winzigen Bruchteil des Platzes ein.

Testen Sie und verwenden Sie, was für Sie am besten funktioniert.

Zusammenfassung

Es gibt viele Möglichkeiten, Content Reflow bei der Bildanzeige im Web zu verhindern. Ein Ansatz ist, die Benutzeroberfläche nicht rendern zu lassen, bis die Bilder eintreffen. Der Nachteil ist, dass Ihr Benutzer länger auf Inhalte warten muss.

Ein guter Mittelweg ist, sofort eine Vorschau des Bildes anzuzeigen und das echte Bild einzublenden, wenn es geladen ist. Dieser Beitrag hat Ihnen zwei Möglichkeiten gezeigt, dies zu erreichen: die Erstellung von degradierten, verschwommenen Versionen eines Bildes mit einem Werkzeug wie Sharp und die Verwendung von BlurHash zur Generierung einer extrem kleinen, Base83-kodierten Vorschau.

Viel Spaß beim Codieren!