Данная статья описывает баг и его решения в контексте ReactJS + Server-Side Rendering, но это также актуально для всех фреймворков большой тройки так и для чистого JS.
При разработке сайта мы столкнулись с проблемой, что при использовании тега <img> на некоторых страницах Safari загружал изображение несколько раз вместо одного. Для отображения картинок мы использовали тег <img>
с атрибутом srcset
, что бы показывать картинки разного разрешения для экранов с высоким ppi.
<img
src="/default-small.jpg"
srcSet="/default-small.jpg 1x, /default-medium.jpg 2x"
alt="Cat"
/>
Пример воспроизведения ошибки для 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"
/>
И вот вроде как простой сменой порядка значений атрибутов мы смогли решить нашу проблему, но у нас также есть тег <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.
И здесь наша проблема проявилась вновь. Хотя значение 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()} />;
}
С этим решением есть проблемы, как минимум оно не самое красивое, во вторых если мы не полностью доверяем ссылкам на изображения, то нам нужно использовать 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>
Пример решения с loading=lazy.
Тот же самый трюк мы можем применить и для тега <img>
когда он один. Но у этого решения тоже есть свои минусы: теперь нам нужно задавать высоту и ширину картинок, что бы не было прыжков layout, так как браузер будет подгружать картинки по мере прокрутки документа и не сможет высчитать высоту всего документа при начальной отрисовке.
Вывод
Думаю, что данная проблема не будет пофикшена в Safari, потому что это может поломать обратную совместимость, так как возможно некоторые сайты опираются на данную оптимизацию. Мне кажется, что наиболее оптимальное решение - это использовать атрибут loading="lazy"
. Но если мы не можем установить значение высоты и неприемлемо изменение высоты документа, то для тэга <picture>
придется использовать решение с innerHtml, но если нам нужен только тег <img>
c srcset
, то мы можем просто использовать верный порядок свойств.