CODEGURU

Vue.js и Intersection Observer

Недавно просмотренный ролик Вадима Макеева с аудитом сайта lingualeo заставил задуматься о том, что изображения действительно могут зачастую составлять бОльшую часть трафика. Это может быть пост в блоге с большим количеством картинок, лента новостей и т.д. и т.п. И загрузка множества изображений может существенно снизить производительность сайта особенно на мобильных устройствах. Плюс есть ещё такой момент: если картинка находится где-то там внизу за пределами вьюпорта, то не факт, что пользователь вообще до неё доберётся. И получится, что картинку он загрузил, но не увидел. А зачем же грузить то, что не нужно?

В общем, если у вас есть какой-то контент с большим количеством картинок, то будет очень неплохо использовать технику «ленивая загрузка». Именно этим мы сейчас и займёмся.

Что за ленивая загрузка?

Ленивая загрузка означает, что мы не загружаем изображение до тех пор, пока оно не понадобится. Кстати, так можно делать не только с изображениями. Можно и javascript так загружать, но мы остановимся только на изображениях. Когда же наступает тот момент, в который необходимо начинать загрузку изображения? Очень просто — когда предполагаемое изображение попадает во вьюпорт. Отлично, теперь осталось только поймать этот момент. Мы оставим в стороне жуткие способы с кучей расчетов — они некрасивые и плохо работают. Мы же хотим, чтобы было красиво и хорошо. Поэтому будет использовать Intersection Observer.

Что ещё за Observer?

Это новый браузерный API, который делает именно то, что нам нужно: следит когда элемент попадает во вьюпорт (или пересекается с любым указанным элементом) и готов в этот момент выполнить любую функцию по нашему желанию. Да, браузерная поддержка оставляет желать лучшего. Только пользователи Chrome, Edge и Firefox смогут оценить наши усилия. Но у нас нет цели осчастливить всех, правильно? Мы используем новую технологию, которая облегчит жизнь прогрессивным пользователям. Остальные же будут как и раньше загружать сразу все картинки. Так что всё ок.

Пройдёмте в закрома

Ну хватит разговоров. Давайте уже сделаем что-нибудь на практике. Так как я в основном делаю проекты на Vue.js, то его мы и будем использовать для примера. Пример возьмём прямо из жизни. В проекте, над которым я сейчас работаю, есть раздел в административной части, в котором нужно показать все загруженные в систему изображения. Представляете, да? Страница, на которой 25/30/50 изображений, в зависимости от выбора пользователя. Отличный повод применить Intersection Observer. Для отображения картинок я создам компонент ImageItem.

<template>
  <div class="image">
    <img
      class="image__item"
      :src="source"
      alt=""
    >
  </div>
</template>

Ну тут всё просто и очевидно. Самый обычный тэг <img>, атрибут src которого является входным параметром (aka props) компонента. Раздел script соответственно выглядит так:

export default {
  name: "",
  props: {
    source: {
      type: String,
      required: true
    }
  }
}

В таком виде наш компонент просто будет показывать изображение как обычно. Для ленивой загрузки нам для начала нужно предотвратить загрузку изображений в принципе. Да, чтобы они вообще не загружались. Ведь мы хотим загружать изображение только тогда, когда оно попадает во вьюпорт. И самый простой способ предотвратить загрузку изображения — убрать у него атрибут src. Но нам же в любом случае понадобится адрес, с которого грузить изображение. Т.е. мы всё равно должны как-то сохранить наш входной параметр source. Для этого отлично подойдёт data атрибут. И шаблон компонента будет выглядеть так:

<template>
  <div class="image">
    <img
      class="image__item"
      :data-src="source"
      alt=""
    >
  </div>
</template>

Ну что ж, пол дела сделано. Изображения не загружаются. Осталось всего ничего — при попадании изображения во вьюпорт вернуть ему атрибут src и подставить в него значение из data-src. Получив атрибут src изображение сразу же загрузится. И вот тут наконец-то на сцену выходит наш герой. Так как мы используем Vue.js, то логичнее всего оформить ленивую загрузку в виде пользовательской директивы. Создадим папку directives, а в ней файл lazyImages.js:

export default {
  inserted: el => {
    function loadImage() {
      const imageElement = Array.from(el.children)
          .find(el => el.nodeName === "IMG");
      if (imageElement) {
          imageElement.addEventListener("error", () => console.log("error"));
      imageElement.src = imageElement.dataset.src;
      }
    }

    function handleIntersect(entries, observer) {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
            loadImage();
            observer.unobserve(el);
        }
      });
    }

    function createObserver() {
      const options = {
        root: null,
        threshold: "0"
      };
      const observer = new IntersectionObserver(handleIntersect, options);
      observer.observe(el);
    }

    if (window["IntersectionObserver"]) {
      createObserver();
    } else {
      loadImage();
    }
  }
};

Теперь разберём по частям, что происходит. У пользовательской директивы во Vue, как и у компонента, есть хуки. Мы функционал нашей директивы вешаем на хук inserted, так как нам необходимо, чтобы элемент уже присутствовал в DOM.

В функции loadImage мы просто возвращаем изображениям атрибут src, чтобы инициировать загрузку.

За запуск функции loadImage, в свою очередь, отвечает функция handleIntersect. В неё мы передаём набор элементов, за которыми ведётся наблюдение (entries) и экземпляр observer. Как только элемент попадает во вьюпорт (entry.isIntersecting), мы инициируем загрузку (imageLoad) и убираем его из списка наблюдения observer (observer.unobserve(el)).

Сам observer создаётся в функции createObserver.

В итоге логика работы директивы получается такая. Первым срабатывает условие:

if (window["IntersectionObserver"]) {
  createObserver();
} else {
  loadImage();
}

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

Регистрация директивы

Почти готово, осталось совсем немного — нужно зарегистрировать директиву. Для глобальной регистрации достаточно добавить её в файл main.js:

...
import LazyLoadDirective from "./directives/lazyImages";

Vue.directive("lazyload", LazyLoadDirective);
...

Если же мы хотим зарегистрировать директиву локально (ну не нужна она другим компонентам), то в файле компонента прописываем:

import LazyLoadDirective from "./directives/lazyImages";

export default {
  directives: {
    lazyload: LazyLoadDirective
  }
}

Используем директиву

Ну вот наконец и добрались до момента применения директивы на практике. Тут всё предельно просто:

<template>
  <div class="image" v-lazyload>
    <img
      class="image__item"
      :data-src="source"
      alt=""
    >
  </div>
</template>

Ура, мы сделали это 🤘