Jak poprawnie wstawić obrazek na stronę WWW?

7

Wstęp

Poradnik na przykładach w PHP i JavaScript działających na serwerze z obsługą .htaccess/mod-rewrite (prawie każdy to ma).

W poniższym poradniku wyjaśnię w jaki sposób wstawić obrazek na stronę WWW. Celowo jednak pominę obrazy wektorowe bo to osobne zagadnienie zasługujące na osobny temat. Zostaje nam zetem grafika rastrowa - czyli formaty typu: jpeg, bmp, webp, png itp...

Czyli sprawę załatwia proste IMG SRC:

  <img src='moj-obrazek.jpg' alt='obrazek'>

Teoretycznie poradnik można w tym miejscu zakończyć.
Niby tak i wydaje się to być banalne jednak w praktyce niekoniecznie ponieważ pod uwagę trzeba wziąć jeszcze:

  1. optymalizację rozmiaru zdjęcia ( aby nie serwować zdjęć wprost "z aparatu" o rozmiarach rzędu 6000x4000);
  2. przygotowanie odpowiednich miniaturek dla wszelkiego rodzaju galerii;
  3. przygotowanie zdjęć dla różnych szerokości ekranów;
  4. umieszczenie zdjęcia w kontenerze trzymającym je w właściwym miejscu i obszarze strony;
  5. kadrowanie;
  6. zastosowanie mechanizmu lazy-load;
  7. cache'owanie miniaturek.

Trochę się tego nazbierało, choć to nie wszystko - na początek jednak wystarczy.

O ile w sensownych narzędziach CMS tego typu procesy wspierane są automatycznie lub poprzez specjalne pluginy to robiąc stronę od podstaw trzeba wszystko ogarnąć samemu. Ogarnięcie wszystkiego na pozór wydaje się być trudne ale nie ma się co bać. Większość problemów możemy rozwiązać pisząc prostą bibliotekę zawierającą kilka funkcji odpowiedzialnych za:

  • wczytanie obrazka do pamięci PHP;
  • skalowanie kadrowanie;
  • konwersję formatów;
  • zapisanie wyniku w pliku cache.

Omówmy jednak wszystko od początku...

Przygotowanie zdjęcia

Zdjęcie źródłowe - to podstawowy element, z którym będziemy pracować, powinno być wysokiej jakości i dużej rozdzielczości po to aby można było je zastosować zarówno w sliderze na całą szerokość ekranu jak i w małej zajawce. Zdjęcia źródłowego nigdy nie będziemy serwować wprost poprzez WWW.
Zawsze będziemy przygotowywać zdjęcie odpowiednio dostosowane do warunków jakie stawia projektowana strona WWW.

Dla formalności przypomnijmy jakie są podstawowe i ważne dla nas cechy zdjęcia:

  • rozmiar pliku ( wyrażany w bajtach, kilobajtach lub o zgrozo w megabajtach );
  • wysokość i szerokość wyrażana w pikselach;
  • proporcje wynikające ze stosunku wysokości do szerokości;
  • rodzaj kompresji - stratna lub bezstratna;
  • stopień kompresji (przekłada się na jakość);

Naszym celem podczas przygotowania odpowiednich miniatur do wstawienia na stronę będzie zawsze:

  • kadrowanie / obcięcie zdjęcia w taki sposób by dobrze wpasowywało się w projektowaną stronę https://pl.wikipedia.org/wiki/Kadrowaniewanie).
  • maksymalne zmniejszenie rozmiaru pliku jednak przy zachowaniu wymaganej jakości i ostrości,
  • zmniejszenie wysokości i szerokości do optymalnej dla przewidzianego obszaru, w którym zdjęcie będzie prezentowane.

Teoria brzmi super ale jak to zrobić w PHP, jak dokładnie powinna wyglądać biblioteka wspierająca, jak cache'ować?

Na potrzeby poradnika wyciągnąłem ze swojego niemal 15 letniego framework'a trochę kodu. Głównie interesuje nas klasa przygotowująca zdjęcia, która na wejściu przyjmuje następujące informacje:


DEFINE ( "CI_FORMAT", "webp" );
DEFINE ( "CI_IMG_CACHE_DIR", "assets-cache/" );
DEFINE ( "CI_PLACEHOLDER", "assets-src/img-placeholder.jpg" );
DEFINE ( "CI_PLACEHOLDER_FORMAT", "jpeg" );

class CacheImage{
  
  public $sourceUrl = '' ;           // Nazwa zdjęcia źródłowego lub adrres URL jeśli PHP wspiera CURL w funkcji file_get_contents.
                                     //
  public $width = 0 ;                // Parametry width oraz height określają jaki rozmiar obrazka otrzymamy po przeskalowaniu.
  public $height = 0 ;               // Parametry powiązane są z polem $aspectRatio
                                     // i w zależności od jego warości zachowują się różnie.
                                     //
  public $aspectRatio = true ;       // true - zachowuje proporcje i wpasowuje obraz w prostokąt
                                     // zadany przez parametry width i height lub wylicza automatycznie
                                     // gdy jedna z nich ma wartość = 0.
                                     //
                                     // false - skaluje obrazek do rozmiarów zadanych wprost przez 
                                     // parametry width i height bez zachowania proporcji - oba parametry muszą być > 0.
                                     //
                                     // 'cropping' - ze środka obrazu wycina fragment o rozmiarach zadanych przez parametry
                                     // width i height przy założeniu że wycinany jest zawsze największy moążiwy obszar.
                                     // Proporcje są zachowywane.
                                     //
  public $suggestedFileName = '' ;   // Sugerowana nazwa pliku cache/docelowego. Zostaną do niej doklejone informacje o parametrach
                                     // związanych z przekształceniami obrazu źródłowego.
                                     //
  public $defaultQuality = 60 ;      // Poziom kompresji.
                                     //
  public $useHtacces = true ;        // true - przygotowuje tylko plik nagłówkowy obrazka.
                                     // Fizyczne przekształcenia i jego odczyt następują dopieo po wywołaniu adresu pliku z przeglądarki.
                                     // W operacji tej niezbędny jest plik .htaccess odpowiednio obsługujący tego typu zapytania do serwera.
                                     // Ta opcja powinna być stosowana podczas przygotowywania obrazów na stronę WWW.
                                     //
                                     // false - obrazek przygotowywany jest w trakcie wykonywaniua metody get().
                                     // Ta opcja powinna być stosowana jeśli chcemy mieć gotowe przeskalowane obrazy natychmiast.

  function get()                     // Metoda zwraca nazwę przekształconego obrazka, kórą możemy użyć jako atrybut src w tagu <img>.
                                     //
  function setFormat( format )       // Metoda ustawia format docelowy obrazka. Możliwe wartości: 'webp', 'jpeg'.
  

Przykład użycia powyższej klasy:

<?php
  require_once ( 'CacheImageClass.php' );
  $image = new CacheImage();
  $image->sourceUrl = 'assets-src/img-src-1.jpg' ;
  $image->suggestedFileName = 'suggested-output-file-name-1';
  $image->width = 640;
  $image->height = 480;
  $image->aspectRatio = 'cropping' ;
  $imageUrl = $image->get();
  
  echo "<img src='{$imageUrl}' alt='My first resized image'>" ;

Efekt działania powyższego przykładu można zobaczyć pod adresem: https://xksi.pl/blog/guide-images-seo/example-minimum.php
Przykład z użyciem różnych kombinacji parametrów wejściowych: https://xksi.pl/blog/guide-images-seo/example-resize.php

Widać obrazki się skalują zatem mechanizm działa...

Na samym początku wspomniałem, że potrzebny nam będzie serwer z obsługą mod-rewrite/.htaccess i jest to niezmiernie ważna rzecz bowiem samo przygotowanie i cache'owanie miniaturek za pomocą wyżej przedstawionej biblioteki już jest jakimś progresem ale to jeszcze za mało żeby powiedzieć, że jest dobrze.

Nie rzuca się to w oczy w powyższych przykładach ale warto przyjrzeć się właściwości klasy:

  public $useHtacces = true ;  // true - przygotowuje tylko plik nagłówkowy obrazka(...)

Nie ma problemu by użyć powyższej klasy zmieniając tą właściwość na false. Wówczas nie będzie nam potrzebny plik .htaccess.
Skoro możemy działać bez niego to dlaczego tak sprawy nie zostawić?
Chodzi o to, że skalowanie obrazków jest procesem, który dość mocno obciąża zasoby serwera t.j. CPU, RAM oraz I/O i sam w sobie zajmuje trochę czasu.
Wyobraźmy sobie, że na stronie mamy 100 obrazków dla których źródłem są zdjęcia z aparatu cyfrowego. W takiej sytuacji przygotowanie 100 miniaturek może zająć nawet kilkadziesiąt sekund. Dlatego przygotowując te miniaturki nasz skrypt PHP nie może czekać aż wszystkie będą przygotowane ponieważ istnieje ryzyko, że klient zamiast podstrony z obrazkami zobaczy komunikat "408 Request Timeout" lub inny podobny... Co po chwili zastanowienia staje się całkowicie zrozumiałe.

Aby uniknąć takiej sytuacji klasa funkcja CacheImage->get() w trybie CacheImage->useHtacces = true nie przygotowuje fizycznej miniaturki a jedynie zapisuje na dysku "plik buforowy", w którym umieszcza informacje o miejscu obrazka źródłowego oraz parametry przekształceń, które chcemy wykonać dla obrazka docelowego.

Przykład:

<?php
  require_once ( 'CacheImageClass.php' );
  $image = new CacheImage();
  $image->sourceUrl = 'assets-src/img-src-1.jpg' ;             // lokalizacja obrazka źródłowego
  $image->suggestedFileName = 'suggested-output-file-name-1';  
  $image->width = 640;                                         // parametry przekształcenia
  $image->height = 480;                                        //
  $image->aspectRatio = 'cropping' ;                           //
  $imageUrl = $image->get();                                   // wywołanie funkcji get() powoduje jedynie 
                                                               // zapisanie pliku z serializowaną informacją
                                                               // w pliku o nazwie:
                                                               //   assets-cache/suggested-output-file-name-1-640-480-1.webp_buff
                                                               // zmienna $imageUrl ma wartość:
                                                               //   assets-cache/suggested-output-file-name-1-640-480-1.webp                               
  
  // -- na chwilę blokujemy : echo "<img src='{$imageUrl}' alt='My first resized image'>" ;

Uruchomienie powyższego kodu spowoduje jedynie utworzenie pliku o nazwie: assets-cache/suggested-output-file-name-1-640-480-1.webp_buff
i następującej zawartości:

a:9:{s:5:"width";i:640;s:6:"height";i:480;s:11:"aspectRatio";s:8:"cropping";s:9:"sourceUrl";s:24:"assets-src/img-src-1.jpg";s:8:"cacheDir";s:13:"assets-cache/";s:14:"defaultQuality";i:60;s:16:"defaultExtension";s:5:".webp";s:17:"suggestedFileName";s:28:"suggested-output-file-name-1";s:11:"imageFormat";s:4:"webp";}

Po deserializacji:

array (
  'width' => 640,
  'height' => 480,
  'aspectRatio' => 'cropping',
  'sourceUrl' => 'assets-src/img-src-1.jpg',
  'cacheDir' => 'assets-cache/',
  'defaultQuality' => 60,
  'defaultExtension' => '.webp',
  'suggestedFileName' => 'suggested-output-file-name-1',
  'imageFormat' => 'webp',
)

Przygotowanie fizycznej miniatury obrazka następuje dopiero po wywołaniu obrazka przez stronę www.
W tym celu możemy odkomentować w skrypcie linię:

echo "<img src='{$imageUrl}' alt='My first resized image'>" ;

lub wywołać zdjęcie bezpośrednio przez przeglądarkę wywołując adres:
http://[twoj-host]/jak-wstawic-obrazek/assets-cache/suggested-output-file-name-1-640-480-1.webp

Gdyby nie fakt, że w katalogu głównym mamy plik .htaccess to serwer zwróciłby kod 404 File not found ponieważ fizycznie na dysku nie ma pliku: assets-cache/suggested-output-file-name-1-640-480-1.webp
Jednak reguły mod-rewrite, które umieściliśmy w pliku .htaccess "przechwytują" wywołaną nazwę i wywołują skrypt PHP CacheImageGet.php (kod poniżej) z odpowiednimi parametrami:

RewriteEngine On

RewriteCond %{REQUEST_FILENAME} -f
RewriteRule ^assets-cache/(.*).webp$ assets-cache/$1.webp [L]

RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^assets-cache/(.*).webp$ CacheImageGet.php?imgName=assets-cache/$1&imageFormat=webp [L]

Omówmy znaczenie poszczególnych fragmentów pliku .htaccess

Włączenie modułu mod-rewrite:

RewriteEngine On

Sprawdzenie czy na dysku istnieje plik ze zdjęciem i jeśli istnieje to go "wywołujemy".
Może to wydawać się masłem maślanym ale tak nie jest. Na końcu wiersze przekierowującego plik na samego siebie mamy znak sterujący [L], który odpowiada za przerwanie dalszego przetwarzania pliku .htaccess.
W przypadku gdy nas plik jest mały to tą regułę można pominoąć w przypadku gdy w dalszej jego części będą realizowane dodatkowe zadania, nie związane z obrazami warto przerwać proces.

RewriteCond %{REQUEST_FILENAME} -f
RewriteRule ^assets-cache/(.*).webp$ assets-cache/$1.webp [L]

Najistotniejszy fragment, który odpowiada za przesłanie odpowiednich parametrów do skryptu PHP tworzącego obrazek.

RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^assets-cache/(.*).webp$ CacheImageGet.php?imgName=assets-cache/$1&imageFormat=webp [L]

Powyższe przekierowanie oznacza, że jeśli plik assets-cache/suggested-output-file-name-1-640-480-1.webp fizycznie nie istnieje na dysku to zostanie wywołany skrypt PHP z parametrami CacheImageGet.php?imgName=assets-cache/assets-cache/suggested-output-file-name-1-640-480-1.webp&imageFormat=webp

A w skrypcie tym znajdziemy prawie to samo co w przykładzie z tą różnicą, że:

  • parametry tworzonego obrazu wczytujemy z pliku buforowego, którego nazwę przekazujemy w parametrze imgName
  • wyłączmy tryb $image->useHtacces = false żeby obrazek utworzyć natychmiast w trakcie wykonywania skryptu,
  • plik zawiera jeszcze prostą obsługę sytuacji, w której plik bufora nie jest znaleziony.
<?php

  require_once ( 'CacheImageClass.php' );

  $name = filter_input ( INPUT_GET, 'imgName' );
  $imageFormat = filter_input ( INPUT_GET, 'imageFormat' ); 
  $bufferFileName = $name.'.'.$imageFormat.'_buff' ;  // <-- tu do nazwy doklejmy _buff
  
  if ( file_exists ( $bufferFileName ) ) {    
    $buffer = file_get_contents ( $bufferFileName );
    $buffer = unserialize ( $buffer );
    
    $imageFormat = $buffer [ 'imageFormat' ];
    $image = new CacheImage();
    $image->setFormat( $imageFormat );
    $image->width = $buffer['width'];
    $image->height = $buffer['height'];
    $image->sourceUrl = $buffer['sourceUrl'];
    $image->suggestedFileName = $buffer['suggestedFileName'];
    $image->aspectRatio = $buffer['aspectRatio'];
    $image->cacheDir = $buffer['cacheDir'];
    $image->useHtacces = false;
    $imageUrl = $image->get();  
    
    unset ( $image );
    unlink ( $name.'.'.$imageFormat.'_buff' );      
  } else {    
    http_response_code(404);
    $imageFormat = CI_PLACEHOLDER_FORMAT ;
    $imageUrl = CI_PLACEHOLDER ;    
  }

  // prepare headers and output
  if ( $imageFormat == 'jpeg' ) Header('Content-type: image/jpeg');
  if ( $imageFormat == 'webp' ) Header('Content-type: image/webp');    
  $secondsToCache = 3600*24*180 ; // 180 days
  $ts = gmdate("D, d M Y H:i:s", time() + $secondsToCache)." GMT";
  header("Expires: $ts");
  header("Pragma: cache");
  header("Cache-Control: max-age={$secondsToCache}");
  readfile ( $imageUrl );
  exit();

Sposobów na zarządzanie obrazkami i ich miniaturkami jest wiele jednak przedstawiona powyżej ma następujące zalety:

  1. W bazie stałych zasobów musimy przechowywać tylko jeden oryginał zdjęcia. Możemy go trzymać w bardzo dużej rozdzielczości by w przyszłości także go wykorzystać na większych ekranach.
  2. Miniatury zostaną utworzone jedynie dla tych obrazów, które faktycznie są oglądane za pośrednictwem strony WWW. W połączeniu z mechanizmem lazy-load mamy ogromne możliwości by zaoszczędzić miejsce niezbędne na cache.

Wada wg mnie jest tylko jedna... W przypadku gdy użytkownik wchodzi na stronę, dla której miniatury jeszcze nie zostały przygotowane to będzie musiał poczekać aż te zostaną przygotowane przez serwer. Jednak przy mądrze dobranym placehold'erze i z wykorzystaniem lazy-load nie będzie to dla niego zauważalne. Dotyczy to oczywiście tylko i wyłącznie pierwszego użytkownika, który odwiedza taką podstronę.

Umieszczenie zdjęcia na stronie WWW.

W części powyżej omówiliśmy jeden ze sposobów przygotowania zdjęcia oraz mechanizm optymalnego serwowania ich poprzez WWW.
Dotyczy to jednak pojedynczego zdjęcia nie uwzględniając tego, że chcemy zrobić serwis WWW, który jest responsywny.
Załóżmy, że chcemy zrobić serwis, który ma na głównej stronie zdjęcie, które będzie rozciągnięte na cały ekran. Szybko okaże się, że przy współczesnych monitorach musimy przygotować takie zdjęcie, które będzie ładnie wyglądało w rozdzielczości 4k... ale także będziemy je serwować na ekranie małego telefonu, którego szerokość wynosi 320px - różnica jest ponad 10-krotna. Całkowicie oczywiste jest, że dla wersji mobilnej nie możemy serwować zdjęcia 4k (choć nie raz takie wyczyny widziałem).

Wygląda na to, że jeśli chcemy zrobić to dobrze to musimy przygotować kilka miniaturek odpowiednio dostosowanych do różnych rozdzielności.
Na szczęście mamy już bibliotekę co przygotowuje miniaturki oraz dobrych ludzi, którzy dali w HTML możliwości reagowania na rozmiar obrazu, w którym wyświetlana jest nasza strona.

Zacznijmy "na pałę" i zbudujmy prostą stronę z dużym zdjęciem i kilkoma boksami.

<?php
  $view = "<html>
    <head>
      <title>How NOT to serve images - guide</title>
      <meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=5.0, minimal-ui, user-scalable=yes'>
      <link rel='stylesheet' type='text/css' href='css/style.css'>
      <link rel='stylesheet' type='text/css' href='css/example-resp-1.css'>
    </head>
    <body>
      <h1>My responsive page</h1>
      <img src='assets-src/img-src-2.jpg'>
      <p style='padding:20px;'>
        Some content. Some content. Some content. Some content. Some content. Some content. Some content. Some content. Some content. 
        Some content. Some content. Some content. Some content. Some content. Some content. Some content. Some content. Some content. 
        Some content. Some content. Some content. Some content. Some content. Some content. Some content. Some content. Some content. 
      </p>
      <div class='boxes'>
        <div class='box'>
          <img src='assets-src/img-src-4.jpg' alt='Product 1 promotion'>
          <h2>Product 1</h2>
        </div>
        <div class='box'>
          <img src='assets-src/img-src-5.jpg' alt='Product 2 promotion'>
          <h2>Product 2</h2>
        </div>
        <div class='box'>
          <img src='assets-src/img-src-6.jpg' alt='Product 3 promotion'>
          <h2>Product 3</h2>
        </div>
        <div class='box'>
          <img src='assets-src/img-src-7.jpg' alt='Product 4 promotion'>
          <h2>Product 4</h2>
        </div>
        <div class='box'>
          <img src='assets-src/img-src-8.jpg' alt='Product 5 promotion'>
          <h2>Product 5</h2>
        </div>
        <div class='box'>
          <img src='assets-src/img-src-9.jpg' alt='Product 6 promotion'>
          <h2>Product 6</h2>
        </div>
      </div>
      <p style='padding:20px;'>
        Some content. Some content. Some content. Some content. Some content. Some content. Some content. Some content. Some content. 
        Some content. Some content. Some content. Some content. Some content. Some content. Some content. Some content. Some content. 
        Some content. Some content. Some content. Some content. Some content. Some content. Some content. Some content. Some content. 
      </p>
    </body>
  </html>";
  
  echo $view;

Działanie powyższego kodu pod linkiem: https://xksi.pl/blog/guide-images-seo/example-resp-1.php
Kod CSS dla powyższego HTML: https://xksi.pl/blog/guide-images-seo/css/example-resp-1.css

Teoretycznie strona jest gotowa i zadziała zarówno na desktopie jak i na małym telefonie komórkowym. Warto jednak zwrócić uwagę, że serwowane zdjęcie assets-src/img-src-2.jpg ma rozdzielczość 4800 x 3201px a wielkość pliku to 7,8MB - ale jest ładne ostre klientowi będzie się podobało:-)

Jakie problemy wystąpią z powyższym rozwiązaniem?

  1. PageSpeed Insights ocenia naszą stronę na ledwo 80% a my przecież mamy tu tylko jedno zdjęcie w głównym widoku... a klient chce jeszcze slider (chyba oszalał)!
  2. Zdjęcia choć na desktopie pokazuje się poprawnie to przy innych rozdzielczościach proporcje nie są zachowane co razi w oczy.
  3. Na telefonie komórkowym na sieci 3G strona ładuje się nieprzyzwoicie powoli.

screenshot-20220603123955.png screenshot-20220603124000.png

Jak to wszystko naprawić? Trzeba wykonać kilka kroków...

  1. Złapać zdjęcie w kontener żeby móc ustawić pozycjonowanie w ramach tego kontenera.
  2. Przygotować miniatury odpowiednie dla różnych rozdzielczości.

Zatem z "grubej rury" robimy całość tak jak ma być.

Dla poprawienia czytelności docelowego kodu tworzę funkcję pomocniczą, którą umieszczam w pliku CacheImage.php:

<?php
  require_once ( 'CacheImageClass.php' );
  function getImages( $imagesConfig, $sourceUrl, $suggestedFileName ){    
    $out = array();
    foreach ( $imagesConfig as $key => $imgCfg ){
      $image = new CacheImage() ;
      $image->width = $imgCfg['width'] ;
      $image->height = $imgCfg['height'] ;
      $image->aspectRatio = $imgCfg['aspectRatio'] ;
      $image->sourceUrl = $sourceUrl ;
      $image->suggestedFileName = "{$suggestedFileName}_{$key}" ;
      $image->getCacheFileName() ;
      $out [ $key ] = $image->get() ;      
    }     
    return $out ;
  }

Funkcja getImages() nie robi nic nowego ani magicznego - zajmuje się przygotowaniem wielu miniaturek na podstawie tablicy parametrów przekazanych w parametrze.
Tworzę także funkcję, która będzie zwracała HTML dla jednego boksu. Warto bo to kod, który się powtarza.

<?php
  require_once ( 'CacheImage.php' ); 

  function getBox ( $title, $srcImage ){    
    $tmpImages = getImages (
      [
        'a' => [ 'width'=> 480, 'height'=>320,  'aspectRatio' => 'cropping' ],
        'b' => [ 'width'=> 320, 'height'=>250,  'aspectRatio' => 'cropping' ],        
      ],                                                            
      $srcImage,
      'box-'.md5( $title.$srcImage )
    );
    
    $view = "<div class='box'>
      <picture>
        <source media='(max-width:1040px)' srcset='{$tmpImages['a']}'>
        <img src='{$tmpImages['b']}' alt='{$title}'>
      </picture>
      <h2>{$title}</h2>
    </div>";
    return $view;
  }

  // Prepare 6 boxes
  $boxes = "" ;
  $boxes .= getBox ( 'Produkt 1', 'assets-src/img-src-4.jpg' );
  $boxes .= getBox ( 'Produkt 2', 'assets-src/img-src-5.jpg' );
  $boxes .= getBox ( 'Produkt 3', 'assets-src/img-src-6.jpg' );
  $boxes .= getBox ( 'Produkt 4', 'assets-src/img-src-7.jpg' );
  $boxes .= getBox ( 'Produkt 5', 'assets-src/img-src-8.jpg' );
  $boxes .= getBox ( 'Produkt 6', 'assets-src/img-src-9.jpg' );

  // Prepare image for slider
  $images = getImages (
    [
      'a' => [ 'width'=> 320, 'height'=>600,  'aspectRatio' => 'cropping' ],
      'b' => [ 'width'=> 480, 'height'=>640,  'aspectRatio' => 'cropping' ],
      'c' => [ 'width'=> 640, 'height'=>800,  'aspectRatio' => 'cropping' ],
      'd' => [ 'width'=>1024, 'height'=>768,  'aspectRatio' => 'cropping' ],
      'e' => [ 'width'=>1903, 'height'=>1200, 'aspectRatio' => 'cropping' ],
    ],                                                            
    'assets-src/img-src-2.jpg',
    'slider_main_1'
  );
  
  $view = "<html lang='pl'>
    <head lang=pl>
        <title>How to serve images - guide</title>
        <meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=5.0, minimal-ui, user-scalable=yes'>
        <link rel='stylesheet' type='text/css' href='css/style.css'>
        <link rel='stylesheet' type='text/css' href='css/example-resp-2.css'>
    </head>
    <body>
      <div class='main-slider'>     
        
        <picture>
          <source media='(max-width: 320px)' srcset='{$images['a']}'>
          <source media='(max-width: 480px)' srcset='{$images['b']}'>
          <source media='(max-width: 640px)' srcset='{$images['c']}'>
          <source media='(max-width:1024px)' srcset='{$images['d']}'>      
          <img src='{$images['e']}' alt='My responsive image.'>
        </picture>
        
        <h1>My responsive page</h1>
        
      </div>

      <p style='padding:20px;'>
        Some content. Some content. Some content. Some content. Some content. Some content. Some content. Some content. Some content. 
        Some content. Some content. Some content. Some content. Some content. Some content. Some content. Some content. Some content. 
        Some content. Some content. Some content. Some content. Some content. Some content. Some content. Some content. Some content. 
      </p>
      
      <div class='boxes'>
        {$boxes}
      </div>

      <p style='padding:20px;'>
        Some content. Some content. Some content. Some content. Some content. Some content. Some content. Some content. Some content. 
        Some content. Some content. Some content. Some content. Some content. Some content. Some content. Some content. Some content. 
        Some content. Some content. Some content. Some content. Some content. Some content. Some content. Some content. Some content. 
      </p>
      
    </body>
  </html>";  

  echo $view;

Działanie powyższego kodu pod linkiem: https://xksi.pl/blog/guide-images-seo/example-resp-2.php
Kod CSS dla powyższego HTML: https://xksi.pl/blog/guide-images-seo/css/example-resp-2.css

Co się stało?

  1. Obrazek IMG został zastąpiony tagiem PICTURE, który ma między innymi tą super właściwość, że w zależności od rozdzielczości ekranu dociągnie z serwera odpowiedni wskazany dla niej obrazek.
  2. IMG wraz z PICTURE zostały złapane w kontener <div class='main-slider'> co pozwana na takie osadzenie obrazka, że nie będą psute jego proporcje podczas skalowania.
  3. Obrazki mimo źródeł w JPG są serwowane jako WEBP.
  4. Wynik w pageSpeed Insight jest zadowalający.

screenshot-20220603124841.png screenshot-20220603124848.png

Podsumowanie

Kody źródłowe wykorzystane w powyższych przykładach dostępne są w repozytorium GIT:
https://bitbucket.org/xksi/blog-images-php/src/master/

Warto zwrócić też uwagę na nagłówki HTTP odpowiadające za cache-control... ale to szerszy temat więc nie będę go tu zgłębiał.

Jeśli się spodobało to opracuję kolejne tematy. W kolejnym kroku aż prosi się omówić lazy-load oraz przerobić wielkie główne zdjęcie na wielki slider wciąż nie tracąc punktacji 100/100/100 :-)

Ciekawostki

Na koniec dodam, że jeśli nie chcemy samodzielnie walczyć z wiatrakami to dostępne są w sieci różne Media API, które będą wspierać nas w takich obszarach jak:

  1. Tworzenie optymalnych miniaturek.
  2. Automatyczne kadrowanie zdjęć z uwzględnieniem wyboru kluczowych miejsc ze zdjęcia np. poprzez wykrywanie twarzy albo istotnych obiektów. Zwykle są to algorytmy działające w oparciu o sztuczną inteligencję.
  3. Serwowanie zdjęć z chmury
    itd ...

Jednym z takich rozwiązań jest np.: https://cloudinary.com/ albo więcej można znaleźć np. pod adresem: https://www.g2.com/products/cloudinary/competitors/alternatives

1

Brakuje wisienki na torcie czyli lazy-load

Osiągnęliśmy już dobry wynik testu ale warto dopracować temat zdjęć do końca.
Choć już wszystkie zdjęcia mamy rozmiarami dopasowane do potrzeb rozdzielczości to nie ma sensu aby przeglądarka wywoływała i pobierała je wszystkie.
Może się zdarzyć sytuacja, że użytkownik serwisu nie zostanie zachęcony głównym banerem do przewinięcia strony w dół, zatem generowanie miniaturek oraz ich transfer z serwera nie mają sensu - są stratą czasu i zasobów.

Tutaj z pomocą przyjdzie nam mechanizm który nazywa się lazy-load.

Jak działa lazy-load?

Mechanizm ten implementowany jest w JavaScript i do jego implementacji można wykorzystać wbudowany w większość nowoczesnych przeglądarek obiekt Intersection Observer API (zachęcam do lektury: https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API).
Działa on w taki sposób, że możemy mu zlecić aby obserwował on wskazane elementy na stronie www i w sytuacji kiedy pojawią się one w obszarze ekranu (lub w okolicy - to kwestia konfiguracji) to zostanie wywołane zdarzenie, które możemy po swojemu obsłużyć. Samo API można stosować także do innych celów ale ta właściwość nam wystarczy by zaimplementować prosty mechanizm lazy-load.

Moja implemtacja wygląda tak:

  class uiLazyLoad{
    constructor(){
      this.init();
    }    
    init(){
      this.lazyImages = [].slice.call ( document.querySelectorAll ( "[data-lazy]" ) );
      this.lazyImageObserver = new IntersectionObserver( ( entries ) => {
        entries.forEach( (entry) => {
          if ( entry.isIntersecting ) {
            let lazyImage = entry.target;
            if ( lazyImage.dataset.lazy !== undefined ){
              lazyImage.src = lazyImage.dataset.lazy ;
              lazyImage.removeAttribute ( "data-lazy" );
              this.lazyImageObserver.unobserve(lazyImage);
            }
          }
        });
      });
      this.lazyImages.forEach ( (lazyImage) => this.lazyImageObserver.observe ( lazyImage ) );        
    }
  }

Co toto powyżej robi?
Nic szczególnego. Wyszukuję w drzewie DOM wszystkie elementy, które mają atrybut data-lazy i dodaję je do Observera.
W przypadku kiedy obraz znajdzie się w obszarze "widzenia" wówczas wartość atrybutu data-lazy jest przepisywana do atrybutu src co spowoduje podmianę obrazka.

Podmianę obrazka! Skoro podmianę to znaczy, że musimy mieć 2 obrazki?
Tak. Potrzebny nam jest wstępny placeholder oraz obrazek docelowy.

Dopóki na naszej przykładowej stronie nie mamy slidera to nasz mechanizm zastosujemy jedynie dla zdjęć w boksach.
Na początek uproszczę temat i tymczasowo oleję obsługę obrazków responsywnych. Boksy są dość małe więc śmiało bez uszczerbku na wyniku testu możemy zastosować jeden obrazek dla wszystkich rozdzielczości.

Zatem dalej modyfikujemy nasz kod.
Na początek na końcu sekcji body doczytamy sobie skrypt a następnie odpowiednio go zainicjujemy:

  <script src='js/uiLazyLoad.js'></script>
  <script>
    window.addEventListener( 'load',()=>{
      //  Lazy load init.
      var LazyLoad = new uiLazyLoad();
    });
  </script>

Następnie nieco przerobimy naszą funkcję getBox(), które generuje nam content boksów w taki sposób by jako domyślny obrazek był wstawiony mały wektorowy placeholder z pliku placeholder.svg.

  function getBox ( $title, $srcImage ){    
    $tmpImages = getImages (
      [
        'fullImage'   => [ 'width'=> 480, 'height'=>320,  'aspectRatio' => 'cropping' ],        
      ],                                                            
      $srcImage,
      'box-'.md5( $title.$srcImage )
    );
    
    $view = "<div class='box'>
      <img src='assets-src/placeholder.svg' data-lazy='{$tmpImages['fullImage']}' alt='{$title}'>
      <h2>{$title}</h2>
    </div>";
    return $view;
  }

Działanie powyższego kodu pod linkiem: https://xksi.pl/blog/guide-images-seo/example-resp-3.php

Jeśli obrazki nie zostały jeszcze pobrane przez naszą przeglądarkę to widać, że w każdym boxie umieszczony jest placeholder a dopiero po chwili pojawiają się właściwe obrazki.

screenshot-20220603154117.png

Efekt ten można nieco "podkręcić" tak aby placeholder aż tak bardzo nie rzucał się w oczy. Zamiast niego można zastosować mocno zmniejszone miniaturki docelowych obrazków. Wówczas kod naszej funkcji generującej box wyglądać będzie w sposób następujący:

  function getBox ( $title, $srcImage ){    
    $tmpImages = getImages (
      [
        'placeholder' => [ 'width'=> 24,  'height'=>16,   'aspectRatio' => 'cropping' ],
        'fullImage'   => [ 'width'=> 480, 'height'=>320,  'aspectRatio' => 'cropping' ],        
      ],                                                            
      $srcImage,
      'box-'.md5( $title.$srcImage )
    );
    
    $view = "<div class='box'>
      <img src='{$tmpImages['placeholder']}' data-lazy='{$tmpImages['fullImage']}' alt='{$title}'>
      <h2>{$title}</h2>
    </div>";
    return $view;
  }

Działanie powyższego kodu pod linkiem: https://xksi.pl/blog/guide-images-seo/example-resp-4.php

Teraz widać, że zamiast szarego placeholdera pokazuje się rozmyty obraz docelowy. Rozwiązanie kosztuje wygenerowanie kolejnego obrazka ale ma taką zaletę, że jest przyjemniejsze w odbiorze dla użytkownika serwisu. Wyostrzanie obrazu mniej kłuje w oczy niż jego całkowita podmiana.

screenshot-20220603154129.png

Podsumowanie

Mechanizm lazy-load można rozbudowywać na wiele sposobów. Do wstępnego przeładowania obrazu można wykorzystać nie tylko fakt pojawienia się obrazka na ekranie ale także:

  • zbliżenie się użytkownika do elementu / stopień przewinięcia ekranu (pozycja scroll),
  • zdarzenie mouseOver na przycisku, który powoduje akcję pojawienia się obrazka na ekranie,
    inne... w zależności od potrzeby i sytuacji.

W chwili obecnej nasz mechanizm lazy-load nie obsługuje wczytywania obrazków w zależności od rozdzielczości ekranu. W tym konkretnie przypadku to nie jest problem ale jeśli chcielibyśmy leniwie doczytywać slajdy do głównego zdjęcia wówczas modyfikacja kodu JavaScript klasy LazyLoad będzie konieczna.

1

Uzupełnienie informacji o Cache-control

Temat nagłówków to temat rzeka i w przypadku zdjęć też go dotykamy.

Jednym z czynników mających wpływ na punktację w narzędziu PageSpeed Insight jest informacja przekazywana do przeglądarki w nagłówkach HTTP.
Chodzi o nagłówki Expires oraz Cache-control. Najogólniej rzecz ujmując tymi nagłówkami przekazujemy z serwera do przeglądarki informację o tym jak długo zdjęcie może pobierać z własnego cache nim ponownie poprosi o nie serwer. Oczywiście nasza informacja nie ma wpływu na lokalne ustawienia przeglądarki albo to, że użytkownik wyczyści cache ręcznie. Warto jednak podawać te czasy jak najdłuższe bo statystycznie znacząco zmniejsza to ruch do naszego serwera.
Jedyny nieprzyjemny efekt uboczny długich czasów cache jest taki, że jeśli na swojej stronie wybrane zdjęcia chcemy często zmieniać to musimy pamiętać by zmieniać nazwy tych obrazków lub dodawać do nich jakiś znacznik czasowy.

W pliku CacheImageGet.php odpowiadającym za pierwsze przygotowanie obrazka zostały umieszczone zaraz przed wysłaniem jego zawartości:

  // prepare headers and output
  if ( $imageFormat == 'jpeg' ) Header('Content-type: image/jpeg');
  if ( $imageFormat == 'webp' ) Header('Content-type: image/webp');    
  $secondsToCache = 3600*24*180 ; // 180 days
  $ts = gmdate("D, d M Y H:i:s", time() + $secondsToCache)." GMT";
  header("Expires: $ts");
  header("Pragma: cache");
  header("Cache-Control: max-age={$secondsToCache}");
  readfile ( $imageUrl );
  exit();

Natomiast dla plików już utworzonych w katalogu z cache nagłówki te obsługiwane są przez plik .htaccess i odpowiada na nie fragment kodu:

<IfModule mod_headers.c>
  <FilesMatch "\.(jpg|jpeg|png|gif|swf|JPG|mp4|svg|webp)$">
    Header set Cache-Control "max-age=9936000, public"
  </FilesMatch>
  <FilesMatch "\.(css|js)$">
    Header set Cache-Control "max-age=9936000, private"
  </FilesMatch>
</IfModule>

Jak można się domyślić powyższy fragment kodu wymaga aby serwer obsługiwał mod_headers (większość komercyjnych, które spotkałem ma to włączone).

1 użytkowników online, w tym zalogowanych: 0, gości: 1