Kawałek Kodu

Czy substytut oryginału może dorównywać temu ostatniemu? Czy atrapa może być równie atrakcyjna? Czy niezamalowane kanwy obrazów mogą uratować właściciela galerii przed wpadką? O tym, no prawie o tym, w dzisiejszym wpisie.

Potrzeba matką placeholderów.

Ostatnio naszła mnie niezbędna potrzeba, aby zaimplementować na stronie funkcjonalność lazy loading. Przejrzałem kilka gotowych skryptów JavaScript i odniosłem wrażenie, że wszystkie opierają się na tym samym założeniu. Ale, ale, jeszcze kilka słów doszczegółowienia. O ile lazy loading samotnego tagu IMG nie stanowi większego problemu, to schody zaczynają się kiedy używamy paczki tagów PICTURE+SOURCE+IMG. A to dlatego, że:

  • poprzez media queries w SOURCE i ich źródła oraz źródło w IMG możemy ustalać różne proporcje naszych obrazów w zależności od np. szerokości ekranu,
  • w przypadku takiej paczki w tagu IMG nie dodajemy atrybutów width oraz height, dlatego, że nie mają one sensu, szczególnie właśnie w przypadku różnych proporcji zdjęć.

Nie dodając jednak atrybutów width oraz height, nie rezerwujemy miejsca na załadowany obrazek. Jeśli przechowamy źródła każdego obrazu w atrybucie data-src i data-srcset, tak, aby w momencie widoczności obrazu na ekranie (na którą czaiłby się Intersection Observer) użyć ich i wrzucić do src oraz srcset, to cały nasz wysiłek spełznie na niczym. Dlaczego? Bo kiedy nie ma ustalonych wymiarów obrazów i brak źródeł, to zdjęcie przyjmuje rozmiary 0x0, co oznacza, że musielibyśmy załadować wszystkie w jednym momencie. A chyba nie na tym polega lazy loading, prawda?

Jeśli natomiast dodalibyśmy atrybuty width oraz height, np. tak:

<picture>
  <source media="(max-width:960px)" srcset="https://picsum.photos/id/237/500/300" />
  <img src="https://picsum.photos/id/237/200/300" width="200" height="300" />
</picture>

to w przypadku szerokości ekranu <=960px obrazek o wymiarach 500x300 byłby ściśnięty do szerokości 200px. Zakładając, że to galeria przewijana w bok, to zaraz za tym obrazem (tym zwężonym) byłoby widać być może kolejny, choć jeszcze nie powinno. Po prostu to ściśnięcie nie jest nam na rękę, podobnie jak ścisk w autobusie w upalny, letni dzień.

A jeśli dodamy:

img {
  max-width: 100%;
  height: auto;
}

Co prawda obrazek nie będzie ściśniety, tzn. zachowa proporcje, ale ze względu na to, że ustaliliśmy wartość atrybutu width na 200px, to nasz obraz przy rozdzielczości <=960px będzie mieć właśnie taką szerokość, ale wysokość 120px (200/500=x/300). Czyli też słabo, bo pomimo, że obrazek będzie kształtny, to nie dość, że będzie pod nim albo nad nim dziura (bo jest za niski), to będzie również jak ten płaszczak, za wąski.

Jak nie urok, to zaślepka.

Chyba ten najbardziej popularny skrypt do lazy loading rozwiązywał ten problem w ten sposób, że ustalał minimalną wysokość kontenera na obrazek (jego rodzica) na 300px. Co wcale nie rozwiązywało problemu, bo wystarczyło użyć niższych obrazów, aby nie załadowały się wszystkie, które powinny być widoczne. Przykłady tego skryptu opierały się po prostu na dużej ilości obrazków jeden pod drugim i miały ładować się przy przewiajniu strony. Kiedy więc obraz miał np. 150px, a dostępna w przeglądarce przestrzeń 600px, to załadowały się dwa, a nie cztery zdjęcia, właśnie przez tą ustaloną wysokość 300px. Przy tym założeniu dwa pierwsze zdjęcia rezerwują przestrzeń, a dwa pozostałe są poza ekranem. Co gorsza nawet po załadowaniu zdjęć byłyby przerwy między nimi, bo nadrzędny element ma ustaloną minimalną wysokość. Oczywiście można usuwać tą właściwiość CSS z rodzica po załadowaniu obrazu, ale wtedy zacznie nam wszystko skakać po ekranie. O ile w przypadku dużych zdjęć (o wysokości >=300px) nie ma problemu, to przy małych lub na urządzeniach mobilnych wysokości mogą być mniejsze. A kiedy zdjęcia będą różnych proporcji, to załóżenie minimalnej wysokości (przy przewijaniu góra/dół) lub minimalnej szerokości (przy przewijaniu lewo/prawo), kompletnie nie zdaje egzaminu. Trzeba więc w jakiś sposób zarezerwować przestrzeń na obrazek o dokładnie takich proporcjach jako samo zdjęcie, jednocześnie nie ładując jego samego.

Powrót do źródła.

Nie rozwiążemy tego inaczej, jak tylko ustalając jakieś źródła dla naszych SOURCE+IMG. Ale przecież nie umieścimy tam docelowych plików, bo nie chcemy ich ładować. Aaaaaa! Błędne koło!
Użyjemy więc plików SVG. Kiedyś już pokazywałem jak świetnie sprawdzają się jako zaślepki do ustalania aspect ratio. I dziś wykorzystamy je w podobny sposób, tj. jako SVG inline.

Czyli zastosujem coś takiego:

<svg viewBox='0 0 300 200'></svg>

Ale oczywiście nie w takiej postaci, bo takiej nie możemy podstawić do src. Użyjem data URI, czyli:

<img src="data:image/svg+xml,<svg viewBox='0 0 300 200'></svg>" data-src="a tu docelowy plik"/>

Ale żeby nie było tak prosto musimy escapować:

  • < (%3C)
  • > (%3E)
  • ewentualny # (%23)
  • spację (%20)

W PHP dla trzech pierwszych używamy

Ze spacją jest o tyle ciekawostka, że bez jej escapowania możemy użyć takiego źródła (placeholder działa poprawnie), ale nie jest poprawnie walidowany w walidatorze w3.org. Tak więc:

<img src="data:image/svg+xml,%3Csvg%20viewBox='0%200%20300%20200'%3E%3C/svg%3E" data-src="a tu docelowy plik"/>

viewBox ustalamy na wymiary takie jakie ma oryginalne zdjęcie.

Ale żeby nie było jeszcze prosto, to sytuacja wygląda zupełnie inaczej dla atrybutu srcset elementu SOURCE! Tu dopiero dzieją się niezłe cyrki.

Powyższy kod SVG już umieszczony jako data URI, powinien wyglądać wtedy tak:

<source srcset="data:image/svg+xml,%3Csvg%20viewBox%3D%270%200%20300%20200%27%3E%3C%2Fsvg%3E" data-srcset="a tu docelowe źródło">

W tym przypadku wystarczy użyć PHP-owej funkcji rawurlencode. urlencode zamieniłoby spację na "+", a nie na "%20".

Należy pamiętać, że dla każdego SOURCE oraz IMG możemy używać zaślepek o różnych proporcjach, ale kiedy zdecydowalibyśmy się na użycie różnych proporcji w ramach jednego elementu SOURCE (srcset w powiązaniu z atrybutem sizes), to już metoda nie zadziała - bo mamy zaślepkę tylko o jednej proporcji. To już jednak przypadek raczej ekstremalny.

Nic nie stoi na przeszkodzie, aby kolorować zaślepki jednolitym kolorem, np. głównym z obrazka. Wtedy wystarczy dodać do kodu SVG element RECT (oczywiście przed escape'owaniem):

<rect width='100%' height='100%' fill='#FF0000'></rect>

Należy wtedy również dodać namespace struktury SVG jako atrybut tego elementu:

<svg xmlns='http://www.w3.org/2000/svg' ...

W przeciwnym razie element SVG będzie interpretowany jako XML i nie wyświetli się!

Jeszcze ciekawszym upiększeniem jest dodanie do SVG tekstu z wymiarem ładowanego obrazka, albo z jakimkolwiek tekstem. Przykład ten jest dostępny pod tym linkiem: https://cloudfour.com/thinks/simple-svg-placeholder/

Kody JS do lazy loading zmieniający nasze zaślepki w pełnoprawne obrazki może wyglądać tak:

/* leniwe obrazki
   do załadowania
*/
const images = document.querySelectorAll("img.lazy");

/* callback, używany
   kiedy zdjęcie będzie
   widoczne
*/
let onIntersection = function(entries) {
  entries.forEach(entry => {
    if (entry.intersectionRatio > 0) {
      observer.unobserve(entry.target);
      loadImage(entry.target);
    }
  });
};

/* funkcja ładowania
   obrazków, czyli wstawiająca
   data-src do src
   oraz data-srcset do srcset
*/
let loadImage = function(element) {
  if (element.dataset && element.dataset.src) {
/* usuwamy klasę,
   bo np. jakoś chcemy stylować
   obrazek po załadowaniu,
   usuwamy też obsługę zdarzenia
   load, bo nie chcemy, aby przy zmianie
   rozdzielczości znów była usuwana
   klasa "lazy" (po co)
*/
    element.onload = function() {
      this.classList.remove("lazy");
      this.onload = function() {};
    };

/* dla IMG wstawiamy src
*/
    element.src = element.dataset.src;

/* szukamy sąsiadów, czyli
   SOURCE i wstawiamy data-srcset
   do srcset
*/
    element
      .closest("picture")
      .querySelectorAll("source")
      .forEach(function(source) {
        if (source.dataset && source.dataset.srcset) {
          source.srcset = source.dataset.srcset;
        }
      });
  }
};

const config = {
  threshold: 0.01
};

/* inicjujemy obserwatora
*/
const observer = new IntersectionObserver(onIntersection, config);

/* i nakazujemy mu
   obserwowanie każdego
   leniwego obrazka
*/
images.forEach(function(image) {
  observer.observe(image);
});

O IntersectionObserver API pisałem więcej tu: https://kawalekkodu.pl/na-lenia-i-na-podgladacza-czyli-lazy-loading-youtubowych-filmow-z-uzyciem-intersectionobserver.
Powyższy skrypt również do ideałów nie należy, bo przy zmianie rozdzielczości lub orientacji ekranu, obrazki załadują się natychmiastowo (po załadowaniu zdjęcia za pierwszym razem przy startowej rozdzielczości, przestajemy je obserwować). Tu ewentualnym rozwiązaniem jest ładowanie do src elementu IMG odpowiedniego źródła, co wiąże się również z badaniem spełnienia warunku media query dla elementu SOURCE. Po prostu sprawdzamy, które źródło powinno w danym momencie się załadować. Wtedy wstawiamy je do IMG, a tylko w tym jednym SOURCE wstawiamy data-srcset do srcset (jeśli spełnia je jakieś SOURCE, a nie sam IMG). Jeżeli podmieniliśmy src oraz wszystkie srcset w danym elemencie, to dopiero wtedy usuwamy go z obserwatora. Oczywiście, to też nie jest takie proste, jeśli używamy w jednym SOURCE kilku źródeł.

Zaślepionych i niezaśleopionych zaślepkami, zapraszam na kolejny wpis. Do przeczytania!

 

Przydatne linki:
Lazy loading filmów z YouTube.
Placeholdery SVG z tekstem o wymiarach ładowanego zdjęcia.