Время прочтения — 6 минут

Прогрессивый рендер изображений с использованием blurhash

Ещё один способ как улучшить опыт пользователей

Содержание
Front-end developer
Андрей
Back-end developer
Алексей
Мы уже давно используем адаптивные изображения 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 для ваших картинок. Микросервис доступен в репозитории: github.com/dev-family/blurhash
Разработчики любезно предоставили нам библиотеку 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 (
  {!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={`${desktop_webp_x1}, ${desktop_webp_x2} 2x`}
      />
      <source
        media={`(min-width: 1025px)`}
        srcSet={`${desktop_x1}, ${desktop_x2} 2x`}
      />
      <source
        type="image/webp"
        media={`(min-width: 501px)`}
        srcSet={`${tablet_webp_x1}, ${tablet_webp_x2} 2x`}
      />
      <source
        media={`(min-width: 501px)`}
        srcSet={`${tablet_x1}, ${tablet_x2} 2x`}
      />

      <source
        type="image/webp"
        media={`(max-width: 500px)`}
        srcSet={`${mobile_webp_x1}, ${mobile_webp_x2} 2x`}
      />
      <source
        media={`(max-width: 500px)`}
        srcSet={`${mobile_x1}, ${mobile_x2} 2x`}
      />
      <img
        ref={imageRef}
        src={desktop_x1}
        srcSet={`${desktop_x2} 2x`}
        crossOrigin=""
        className={className}
        alt={alt}
        onLoad={onLoad}
        onError={onLoadError}
      />
    </picture>
  ))}
);

Picture.displayName = "Picture";
export default Picture;
Здесь используется styled-components, но это не принципиально.
StyledLazyImage — div контейнер, его стили:
Blurhash — это компонент библиотеки react-blurhash, его пропсы:

const StyledLazyImage = styled.div`
  width: 100%;
  height: 100%;
  position: relative;
  canvas {
    width: 100%;
    height: 100%;
  }
  .lazy {
    opacity: 0;
  }
`;
StyledBlurhash — контейнер для компонента Blurhash, его стили:

const StyledBlurHash = styled.div<{ isHidden?: boolean }>`
  position: absolute;
  width: 100%;
  height: 100%;
  z-index: 22222;
  visibility: visible;
  transition: visibility 0.2s, opacity 0.2s;
  ${({ isHidden }) =>
    isHidden &&
      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={`${desktop_webp_x1}, ${desktop_webp_x2} 2x`}
      />
      <source
        media={`(min-width: 1025px)`}
        srcSet={`${desktop_x1}, ${desktop_x2} 2x`}
      />
      <source
        type="image/webp"
        media={`(min-width: 501px)`}
        srcSet={`${tablet_webp_x1}, ${tablet_webp_x2} 2x`}
      />
      <source
        media={`(min-width: 501px)`}
        srcSet={`${tablet_x1}, ${tablet_x2} 2x`}
      />

      <source
        type="image/webp"
        media={`(max-width: 500px)`}
        srcSet={`${mobile_webp_x1}, ${mobile_webp_x2} 2x`}
      />
      <source
        media={`(max-width: 500px)`}
        srcSet={`${mobile_x1}, ${mobile_x2} 2x`}
      />
      <img
        ref={imageRef}
        src={desktop_x1}
        srcSet={`${desktop_x2} 2x`}
        crossOrigin=""
        className={className}
        alt={alt}
        onLoad={onLoad}
        onError={onLoadError}
      />
    </picture>
  )}
);

Picture.displayName = "Picture";
export default Picture;

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

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