Данная статья описывает баг и его решения в контексте ReactJS + Server-Side Rendering, но это также актуально для всех фреймворков большой тройки так и для чистого JS.

Проблема с использованием тега img и picture в Safari.
Проблема с использованием тега img и picture в Safari.

При разработке сайта мы столкнулись с проблемой, что при использовании тега <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"
/>
Правильная работа браузера
Правильная работа браузера

Пример решения для img.

И вот вроде как простой сменой порядка значений атрибутов мы смогли решить нашу проблему, но у нас также есть тег <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

Пример воспроизведения ошибки для 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

Пример решения с innerHtml.

С этим решением есть проблемы, как минимум оно не самое красивое, во вторых если мы не полностью доверяем ссылкам на изображения, то нам нужно использовать 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"

Пример решения с loading=lazy.

Тот же самый трюк мы можем применить и для тега <img> когда он один. Но у этого решения тоже есть свои минусы: теперь нам нужно задавать высоту и ширину картинок, что бы не было прыжков layout, так как браузер будет подгружать картинки по мере прокрутки документа и не сможет высчитать высоту всего документа при начальной отрисовке.

Вывод

Думаю, что данная проблема не будет пофикшена в Safari, потому что это может поломать обратную совместимость, так как возможно некоторые сайты опираются на данную оптимизацию. Мне кажется, что наиболее оптимальное решение - это использовать атрибут loading="lazy".  Но если мы не можем установить значение высоты и неприемлемо изменение высоты документа, то для тэга <picture> придется использовать решение с innerHtml, но если нам нужен только тег <img> c srcset, то мы можем просто использовать верный порядок свойств.

GitHub репозитоий с примерами.

Live demo.

Комментарии (0)