Blur Up плейсхолдер для next/image

Неделю назад `Vercel` анонсировали 10ую версию своего фреймворка next.js, куда в том числе вошёл новый компонент для отрисовки изображений - next/image. В этой статье я не буду говорить о его плюсах/минусах, а лишь опишу свой опыт доработки этого компонента под свои нужды. Мне хотелось, чтобы пользователь во время загрузки изображений мог наблюдать эффект blur up - когда маленькая копия оригинального изображения растягивается на оригинальную длину\ширину и блурится, будто говоря: "я вот-вот уже почти загрузилась".

Еще до того, как появился компонент от самих next.js, я пользовался собственной поделкой, которая умела в lazy loading даже для изображений без конкретных размеров (в моём блоге превалируют именно такие изображения), а так же поддавалась кроулингу.

const LazyImage = ({ alt, ratio, src, ...props }) => {
  const ref = useRef(null);
  const [size, setSize] = useState({});
  const [imgSrc, setImgSrc] = useState(undefined);
  const [loaded, setLoaded] = useState(false);
  const handleLoad = useCallback(() => {
    setLoaded(true);

    ref.current.removeEventListener('load', handleLoad);
  }, []);

  // 1. Set image width and height after mount, when width is available
  useEffect(() => {
    const { width } = ref.current.getBoundingClientRect();
    const height = width / ratio;

    setSize({
      height,
      width,
    });
  }, []);

  // 2. When dimensions are known set src to start loading image
  useEffect(() => {
    if (size.width && size.height) {
      setImgSrc(src);
    }
  }, [size, src]);

  // 3. Add onload callback to reset width and height to make pictures responsive again
  useEffect(() => {
    if (imgSrc) {
      ref.current.addEventListener('load', handleLoad);
    }
  }, [handleLoad, imgSrc]);

  return (
    <>
      <img
        alt={alt}
        height={loaded ? undefined : size.height}
        loading="lazy"
        ref={ref}
        src={imgSrc}
        width={loaded ? undefined : size.width}
        {...props}
      />
      <noscript>
        <img
          alt={alt}
          loading="lazy"
          src={src}
          {...props}
        />
      </noscript>
    </>
  );
};

Уже тогда я хотел что-то вроде блура для своих изображений, но как-то лениво было этим заняться. И вот час настал. Касательно теории, next/image повторяет дизайн обычного img, но требует обязательно наличие ширины\длины. При чём, как утверждает документация, их img по умолчанию отзывчивый:

width and height are required to prevent Cumulative Layout Shift, a Core Web Vital that Google is going to use in their search ranking
width and height are automatically responsive, unlike the HTML <img> element;

Это нас устраивает. Но стоит учитывать, что при рендере этого компонента сам img окажется обёрнут ещё в два div-а. И лично меня это немного озадачило, т.к. все мои изображения рисовались внутри инлайнового <p>.

От слов к делу. Давайте я сразу покажу код готового компонента, а потом разберёмся, что он делает:

const LazyImageWithPlaceholder = ({ alt, className, height, placeholderSrc, src, width, ...props }) => {
  const [loaded, setLoaded] = useState(false);
  const style = {
    background: loaded ? 'none' : `url(${placeholderSrc}) no-repeat center center`,
    backgroundSize: loaded ? 'initial' : 'cover',
    filter: loaded ? 'none' : 'blur(20px)',
    transition: 'filter .3s ease-out',
  };
  const handleLoad = useCallback(() => {
    setLoaded(true);
  }, []);

  return (
    <div className={className} style={style}>
      <Image
        alt={alt}
        height={height}
        onLoad={handleLoad}
        src={src}
        width={width}
        {...props}
      />
    </div>
  );
};

export default LazyImageWithPlaceholder;

Начнём, пожалуй, с названия. Оно немного длинное лишь потому, что этот компонент на самом деле рисуется внутри другого - LazyImage. Например, когда мне не нужен плейсхолдер, то логика присущая только плейсхолдеру не будет выполнена:

const LazyImage = ({ ...props }) => {
  if (placeholderColor) {
    return (
      <LazyImageWithColor
        // ...
      />
    );
  }

  if (placeholderSrc) {
    return (
      <LazyImageWithPlaceholder
        // ...
      />
    );
  }

  return (
    <Image
      // ...
    />
  );
};

В коде выше есть небольшой спойлер - LazyImageWithColor. Этот компонент позволяет вместо blur up выдавать доминантный цвет изображения, пока оно грузится.

Но вернёмся к LazyImageWithPlaceholder. В нём есть два момента, на которые стоит обратить внимание.

  1. placeholderSrc получаем из пропсов
  2. добавляется третий div поверх двух от next/image

Чтобы вывести заблюреное изображение, его нужно как-то произвести. Делать это будем с помощью lqip-loader. Этот лоадер для вебпака позволяет импортировать изображения в уже заблюреном виде. Использовать его можно напрямую (донастроив вебпак руками) или воспользоваться готовыми "решениями": next-lqip-images/next-optimized-images. Давайте рассмотрим второй вариант с next-lqip-images. Вся настройка описана в репозитории, я не буду на ней останавливаться. Смотрим, как этим пользоваться:

import catPhoto from './cat.png?lqip';

<LazyImage
  alt="Котейка"
  height={500}
  placeholderSrc={catPhoto.dataURI}
  src={catPhoto.src}
  width={327}
/>

После импорта catPhoto это объект, в котором src - ссылка на оригинальное изображение, а dataURI - заблюреное изображение в кодировке base64. Если мне не нужен плейсхолдер, то просто не передаю его в пропсах. А если вообще хочу отключить любую оптимизацию и видеть оригинал, то передаю в пропсы unoptimized:

import catPhoto from './cat.png';

// lazy-loading без blur up
<LazyImage
  alt="Котейка"
  height={500}
  src={catPhoto}
  width={327}
/>
import catPhoto from './cat.png';

// lazy-loading без оптимизации
// (по умолчанию картинки выводятся с качеством 75%)
<LazyImage
  alt="Котейка"
  height={500}
  src={catPhoto}
  unoptimized
  width={327}
/>

На этом как бы и всё, компонент очень простой. Из доработок можно подумать над тем, чтобы блюрить изображение не сразу, а спустя какое-то небольшое время. Чтобы для уже закешированных изображений не показывать blur up. Работу этого компонента можете проверить почти на любой картинке моего сайта.

У меня нет особо времени, чтобы оформить этот компонент в виде библиотеки, поэтому, если кто-то желает этим заняться - вперёд! Оставьте только ссылку на эту страницу, как на источник вдохновения и я вам буду очень благодарен.

 Обсудить