Kamil Owczarek
Opublikowano

Atrybut sizes to najskuteczniejsza optymalizacja obrazów, której nie używasz

Autorzy

315 obrazów, zero optymalizacji

Zrobiliśmy wszystko dobrze. Przynajmniej tak nam się wydawało.

Nasz pipeline obrazów szedł przez CDN z automatyczną konwersją do WebP, dostrajaniem jakości i skalowaniem w locie. Każdy URL obrazka przyjmował parametry szerokości i jakości. Infrastruktura była solidna. Wyniki Lighthouse mówiły co innego.

Podczas rutynowego audytu wydajności na naszej platformie e-commerce otworzyliśmy DevTools na stronie listingu produktów i spojrzeliśmy na zakładkę Network przefiltrowaną po obrazach. Siatka miniatur produktów — każda wyświetlana w około 300px szerokości na desktopie i 180px na mobile — pobierała obrazy o szerokości 1380px. Co do jednego.

Sprawdziliśmy baner hero. Ten sam problem. Baner był wyświetlany na pełną szerokość na desktopie (około 1400px), ale na ekranie telefonu o szerokości 390px przeglądarka nadal pobierała wersję 1380px. Na ekranie retina 2x pobierała wersję 2760px.

Sprawdziliśmy stronę marketingową. Ten sam problem. Trzydzieści pięć obrazów, wszystkie pobierane w maksymalnym rozmiarze natywnym, niezależnie od tego, jak były wyświetlane.

W obu aplikacjach naliczyliśmy około 315 wystąpień obrazów. Ani jeden nie był serwowany w rozmiarze właściwym dla viewportu. Nasz CDN generował idealnie zoptymalizowane obrazy w każdej szerokości, o jaką poprosiliśmy — a przeglądarka za każdym razem prosiła o złą szerokość.

Dlaczego sam srcset nie wystarczy

Źródłem problemu było coś, co fundamentalnie źle rozumieliśmy w działaniu obrazów responsywnych.

Nowoczesne komponenty obrazów (w tym ten z Nuxt Image v2, którego używamy) generują atrybut srcset z listą tego samego obrazka w wielu szerokościach:

<!-- What our component was generating WITHOUT sizes -->
<img
  srcset="/image.webp?w=640 1x, /image.webp?w=1280 2x"
  src="/image.webp?w=640"
  width="640"
/>

Bez atrybutu sizes biblioteka obrazów cofa się do deskryptorów gęstości (1x, 2x). To mówi przeglądarce: "wybierz obrazek wyłącznie na podstawie device pixel ratio". Urządzenie 1x dostaje obrazek 640px. Urządzenie retina 2x dostaje 1280px. Faktyczny rozmiar wyświetlania obrazka na stronie jest kompletnie nieistotny.

To kluczowa różnica, którą większość developerów przeocza. Istnieją dwa typy deskryptorów w srcset i zachowują się bardzo różnie:

Typ deskryptoraPrzykładLogika wyboru w przeglądarce
Gęstość (1x, 2x)image.webp 1x, image-2x.webp 2xWybiera wyłącznie na podstawie device pixel ratio
Szerokość (300w, 600w)image.webp 300w, image.webp 600wWybiera na podstawie szerokości viewportu ORAZ device pixel ratio

Deskryptory szerokości są bezdyskusyjnie inteligentniejsze. Ale jest haczyk: przeglądarka użyje deskryptorów szerokości tylko wtedy, gdy obecny jest atrybut sizes. Bez sizes przeglądarka nie ma pojęcia, jak szeroko obrazek zostanie wyrenderowany, więc nie może podjąć rozsądnej decyzji. Cofa się do jedynego sygnału, jaki ma — gęstości pikseli.

Tak wygląda ten sam obrazek, gdy podamy sizes:

<!-- What our component generates WITH sizes -->
<img
  srcset="
    /image.webp?w=320 320w,
    /image.webp?w=640 640w,
    /image.webp?w=960 960w,
    /image.webp?w=1280 1280w,
    /image.webp?w=1920 1920w
  "
  sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
  src="/image.webp?w=640"
/>

Teraz przeglądarka wie: "na ekranach do 640px ten obrazek zajmuje pełną szerokość. Do 1024px zajmuje połowę. Powyżej — jedną trzecią". Mnoży wyliczoną szerokość wyświetlania przez device pixel ratio i wybiera najbliższe dopasowanie z srcset. Telefon 390px przy 2x prosi o wariant 960w (390 x 2 = 780, najbliższe dopasowanie to 960). Desktop 1440px przy 1x prosi o wariant 640w (1440 x 0,33 = 475, najbliższe dopasowanie to 640).

Matematyka, która zmusiła nas do działania

Przeliczyliśmy typową stronę listingu produktów, żeby zrozumieć skalę marnotrawstwa. Strona pokazuje siatkę, która przechodzi z 2 kolumn na mobile do 4 kolumn na desktopie, z 12 produktami widocznymi nad linią zgięcia.

ScenariuszPobrany obrazPotrzebny obrazMarnotrawstwo na obraz
Mobile (390px, 2x), siatka 2-kolumnowa1280px (gęstość 2x)390px (195px x 2)890px za szeroko
Tablet (768px, 2x), siatka 3-kolumnowa1280px (gęstość 2x)512px (256px x 2)768px za szeroko
Desktop (1440px, 1x), siatka 4-kolumnowa640px (gęstość 1x)360px280px za szeroko

Rozmiar pliku obrazka skaluje się mniej więcej z kwadratem szerokości (dwa razy szerszy obrazek to cztery razy więcej pikseli). Obrazek WebP 1280px przy jakości 75 ważył około 120-180 KB. Poprawnie zwymiarowana wersja 390px tego samego obrazka ważyła około 15-25 KB. Pomnóż to przez 12 miniatur produktów nad linią zgięcia, a okaże się, że przesyłaliśmy około 1,5 MB danych obrazkowych zamiast około 250 KB.

To ponad megabajt zbędnych danych przy każdym wczytaniu strony na mobile. Dla strony, która ładuje się w 2,5 sekundy na dobrym łączu, samo marnotrawstwo na obrazach potrafiło odpowiadać za 800 ms na typowym połączeniu 4G.

Naprawa: jeden prop, 315 wystąpień

Nasz komponent obrazka to wrapper na Nuxt Image. Przyjmował opcjonalny prop sizes i przekazywał go do komponentu pod spodem. Naprawa miała dwie części.

Część 1: uczynić sizes propem wymaganym. Zmieniliśmy definicję TypeScript z opcjonalnej na wymaganą:

// Before
defineProps<{
  src?: string
  sizes?: string  // optional -- easy to forget
}>()

// After
defineProps<{
  src?: string
  sizes: string  // required -- TypeScript enforces it
}>()

Ta jedna zmiana sprawiła, że TypeScript oznaczył każde wystąpienie obrazka w obu aplikacjach, w którym brakowało tego propa. Kompilator stał się naszym narzędziem audytowym.

Część 2: dodać poprawną wartość sizes we wszystkich 315 wystąpieniach. To była żmudna część, ale logika była prosta. Przy każdym obrazku zadawaliśmy jedno pytanie: "jak szeroki jest ten obrazek na każdym breakpoincie?".

Odpowiedź brała się wprost z CSS. Oto wzorce, na jakie trafiliśmy:

Full-width heroes and banners:
  sizes="100vw"

Grid with 2 cols on mobile, 3 on tablet, 4 on desktop:
  sizes="50vw sm:33vw lg:25vw"

Fixed-size icons (56px square):
  sizes="56px"

Logo that changes size at breakpoints:
  sizes="128px md:208px"

Product card in a carousel:
  sizes="85vw sm:45vw md:30vw xl:22vw"

Small flag icons:
  sizes="28px"

Użyta składnia to skrót, w którym nazwy breakpointów mapują się na szerokości CSS (sm=640px, md=768px, lg=1024px, xl=1280px). W finalnym HTML rozwija się to do standardowej składni media query.

Kluczowy wniosek jest taki, że wartość sizes powinna odzwierciedlać twój layout w CSS, a nie natywne wymiary obrazka. Jeśli twój CSS mówi grid-cols-4 na breakpoincie lg, twoje sizes powinno mówić lg:25vw. Jeśli obrazek zawsze renderuje się w szerokości 56 pikseli niezależnie od rozmiaru ekranu, twoje sizes powinno mówić 56px.

Najczęstszym błędem, jaki widzieliśmy podczas audytu, było domyślne wpisywanie natywnej szerokości obrazka. Zdjęcie produktu może mieć 800px w pełnej rozdzielczości, ale jeśli wyświetla się w czterokolumnowej siatce, zajmuje najwyżej 25% szerokości viewportu. Atrybut sizes powinien mówić 25vw, a nie 800px. Przeglądarka pomnoży 25% viewportu przez device pixel ratio i wybierze najbliższego kandydata z srcset. Na desktopie 1440px przy 1x to 360px. Na telefonie 390px przy 2x to 195px. Oba są dramatycznie mniejsze niż natywne 800px, a różnica w jakości obrazu przy takich rozmiarach wyświetlania jest niezauważalna.

Odkryliśmy też, że dynamiczne wymiary wymagają dynamicznych wartości sizes. Nasz komponent wyboru koloru wykończenia renderuje próbki w 56px albo 48px, zależnie od propa. Atrybut sizes też musiał być reaktywny — powiązany z tym samym warunkiem, który steruje renderowaną szerokością. Statyczne sizes na dynamicznych obrazkach to kolejne częste źródło rozjazdu.

Linki preload też były zepsute

Naprawiając atrybut sizes na tagach obrazków, odkryliśmy drugi problem: nasze linki preload miały ten sam błąd.

Dla obrazów nad linią zgięcia (takich jak baner hero) wstrzykujemy link preload do head dokumentu, żeby przeglądarka zaczęła pobierać obrazek, zanim sparsuje HTML i natrafi na tag obrazka. Nasza implementacja preloadu wyglądała tak:

// Before: preload always fetched the full-size image
useHead({
  link: [{
    rel: 'preload',
    as: 'image',
    fetchpriority: 'high',
    href: getFullSizeImageUrl(src),  // always the biggest version
  }]
})

Oznaczało to, że link preload pobierał na mobile obrazek 1380px, a potem, gdy przeglądarka docierała do tagu obrazka (już z poprawnym sizes), wyliczała, że tak naprawdę potrzebuje obrazka 390px. Wstępnie pobrany obrazek szedł do kosza. Przeglądarka pobierała poprawny, mniejszy obrazek osobno.

Naprawiliśmy to, używając na linku preload atrybutów imagesrcset i imagesizes, dopasowanych do tego samego srcset i sizes, których używa tag obrazka:

// After: preload respects sizes
useHead({
  link: [{
    rel: 'preload',
    as: 'image',
    fetchpriority: 'high',
    imagesizes: computedSizes,    // same as the img sizes attribute
    imagesrcset: computedSrcset,  // same as the img srcset attribute
  }]
})

Teraz link preload i tag obrazka są zgodne co do tego, który wariant pobrać. Przeglądarka pobiera poprawnie zwymiarowany obrazek podczas preloadu i wykorzystuje go ponownie, gdy natrafi na tag obrazka.

Problem nadużywania preloadu

Gdy mechanizm preloadu był już naprawiony, znaleźliśmy trzeci problem: zbyt wiele obrazów było oznaczonych do preloadu.

Na stronie głównej mieliśmy sześć obrazów z preload i fetchpriority="high": baner hero, logo nałożone na baner, trzy ikony SVG sekcji feature poniżej linii zgięcia oraz logo w mobilnym menu bocznym. Wszystkie sześć konkurowało o wczesne pasmo.

Podpowiedź fetchpriority="high" mówi przeglądarce, żeby priorytetyzowała zasób, ale kiedy wszystko ma wysoki priorytet, nie ma go nic. Przeglądarka dzieliła swój wczesny budżet pasma na sześć preloadów zamiast poświęcić go temu jednemu, który faktycznie miał znaczenie — banerowi hero, czyli elementowi Largest Contentful Paint (LCP).

Usunęliśmy preload z pięciu z sześciu obrazów, zostawiając go tylko na banerze hero. Ikony SVG poniżej linii zgięcia nie potrzebują preloadu — nie są widoczne, dopóki użytkownik nie przescrolluje. Logo w mobilnym menu bocznym siedzi za interakcją z hamburgerem. Logo nałożone na baner to element drugorzędny, który nie wpływa na czas LCP.

Co zmieniło się w praktyce

Łączny efekt tych trzech zmian — poprawnego sizes na wszystkich obrazach, responsywnych linków preload i oszczędnego użycia preloadu — był wyraźnie widoczny w waterfallu sieciowym.

Przed (mobile, strona listingu produktów):

  • 12 miniatur produktów po 1280px szerokości: ~1,8 MB łącznie
  • Baner hero preloadowany w 1380px: ~250 KB
  • 5 dodatkowych zbędnych preloadów walczących o pasmo
  • Obrazek LCP opóźniony przez rywalizację preloadów

Po (mobile, strona listingu produktów):

  • 12 miniatur produktów po 390px szerokości: ~250 KB łącznie (86% mniej)
  • Baner hero preloadowany w 390px: ~45 KB
  • Jeden skoncentrowany preload dla elementu LCP
  • Obrazek LCP ładuje się z pełnym priorytetem wczesnego pasma

Łączny payload obrazów dla treści nad linią zgięcia na mobile spadł z około 2 MB do poniżej 300 KB. To nie jest syntetyczny benchmark — to faktyczna różnica w transferze sieciowym widoczna w DevTools.

Na stronie marketingowej efekt był jeszcze bardziej spektakularny na stronie listingu bloga. Obrazy hero, które pobierały się w pełnej rozdzielczości na potrzeby karuzeli, dopasowały się poprawnie do viewportu. Obrazy siatki kolekcji, pokazywane jako karty o szerokości 320px, przestały prosić o oryginały 1280px.

Dla kontekstu: strona marketingowa miała 35 wystąpień obrazów na wszystkich podstronach. Każde hero na pełną szerokość dostało sizes="100vw". Karty listingu bloga w responsywnej siatce dostały sizes="100vw md:50vw lg:33vw". Logotypy i portrety o stałym rozmiarze dostały jawne wartości pikselowe w rodzaju sizes="192px" czy sizes="250px". Migracja całej strony zajęła niecałą godzinę, bo kiedy raz zrozumiesz wzorzec, każdy obrazek to decyzja na dziesięć sekund: spójrz w CSS, wpisz sizes, idź dalej.

Jak to utrwaliliśmy

Naprawa 315 obrazów nie ma sensu, jeśli następny developer (albo funkcjonalności z kolejnego miesiąca) wprowadzi ten sam problem od nowa. Potrzebowaliśmy, żeby naprawa egzekwowała się sama.

Egzekwowanie przez TypeScript. Dzięki temu, że sizes stał się wymaganym propem komponentu obrazka, każde nowe wystąpienie obrazka bez sizes to błąd kompilacji. Developer po prostu tego nie wypuści. To najmocniejsza gwarancja, jaką mamy — nie jest to sugestia lintera ani punkt na checkliście code review. To twarda blokada.

Dokumentacja dla developerów. Dodaliśmy tę zasadę do dokumentacji projektu wraz z konkretnymi przykładami pokrywającymi typowe wzorce: responsywne siatki, ikony o stałym rozmiarze, banery na pełną szerokość i logotypy zmieniające rozmiar na breakpointach. Developer dodający nowy obrazek może dopasować się do wzorca z przykładów, bez konieczności rozumienia całej teorii wyboru obrazów responsywnych.

Barierki składniowe. Udokumentowaliśmy, że standardowa składnia HTML z media query dla sizes (w rodzaju (max-width: 640px) 50vw, 33vw) nie działa poprawnie z parserem naszej biblioteki obrazów. Wymagany jest format skrócony (50vw sm:33vw). Zapobiega to subtelnemu bugowi, w którym użycie "poprawnej" składni HTML w rzeczywistości produkuje błędne generowanie srcset.

Pięciominutowa checklista

Jeśli używasz jakiegokolwiek frameworka do optymalizacji obrazów (Nuxt Image, Next.js Image, Astro Image albo nawet czystego HTML z CDN-em), oto jak sprawdzić, czy masz ten problem:

  1. Otwórz swoją stronę na urządzeniu mobilnym (albo użyj emulacji mobile w DevTools)
  2. Otwórz zakładkę Network i przefiltruj po obrazach
  3. Spójrz na kolumnę "Size" przy największych obrazach
  4. Porównaj wymiary pobranego obrazka z wymiarami wyświetlania

Jeśli twoje obrazy pobierają się w 1280px lub więcej, a wyświetlają w 300-400px, to niemal na pewno masz problem z brakującym atrybutem sizes. Możesz też sprawdzić wygenerowany HTML: jeśli twoje atrybuty srcset używają deskryptorów gęstości (1x, 2x) zamiast deskryptorów szerokości (300w, 640w), przeglądarka nie wybiera obrazka na podstawie viewportu.

Naprawa jest mechaniczna: przy każdym obrazku spójrz w CSS i odpowiedz na pytanie "jak szeroki jest ten obrazek na każdym breakpoincie?". Wpisz to jako wartość sizes. Jeśli obrazek zawsze ma 56px szerokości, wpisz sizes="56px". Jeśli jest pełnej szerokości na mobile i połowy na desktopie, wpisz sizes="100vw md:50vw" (albo równoważną składnię media query dla twojego frameworka).

To nie jest efektowna praca. Aktualizacja 315 wystąpień obrazków była żmudna. Ale nie ma innej pojedynczej zmiany, którą wprowadziliśmy, a która w jedno popołudnie zmniejszyła wagę strony na mobile o 86%.

Jeszcze jedna rzecz, na którą warto uważać: jeśli używasz linków preload dla obrazów nad linią zgięcia, upewnij się, że te preloady zawierają atrybuty imagesrcset i imagesizes zgodne z tagiem obrazka. Preload bez responsywnego wymiarowania pobierze obrazek w pełnym rozmiarze, a potem przeglądarka pobierze osobno poprawnie zwymiarowaną wersję, gdy natrafi na tag obrazka. Kończy się na tym, że płacisz za ten sam obrazek dwa razy.

Dlaczego wszyscy to przeoczają

Uważamy, że ten problem jest tak powszechny przez trzy zbiegające się czynniki.

Frameworki do optymalizacji obrazów dają fałszywe poczucie bezpieczeństwa. Kiedy instalujesz bibliotekę do optymalizacji obrazów i widzisz, że generuje atrybuty srcset, naturalnie zakładasz, że najtrudniejsze jest już za tobą. Srcset jest. Wiele rozmiarów wypisanych. Framework się tym zajmuje. Czego nie widzisz, to że bez sizes srcset używa deskryptorów gęstości zamiast deskryptorów szerokości, a przeglądarka ignoruje twoje starannie wygenerowane warianty obrazów.

Na desktopie działa to dobrze. Podejście z deskryptorami gęstości pobiera natywną szerokość przy 1x i podwójną przy 2x. W przeglądarkach desktopowych, gdzie viewport jest szeroki, obrazek 1x i tak często jest blisko właściwego rozmiaru. Marnotrawstwo koncentruje się na mobile, gdzie viewport jest wąski, a gęstość wysoka. Jeśli testujesz tylko na desktopie, nigdy nie zobaczysz problemu.

Atrybut sizes sprawia wrażenie zbędnego. Tag obrazka ma już atrybuty width i height. CSS już steruje rozmiarem wyświetlania. Wydaje się, że przeglądarka powinna umieć wywnioskować rozmiar wyświetlania z CSS. Ale nie może — wyboru zasobu obrazkowego dokonuje „preparser”, zanim CSS zostanie w pełni sparsowany — a ma on dostęp wyłącznie do atrybutów HTML. Atrybut sizes istnieje dokładnie dlatego, że informacja o layoucie z CSS nie jest dostępna w momencie wyboru obrazka.

To jeden z tych przypadków, w których zrozumienie, dlaczego przeglądarka potrzebuje tej informacji, sprawia, że naprawa staje się oczywista. Przeglądarka próbuje zacząć pobierać obrazy najwcześniej, jak się da, jeszcze przed wyliczeniem layoutu. Robi ci przysługę, nie czekając na CSS. Ale ceną tej gorliwości jest to, że musisz jej powiedzieć, jak duży będzie obrazek, bo sama naprawdę nie jest w stanie tego jeszcze wywnioskować.

Szersza lekcja

Spędziliśmy miesiące na optymalizowaniu konfiguracji CDN-a, dostrajaniu ustawień jakości WebP, wdrażaniu lazy loadingu, dodawaniu placeholderów typu blur-up i budowaniu responsywnej logiki preloadu. Cała ta praca była konieczna, ale niewystarczająca. Najbardziej wpływową zmianą było dodanie czteroznakowego atrybutu do tagu HTML.

Optymalizacja wydajności jest pełna takich momentów. Możesz zbudować najbardziej wyrafinowany pipeline na świecie, ale jeśli przeglądarka nie ma informacji potrzebnych do podjęcia właściwej decyzji, cała ta infrastruktura idzie na marne. CDN był gotowy serwować idealnie zwymiarowane obrazy. Srcset wypisywał każdą dostępną szerokość. Przeglądarka była gotowa wybrać optymalny wariant. Potrzebowała tylko, żebyśmy powiedzieli jej, jak duży ten obrazek faktycznie będzie na ekranie.

Czasem najważniejsza optymalizacja to nie nowe narzędzie ani sprytny algorytm — to pojedynczy atrybut, który zasypuje przepaść między tym, co przeglądarka wie, a tym, co wiedzieć musi.