Implementeren van Low-Quality Image Placeholders (LQIP) in Astro

Martijn null

Martijn

6 min lezen , ~2 ~uur om te bouwen

Een populaire techniek om de gebruikerservaring te verbeteren is het tonen van een wazige, laagwaardige versie van een afbeelding terwijl de versie met volledige resolutie wordt geladen. In dit artikel leer je hoe je dit effect implementeert in Astro, zonder afhankelijk te zijn van de overhead van JavaScript-frameworks zoals React of Vue.

Implementeren van Low-Quality Image Placeholders (LQIP) in Astro

In dit artikel:

  • Bij het laden van de pagina moeten alle afbeeldingen een wazige, lage kwaliteit versie tonen van de uiteindelijke afbeelding.
  • Zodra de volledige afbeeldingen zijn geladen, vervangen ze de lage kwaliteit previews.
  • We willen dit realiseren in Astro, zonder de JavaScript van een framework zoals React of Vue te laden.

Daarna zal het resultaat er zo uitzien:
✨ Live voorbeeld: https://pinelab.studio/examples/blurry-placeholder-loading

⚠️ Disclaimer: Gebruik wazige afbeeldingspreviews vooral om de waargenomen gebruikerservaring te verbeteren, bijvoorbeeld op pagina’s achter een login. Er zijn echter nadelen wanneer SEO belangrijk is:

  • Als de originele afbeelding niet aanwezig is in de HTML, kan dit nadelig zijn voor SEO en indexering van afbeeldingen.
  • Extra verwerking (zoals het decoderen van de base64 hashes) tijdens het laden kan negatieve impact hebben op Core Web Vitals.

Vereisten

  • Je hebt al een gegenereerde placeholder-hash voor je afbeeldingen. Wij gebruiken Daniel Biegler's uitstekende Vendure plugin om hashes te genereren in Vendure.
  • We gebruiken ThumbHash om de hashes te maken, dus we hebben ook het ThumbHash decoding-script nodig om de juiste base64-afbeelding te maken.

Hoe werken wazige preview hashes?

Op de server worden kleine hashes gegenereerd voor al je assets. Deze hashes representeren wazige versies van je afbeeldingen. Voordat je ze kunt gebruiken, moeten we ze decoderen naar een base64 data-URL die je browser kan laden:

  1. De browser laad je HTML-document.
  2. De afbeeldingen in je HTML bevatten een hash, zoals VtcZFQJoiI+Hp4dnh3Z4h4iAgwhY.
  3. Bij het laden van de pagina op de client, decoderen we de hash VtcZFQJ.... naar een base64-afbeelding die de browser kan weergeven: data:image/png;base64,iVBOR...

Deze base64-afbeeldingen zijn veel groter dan de oorspronkelijke hash, daarom decoderen we ze clientside in plaats van op de server.

Maar let op! Deze decoding vereist JavaScript. Er is dus een afweging: Voor slechts één afbeelding kun je beter server-side decoderen, want het script is dan even groot als de wazige preview.

In dit voorbeeld:

  • Een gedecodeerde wazige preview is 3kb
  • De minified versie van het script is 2.5kb

Voor meerdere afbeeldingen op een pagina bespaar je zeker veel KB's, aangezien we alleen een hash voor elke afbeelding laden.

De oplossing

Wat we gaan doen:

  1. Zet de hashes op afbeeldingen in je HTML in de backend: <img data-placeholder-hash="VtcZFQJoiI+Hp4dnh3Z4h4iAgwhY" >
  2. Voeg een inline script toe dat alle afbeeldingen met data-placeholder-hash opzoekt, de hashes decodeert naar base64, en deze als src op de afbeelding zet.
  3. Wanneer de originele afbeelding geladen is, vervangen we de wazige preview door het origineel.

Hashes instellen op img-elementen in je HTML

Dit moet de HTML zijn die door je server wordt gegenereerd, ofwel via SSR of SSG.

  <img
    data-placeholder-hash="VtcZFQJoiI+Hp4dnh3Z4h4iAgwhY"
    data-src="https://images.unsplash.com/photo-1607348318282-6d2148bb296c?q=80&w=408&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
    src="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'/>"
    width="500"
    height="500"
  />
  • data-placeholder-hash bevat de hash die we gaan decoderen
  • data-src is de originele afbeelding die op de achtergrond geladen wordt
  • src is een lege SVG om te voorkomen dat de afbeelding een border toont—dat gebeurt soms bij een afbeelding zonder src.

Voeg een inline script toe om afbeeldingen te decoderen

Plaats dit script onderaan je <body>, want het wordt direct uitgevoerd.

<script is:inline>
  // Find all images with data-hash-placeholder attribute
  const images = document.querySelectorAll("img[data-placeholder-hash]");

  images.forEach((img) => {
    const placeholderHash = img.dataset.placeholderHash;
    const original = img.dataset.src;

    // Decode hash 
    const buffer = Uint8Array.from(atob(placeholderHash), (v) => v.charCodeAt(0));
    const placeholder = thumbHashToDataURL(buffer);

    // Set placeholder as initial src
    img.src = placeholder;

    const fullImage = new Image();
    fullImage.src = original;

    fullImage.onload = async () => {
      img.src = original;
      img.classList.add("loaded"); // Optional for transition
    };
  });

  // Copied the decoding functions from the thumbhash repo, because we want to inline the script.
  // Source can be found here: https://github.com/evanw/thumbhash/blob/a652ce6ed691242f459f468f0a8756cda3b90a82/js/thumbhash.js

  function thumbHashToDataURL(hash) {
       ... (omitted for brevity) 
   }
</script>

Volledige script: view-source:https://pinelab.studio/examples/blurry-placeholder-loading

Nu zie je bij het openen van de pagina eerst een wazige preview vóórdat de originele afbeelding is geladen.

ℹ️ Zet je netwerk op 3G en schakel caching uit in je devtools om het effect goed te zien. Wi-Fi is waarschijnlijk te snel.

Waarom inline?

We willen dat de wazige previews zo snel mogelijk verschijnen. Dit script decodeert de afbeelding zodra de HTML is ontvangen. Zonder is:inline pakt Astro het JS-bestand in een extern script, dat eerst geladen moet worden.

Waarom niet in React?

Hoewel het code-technisch netter is om dit in een React-component te doen, zijn er enkele nadelen:

  • In Astro worden frameworkcomponenten standaard zonder JS geladen—dus de preview wordt dan nooit ingesteld.
  • Als je client:load of client:idle gebruikt, moet de hele JS van het framework eerst geladen zijn voordat het decoderen werkt. Voor React is dat minimaal 10kb gzipped.
  • Zelfs als dat acceptabel is, vereist het laden van het React-script een extra netwerkverzoek.

TL;DR: Voor performance.

Kanttekeningen

  • Zet het inline script niet in een Astro Image-component die je meerdere keren importeert. Het script wordt dan elke keer opnieuw ingevoegd. Zie: https://docs.astro.build/en/guides/client-side-scripts/#opting-out-of-processing
  • Gebruik deze methode alleen bij meerdere afbeeldingen op een pagina. Het decoding-script is 2.5kb, dus voor 1-2 afbeeldingen is het efficiënter om direct een gedecodeerde blurry afbeelding te gebruiken (3.2kb).
  • Hydratatiewaarschuwingen: De React-component gebruikt suppressHydrationWarning op het img-element omdat onze inline script de DOM wijzigt, wat afwijkt van wat React verwacht.

Volgende stappen

Voor productiegebruik, doe het volgende:

  • Verplaats het <img>-element met bijbehorende styling naar een component, bijvoorbeeld in React (zie hieronder)
  • Gebruik een minified versie van het inline script. Zie onder of minificeer zelf via een tool zoals https://minify-js.com/, want, vertrouw niet zomaar op scripts uit blogs.

Gebruikte codevoorbeelden

ImageWithPlaceholder.tsx React-component

// ImageWithPlaceHolder.tsx
import { useEffect, useState } from "react";

interface ImageWithPlaceholderProps {
  placeholderHash: string;
  srcImage: string;
  width?: number;
  height?: number;
  alt?: string;
  className?: string;
  loading?: "lazy" | "eager";
}

export default function ImageWithPlaceholder({
  placeholderHash,
  srcImage,
  width = 500,
  height = 500,
  alt = "",
  loading = "eager",
}: ImageWithPlaceholderProps) {

  // Placeholder SVG until the placeholder hash is decoded.
  const [src, setSrc] = useState(
    "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'/>",
  );
  useEffect(() => {
    // On mount in browser, set the src.
    // This is to make sure an image is loaded when this component is used with client:only
    setSrc(srcImage);
  });
  return (
    <div>
      <style>
        {`
            img[data-placeholder-hash] {
              transition: opacity 0.3s ease;
              border-radius: 10px;
              opacity: 0.999;
            }
  
            img[data-placeholder-hash].loaded {
              opacity: 1;
            }
          `}
      </style>
      <img
        // Suppress hydration warning, because the external script will set the decoded placeholder hash,
        // before any React code is loaded. So, an hydration warning is expected.
        suppressHydrationWarning
        src={src}
        data-placeholder-hash={placeholderHash}
        data-src={srcImage}
        loading={loading}
        width={width}
        height={height}
        alt={alt}
      />
    </div>
  );
}

Gebruik in je Astro- of React-bestand:

   <ImageWithPlaceholder
    placeholderHash="VtcZFQJoiI+Hp4dnh3Z4h4iAgwhY"
    srcImage="https://images.unsplash.com/photo-1607348318282-6d2148bb296c?q=80&w=408&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
  /> 

Minified ThumbHash decode-script

Geminificeerde versie van het decoding script van https://github.com/evanw/thumbhash/blob/a652ce6ed691242f459f468f0a8756cda3b90a82/js/thumbhash.js

const images=document.querySelectorAll("img[data-placeholder-hash]");function thumbHashToDataURL(t){let e=thumbHashToRGBA(t);return rgbaToDataURL(e.w,e.h,e.rgba)}function thumbHashToRGBA(t){let{PI:e,min:a,max:o,cos:r,round:l}=Math,s=t[0]|t[1]<<8|t[2]<<16,h=t[3]|t[4]<<8,n=(63&s)/63,f=(s>>6&63)/31.5-1,c=(s>>12&63)/31.5-1,u=(s>>18&31)/31,i=s>>23,m=(h>>3&63)/63,d=(h>>9&63)/63,b=h>>15,p=o(3,b?i?5:7:7&h),g=o(3,b?7&h:i?5:7),A=i?(15&t[5])/15:1,R=(t[5]>>4)/15,T=i?6:5,H=0,U=(e,a,o)=>{let r=[];for(let l=0;l<a;l++)for(let s=l?0:1;s*a<e*(a-l);s++)r.push(((t[T+(H>>1)]>>((1&H++)<<2)&15)/7.5-1)*o);return r},L=U(p,g,u),w=U(3,3,1.25*m),y=U(3,3,1.25*d),D=i&&U(5,5,R),x=thumbHashToApproximateAspectRatio(t),C=l(x>1?32:32*x),B=l(x>1?32/x:32),G=new Uint8Array(C*B*4),I=[],S=[];for(let t=0,l=0;t<B;t++)for(let s=0;s<C;s++,l+=4){let h=n,u=f,m=c,d=A;for(let t=0,a=o(p,i?5:3);t<a;t++)I[t]=r(e/C*(s+.5)*t);for(let a=0,l=o(g,i?5:3);a<l;a++)S[a]=r(e/B*(t+.5)*a);for(let t=0,e=0;t<g;t++)for(let a=t?0:1,o=2*S[t];a*g<p*(g-t);a++,e++)h+=L[e]*I[a]*o;for(let t=0,e=0;t<3;t++)for(let a=t?0:1,o=2*S[t];a<3-t;a++,e++){let t=I[a]*o;u+=w[e]*t,m+=y[e]*t}if(i)for(let t=0,e=0;t<5;t++)for(let a=t?0:1,o=2*S[t];a<5-t;a++,e++)d+=D[e]*I[a]*o;let b=h-2/3*u,R=(3*h-b+m)/2,T=R-m;G[l]=o(0,255*a(1,R)),G[l+1]=o(0,255*a(1,T)),G[l+2]=o(0,255*a(1,b)),G[l+3]=o(0,255*a(1,d))}return{w:C,h:B,rgba:G}}function rgbaToDataURL(t,e,a){let o=4*t+1,r=6+e*(5+o),l=[137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,t>>8,255&t,0,0,e>>8,255&e,8,6,0,0,0,0,0,0,0,r>>>24,r>>16&255,r>>8&255,255&r,73,68,65,84,120,1],s=[0,498536548,997073096,651767980,1994146192,1802195444,1303535960,1342533948,-306674912,-267414716,-690576408,-882789492,-1687895376,-2032938284,-1609899400,-1111625188],h=1,n=0;for(let t=0,r=0,s=o-1;t<e;t++,s+=o-1)for(l.push(t+1<e?0:1,255&o,o>>8,255&~o,o>>8^255,0),n=(n+h)%65521;r<s;r++){let t=255&a[r];l.push(t),h=(h+t)%65521,n=(n+h)%65521}l.push(n>>8,255&n,h>>8,255&h,0,0,0,0,0,0,0,0,73,69,78,68,174,66,96,130);for(let[t,e]of[[12,29],[37,41+r]]){let a=-1;for(let o=t;o<e;o++)a^=l[o],a=a>>>4^s[15&a],a=a>>>4^s[15&a];a=~a,l[e++]=a>>>24,l[e++]=a>>16&255,l[e++]=a>>8&255,l[e++]=255&a}return"data:image/png;base64,"+btoa(String.fromCharCode(...l))}function thumbHashToApproximateAspectRatio(t){let e=t[3],a=128&t[2],o=128&t[4];return(o?a?5:7:7&e)/(o?7&e:a?5:7)}images.forEach((t=>{const e=t.dataset.placeholderHash,a=t.dataset.src,o=thumbHashToDataURL(Uint8Array.from(atob(e),(t=>t.charCodeAt(0))));t.src=o;const r=new Image;r.src=a,r.onload=async()=>{t.src=a,t.classList.add("loaded")}}));