Данная статья описывает баг и его решения в контексте ReactJS + Server-Side Rendering, но это также актуально для всех фреймворков большой тройки так и для чистого JS.
![Проблема с использованием тега img и picture в Safari. Проблема с использованием тега img и picture в Safari.](https://habrastorage.org/getpro/habr/upload_files/6e9/9e4/bf3/6e99e4bf37cebeddeff9a68264b07abf.png)
При разработке сайта мы столкнулись с проблемой, что при использовании тега <img> на некоторых страницах Safari загружал изображение несколько раз вместо одного. Для отображения картинок мы использовали тег <img>
с атрибутом srcset
, что бы показывать картинки разного разрешения для экранов с высоким ppi.
<img
src="/default-small.jpg"
srcSet="/default-small.jpg 1x, /default-medium.jpg 2x"
alt="Cat"
/>
![Ошибка загрузки картинки Ошибка загрузки картинки](https://habrastorage.org/getpro/habr/upload_files/210/b85/b90/210b85b90538b335ce04e5c10930af9a.png)
Пример воспроизведения ошибки для img.
При первом открытии страницы, когда срабатывал Server-Side Rendering, все было хорошо и происходила загрузка нужной картинки один раз, но при переходах между страницами уже та же картинка грузилась 2 раза (в двух разных разрешениях). Но на некоторых страницах загрузка происходила верно. При детальном сравнении картинок, которые загружались несколько раз и которые загружались один раз, выяснилось, что в местах где загрузка происходила один раз, параметр srcset
был указан первее src
, и после смены порядка значений проблема полностью пропала.
До:
<img src="/default.jpeg" srcset="/default.jpeg 1x, /retina.jpeg 2x" alt="">
После:
<img srcset="/default.jpeg 1x, /retina.jpeg 2x" src="/default.jpeg" alt="">
Это показалось совсем странным, и после детального поиска проблемы, было найдено описание бага на WebKit Bugzilla. Из него следует, что если создать элемент img через document.createElement
и установить ему значение src, то браузер Safari начнет мгновенно загружать картинку не дожидаясь когда она будет добавлена в DOM.
Видимо это было сделано в угоду ускорению работы браузера, чтобы было больше времени на загрузку картинки и как можно быстрее отобразить картинку, но эта магия сломалась с приходом атрибута srcset
. Также стоит отметить, что при использовании статических страниц HTML этой проблемы нет, так как все параметры тэга у браузера есть сразу и он может обработать такой тэг верно, а все современные фреймворки под капотом используют document.createElement
. Это полностью объясняло нашу проблему, почему при первом открытии страницы, когда срабатывал SSR, проблемы с загрузкой изображений не было.
Первая идея, которая сразу может прийти в голову: удалить параметр srcset
и во время отрисовки выбирать нужную нам картинку. Этот вариант нам не подходил, так как мы использовали Server-Side Rendering и нельзя получить информацию о ширине окна пользователя при http запросе на сервере.
Вторая идея: можно использовать старый трюк с CSS. Установить картинку свойством background-image
и использовать media queries. Но этот вариант нам так же не подходил по требованиям доступности, seo и возможности пользователей взаимодействовать с картинками как картинками.
Третья идея: состоит в том, что если первым установить значение srcset
то браузер будет иметь возможность отработать верно. Хотя браузер все равно начнет загрузку мгновенно после установки значения srcset
не дожидаясь пока элемент будет добавлен в DOM.
<img
srcSet="/default-small.jpg 1x, /default-medium.jpg 2x"
src="/default-small.jpg"
alt="Cat"
/>
![Правильная работа браузера Правильная работа браузера](https://habrastorage.org/getpro/habr/upload_files/e28/a33/cc9/e28a33cc97096eaf5f054198a0349b78.png)
И вот вроде как простой сменой порядка значений атрибутов мы смогли решить нашу проблему, но у нас также есть тег <picture>
.
<picture>
<source
media="(min-width: 700px)"
srcSet="/wide-small.jpg 1x, /wide-medium.jpg 2x"
/>
<source
media="(min-width: 450px)"
srcSet="/narrow-small.jpg 1x, /narrow-medium.jpg 2x"
/>
<img
srcSet="/default-small.jpg 1x, /default-medium.jpg 2x"
src="/default-small.jpg"
alt="GFG"
/>
</picture>
![Ошибка загрузки картинки для тега picture Ошибка загрузки картинки для тега picture](https://habrastorage.org/getpro/habr/upload_files/742/9f1/85b/7429f185b17565992de2728626fff60d.png)
Пример воспроизведения ошибки для picture.
И здесь наша проблема проявилась вновь. Хотя значение srcset
и установлено первым, но браузер все равно загружает 2 картинки, так как он мгновенно начинает загрузку картинки после получения значения srcset
, а в нашем случае нам нужна картинка из тега <source>
, а не из тега <img>
.
И здесь опять есть несколько решений. Первая идея в том, что если браузер обрабатывает страницы с статичным HTML верно, то мы можем использовать dangerouslySetInnerHTML
в этом случае react не будет использовать document.createElement
и браузер получит HTML строку и все сработает верно, так же как и с статическими HTML страницами.
function getPictureAsInnerHTML() {
const html = /* html */ `
<picture>
<source
media="(min-width: 700px)"
srcSet="/wide-small.jpg 1x, /wide-medium.jpg 2x"
/>
<source
media="(min-width: 450px)"
srcSet="/narrow-small.jpg 1x, /narrow-medium.jpg 2x"
/>
<img
srcSet="/default-small.jpg 1x, /default-medium.jpg 2x"
src="/default-small.jpg"
alt="Cat"
/>
</picture>`;
return { __html: html };
}
export function PictureExampleInnerHTMLFixed() {
return <div dangerouslySetInnerHTML={getPictureAsInnerHTML()} />;
}
![Правильная работа браузера c InnerHTML Правильная работа браузера c InnerHTML](https://habrastorage.org/getpro/habr/upload_files/960/c30/517/960c305172fca5fa5acd0e8eefbac285.png)
С этим решением есть проблемы, как минимум оно не самое красивое, во вторых если мы не полностью доверяем ссылкам на изображения, то нам нужно использовать HTML Sanitizer
, чтобы защитить себя от XSS атак.
Наиболее подходящее для нас решение было найдено случайно. Оказалось, что если img
элементу задать параметр loading="lazy"
, то Safari выключит мгновенную загрузку изображения, что логично, так как браузер не может предсказать будет ли данная картинка в области видимости пользователя. Но нам все так же нужно соблюдать порядок установки свойств, чтобы браузер выключил оптимизацию перед тем как получит значения ссылок на изображения.
<picture>
<source
media="(min-width: 700px)"
srcSet="/wide-small.jpg 1x, /wide-medium.jpg 2x"
/>
<source
media="(min-width: 450px)"
srcSet="/narrow-small.jpg 1x, /narrow-medium.jpg 2x"
/>
<img
loading="lazy"
srcSet="/default-small.jpg 1x, /default-medium.jpg 2x"
src="/default-small.jpg"
alt="Cat"
/>
</picture>
![Правильная работа браузера c loading="lazy" Правильная работа браузера c loading="lazy"](https://habrastorage.org/getpro/habr/upload_files/efd/913/a9b/efd913a9beae7da2c32eab0a40d02a60.png)
Пример решения с loading=lazy.
Тот же самый трюк мы можем применить и для тега <img>
когда он один. Но у этого решения тоже есть свои минусы: теперь нам нужно задавать высоту и ширину картинок, что бы не было прыжков layout, так как браузер будет подгружать картинки по мере прокрутки документа и не сможет высчитать высоту всего документа при начальной отрисовке.
Вывод
Думаю, что данная проблема не будет пофикшена в Safari, потому что это может поломать обратную совместимость, так как возможно некоторые сайты опираются на данную оптимизацию. Мне кажется, что наиболее оптимальное решение - это использовать атрибут loading="lazy"
. Но если мы не можем установить значение высоты и неприемлемо изменение высоты документа, то для тэга <picture>
придется использовать решение с innerHtml, но если нам нужен только тег <img>
c srcset
, то мы можем просто использовать верный порядок свойств.