Мы уже давно используем адаптивные изображения picture с srcset, но захотелось реализовать ленивую загрузку изображений на проектах, когда юзер видит изображение в плохом качестве, пока загружается основное.

В наше время пользователи хотят получать как можно больше контента за как можно меньшее время. В связи с этим, если изображение загружается не мгновенно, это уже проблема. Пользователь, скорее всего, его пропустит и пройдет дальше, если вообще не закроет сайт.

Вариант с отображением анимированного блока или изображения худшего качества в момент загрузки, очевидно, привлекает пользователей сильнее, чем просто белый блок. Поэтому решили, что его и будем реализовывать. Однако, есть множество различных способов это сделать.

Какие варианты мы рассматривали?

Использование Skeleton

Плюсы:

  • очень простая имплементация

  • требует немного ресурсов

Минусы:

  • в 2023 выглядит достаточно скучно и не цепляет внимание (не вызывает желание дождаться окончания загрузки)

Изображение в формате base64

Отображение сжатого до 1-2kb изображения с наложенным blur эффектом. Вроде отличная идея...

Исходный вид изображения
Исходный вид изображения
Вид с обработкой
Вид с обработкой

Плюсы:

  • такие изображения загружаются почти мгновенно.

Минусы:

  • т.к. изображения для размеров экранов в desktop, tablet и mobile могут отличаться (размер, соотношение сторон, сам контент изображений), возникает необходимость присылать из api base64 изображение для каждого разрешения экрана. Это кратно увеличивает размер json'a (особенно, если мы запрашиваем страницу каталога, каждая карточка в которой содержит слайдер из множества картинок).

Примерно так у нас сейчас выглядит объект картинки:

Добавлять base64 строку под каждое разрешение представляется не лучшей идеей, поэтому идем дальше.

Progressive JPEG

Это относительно новая технология загрузки jpeg изображений, когда картинка загружается не линейно (сверху вниз), а сразу заполняет все пространство блока и загружается в несколько слоев (от самого худшего качества до наилучшего).

Изображение из статьи

Тем не менее, есть несколько нюансов, которые заставляют поискать что-то еще:

  • это jpeg, никакой другой формат изображений так не может, а значит, мы вынуждены отказаться от других форматов (например, webp или avif),

  • хотя JPEG по-прежнему является доминирующей технологией для хранения цифровых изображений, он не отвечает нескольким требованиям, которые стали важными в последние годы, например, сжатие изображений с более высокой битовой глубиной (от 9 до 16 бит), изображения с высоким динамическим диапазоном, без потерь. сжатие и представление альфа-каналов.

Это тот вариант, на котором мы остановились.

Blurhash

Blurhash — это библиотека, разработанная крутыми ребятами с имплементацией на любой язык

В чем ее суть для фронтенда?

Мы получаем короткую строку (20–30 символов) в формате base83, и «натягиваем» ее на канвас.

Плюсы:

  • данный формат позволяет сократить вес json’а до минимума

  • нет необходимости добавлять лишние стили для заблюривания картинки

  • нет необходимости присылать несколько вариантов изображения для разных экранов (desktop, tablet, mobile)

  • изображение имеет «мягкие» очертания будущего контента

Минусы:

  • необходимость использования canvas и довольно сильное размытие контента

Прежде чем переходить к реализации на фронте, мы реализовали небольшой микросервис, который вы можете поднять из docker образа, который сможет генерировать base83 для ваших картинок. Микросервис доступен в репозитории.

Наша имплементация

Разработчики любезно предоставили нам библиотеку react-blurhash, которая имеет уже адаптированные компоненты для работы с blurhash.

Приблизительный вид json, который получает этот компонент:

В целом, нет острой необходимости присылать изображения для разных разрешений ( и разных форматов). Достаточно изменить типизацию и заменить рукописный копмонент Picture на любой другой Image для вашего варианта использования.

В данном контексте нас интересует лишь поле:

placeholder: "|FDcXS4nxu~q4nt7-;9Fxu?bxu9FxuRjIU%MayRjRj%MRjIU%MM{RjxvRjozofxuM{t8xuIUofofWBRjt7RjayxuM{WBt7InWUofWBoft7WBWBofRioft7ayt7oeayofWBRjoLs:ayoffRayofR*ofj[j[oMWBayj[azfR"

Оно и является нашей строкой blurhash (длину строки можно регулировать при кодировании, изменяя соотношение сторон и размер исходного изображения).

Принцип работы компонента

  • По умолчанию изображение не содержит данных

  • В момент, когда оно попадает в зону видимости пользователя, срабатывает useEffect, выполняющий set данных в компонент Picture и накладывающий поверх него canvas c blurhash

  • Когда изображение полностью загружено, canvas плавно прячется

Список пропсов компонента

export interface LazyPictureProps {
data: IPicture;
alt?: string;
placeholder?: string;
breakpoints?: IBreakpoints;
onLoadSuccess?: (img: EventTarget) => void;
onLoadError?: () => void;
className?: string;
}
export interface IBreakpoints {
desktop: string;
tablet: string;
mobile: string;
}

Сам компонент:

export default memo(function LazyPicture({
data,
onLoadError,
onLoadSuccess,
className = "",
...props
}: LazyPictureProps) {
const [isLoaded, setIsLoaded] = useState(false);
const { placeholder, ...imageProps } = data;
const imgPlaceholder = useMemo(
() => placeholder || defaultBlurPlaceholder,
[placeholder]
);
const [imageSrc, setImageSrc] = useState(defaultImageProps);
const imageRef = useRef(null);
const _onLoad: ReactEventHandler = (event) => {
const img = event.target;
if (onLoadSuccess) onLoadSuccess(img);
setIsLoaded(true);
};
}

Задаем базовый флаг isLoaded для изменения стилей и контроля за состоянием загрузки.

imgPlaceholder — это и есть строка blurhash;

imageSrc — сюда будет «сетиться» ссылка на исходное изображение (по дефолту пустая строка. или, как в нашем случае, объект из нескольких полей);

imageRef  для отслеживания попадания картинки в область видимости юзера;

onLoad — хендлер успешной загрузки изображения.

Добавляем useEffect, который и выполняет всю работу:

useEffect(() => {
let observer: IntersectionObserver;
if (IntersectionObserver) {
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
// when image is visible in the viewport + rootMargin
if (entry.intersectionRatio > 0 || entry.isIntersecting) {
setImageSrc(imageProps);
imageRef?.current && observer.unobserve(imageRef?.current);
}
});
},
{
threshold: 0.01,
rootMargin: "20%",
}
);
imageRef?.current && observer.observe(imageRef?.current);
} else {
// Old browsers fallback
setImageSrc(imageProps);
}
return () => {
imageRef?.current && observer.unobserve(imageRef.current);
};
}, []);

Используем IntersectionObserver для отслеживания попадания изображения в зону видимости, когда это происходит — сетим данные в Picture и отменяем подписку.

Собственно jsx:

return (
<StyledLazyImage>
<StyledBlurHash isHidden={isLoaded}>
<Blurhash
hash={imgPlaceholder}
width={"100%"}
height={"100%"}
resolutionX={32}
resolutionY={32}
punch={1}
/>
</StyledBlurHash>
<Picture
ref={imageRef}
{...imageSrc}
{...props}
className={${className}</span> ${!<span class="hljs-attribute" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">isLoaded</span> &amp;&amp; "<span class="hljs-attribute" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">lazy</span>"}}
onLoad={_onLoad}
onLoadError={onLoadError}
/>
</StyledLazyImage>
);

Здесь используется styled-components, но это не принципиально.

StyledLazyImage — div контейнер, его стили:

Blurhash — это компонент библиотеки react-blurhash, его пропсы:

const StyledLazyImage = styled.div  width: <span class="hljs-number" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">100</span>%;   height: <span class="hljs-number" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">100</span>%;   position: relative;   canvas {     width: <span class="hljs-number" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">100</span>%;     height: <span class="hljs-number" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">100</span>%;   }   .lazy {     opacity: <span class="hljs-number" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">0</span>;   };

Name

Description

hash (string)

The encoded blurhash string.

width (int | string)

Width (CSS) of the decoded image.

height (int | string)

Height (CSS) of the decoded image.

resolutionX (int)

The X-axis resolution in which the decoded image will be rendered at. Recommended min. 32px. Large sizes (>128px) will greatly decrease rendering performance. (Default: 32)

resolutionY (int)

The Y-axis resolution in which the decoded image will be rendered at. Recommended min. 32px. Large sizes (>128px) will greatly decrease rendering performance. (Default: 32)

punch (int)

NControls the "punch" value (~contrast) of the blurhash decoding algorithm. (Default: 1)

StyledBlurhash — контейнер для компонента Blurhash, его стили:

const StyledBlurHash = styled.div<{ isHidden?: boolean }>   position: absolute;   width: <span class="hljs-number" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">100</span>%;   height: <span class="hljs-number" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">100</span>%;   z-index: <span class="hljs-number" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">22222</span>;   visibility: visible;   transition: visibility <span class="hljs-number" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">0.2</span>s, opacity <span class="hljs-number" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">0.2</span>s;   ${({ isHidden }) =&gt;     isHidden &amp;&amp;       css
visibility: hidden;
opacity: 0;
animation: ${displayAnim} 0.2s;
  };
const displayAnim = keyframes  to {     display: none;   };

Скорость и плавность скрытия Blurhash можно регулировать через transition и animation.

Picture — компонент картинки (его можно заменить на NextImage или любой другой, он должен возвращать image).

const Picture = forwardRef((props, imageRef) => {
const {
noImageOnTouch = false,
alt = "",
onLoad,
onLoadError,
className = "",
} = props;
const desktopImages: PictureSources =
props.desktop || defaultImageProps.desktop;
const {
x1: desktop_x1,
x2: desktop_x2,
webp_x1: desktop_webp_x1,
webp_x2: desktop_webp_x2,
} = desktopImages;
const tabletImages: PictureSources =
props.tablet || props.desktop || defaultImageProps.tablet;
const {
x1: tablet_x1,
x2: tablet_x2,
webp_x1: tablet_webp_x1,
webp_x2: tablet_webp_x2,
} = tabletImages;
const mobileImages: PictureSources = props.mobile || defaultImageProps.mobile;
const {
x1: mobile_x1,
x2: mobile_x2,
webp_x1: mobile_webp_x1,
webp_x2: mobile_webp_x2,
} = mobileImages;

Он принимает ссылки на все типы изображений и сетит их в <picture/>

return !Object.keys(props).length ? (
<img src="/images/error-page-image.png" alt="error-image" />
) : desktop_x1 && desktop_x1.endsWith(".svg") ? (
<img src={desktop_x1} alt="" />
) : (
<picture>
{noImageOnTouch && (
<source
media="(hover: none) and (pointer: coarse), (hover: none) and (pointer: fine)"
srcSet={base64Pixel}
sizes="100%"
/>
)}
<source
type="image/webp"
media="(min-width: 1025px)"
srcSet={<span class="hljs-variable" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">${desktop_webp_x1}</span>, <span class="hljs-variable" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">${desktop_webp_x2}</span> <span class="hljs-number" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">2</span>x}
/>
<source
media="(min-width: 1025px)"
srcSet={<span class="hljs-variable" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">${desktop_x1}</span>, <span class="hljs-variable" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">${desktop_x2}</span> <span class="hljs-number" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">2</span>x}
/>
<source
type="image/webp"
media="(min-width: 501px)"
srcSet={<span class="hljs-variable" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">${tablet_webp_x1}</span>, <span class="hljs-variable" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">${tablet_webp_x2}</span> <span class="hljs-number" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">2</span>x}
/>
<source
media="(min-width: 501px)"
srcSet={<span class="hljs-variable" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">${tablet_x1}</span>, <span class="hljs-variable" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">${tablet_x2}</span> <span class="hljs-number" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">2</span>x}
/>
<source
type="image/webp"
media="(max-width: 500px)"
srcSet={<span class="hljs-variable" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">${mobile_webp_x1}</span>, <span class="hljs-variable" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">${mobile_webp_x2}</span> <span class="hljs-number" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">2</span>x}
/>
<source
media="(max-width: 500px)"
srcSet={<span class="hljs-variable" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">${mobile_x1}</span>, <span class="hljs-variable" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">${mobile_x2}</span> <span class="hljs-number" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">2</span>x}
/>
<img
ref={imageRef}
src={desktop_x1}
srcSet={<span class="hljs-variable" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">${desktop_x2}</span> <span class="hljs-number" style="box-sizing: content-box; margin: 0px; padding: 0px; border: 0px; color: rgb(0, 128, 128);">2</span>x}
crossOrigin=""
className={className}
alt={alt}
onLoad={onLoad}
onError={onLoadError}
/>
</picture>
);
};
Picture.displayName = "Picture";
export default Picture;

Подводя итоги

Данный компонент был имплементирован в уже готовый проект с множеством изображений в различных слайдерах и блочных моделях.

Задача была - реализовать максимально адаптивный вариант без внешних исправлений верстки. Сейчас мы пробуем эту реализацию на других проектах и фидбек положительный, что не может не радовать.

Подробный пример реализации на React можно посмотреть здесь. Микросервис на Go для получения blurhash из картинок — здесь.

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


  1. pda0
    19.09.2023 08:31
    +3

    Progressive JPEG

    Это относительно новая технология загрузки jpeg изображений

    Лолшто? Эта технология во всю использовалась в браузерах 90-х, т.к. позволяла смягчить загрузку страниц модемами.


  1. Vlad_IT
    19.09.2023 08:31

    Классная штука, но в теории она может ухудшить метрики производительности TBT и TTI. Интересно было бы замерить в сравнении с base64. При большом числе картинок я бы подумал над тем, чтобы считать и рисовать канву в OffscreenCanvas, или вынес в ворклет на CSS Painting API (но тут в сафари беда).

    Еще сравнение с скелетонами не совсем корректное, ведь скелетоны применяются тогда, когда еще нет никаких загружаемых данных, а у вас уже есть текст и этот хешик.


  1. Zara6502
    19.09.2023 08:31
    +2

    Progressive JPEG

    Это относительно новая технология загрузки jpeg изображений, когда картинка загружается не линейно (сверху вниз), а сразу заполняет все пространство блока и загружается в несколько слоев (от самого худшего качества до наилучшего)

    Этой "относительно новой технологии" аж 30 лет.

    ---

    Вообще я не такой как все, я не люблю заблюренные подгрузки картинок, я хочу точно знать что мне показывается конечный вариант в максимальном качестве. Вот например стал недавно таки пользоваться телеграмом и очень бесит когда тебе присылают картинку, ты ее сохраняешь (и она сохраняется), а позже узнаешь что это была превьюшка в неполном качестве. Домохозяйкам наверное и так сойдет, а мне - нет.


  1. dom1n1k
    19.09.2023 08:31

    Смысл миниатюр в base64 не только в том, чтобы быть мелкими, а чтобы инлайнить их прямо в html без внешних сервисов.
    Проблема разных картинок на мобиле и десктопе мне кажется преувеличенной. Принципиально разными они быть не могут. Да, может быть немного разное кадрирование — да и плевать. Задача LQIP показать размытые очертания объекта, этого достаточно.


  1. imvitalya
    19.09.2023 08:31

    Классная статья, которая дает повод задуматься об вводе подобного механизма в свои проекты, но есть но:

    Задача была - реализовать максимально адаптивный вариант без внешних исправлений верстки. Сейчас мы пробуем эту реализацию на других проектах и фидбек положительный, что не может не радовать.

    Выглядит абстрактно и не несет в себе никакой информации. Хотелось бы увидеть графики и метрики, которые говорят нам о таком положительном эффекте =)