Вариант бесконечной прокрутки постов

В данной статье сделаем так называемую бесконечную прокрутку постов(Infinite Scroll) используя Intersection Observer API, Vue.js, Axios

Записи будем получать из ресурса {JSON} Placeholder, используя библиотеку Axios

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

А для отслеживания элементов в видимой области браузера будем использовать Intersection Observer API

А также будут CSS-анимации, CSS-переменные, плавное появление элементов - думаю будет интересно, а главное полезно ;)

В итоге у нас получится следующий результат

В конце статьи ссылка на итоговый результат

Пишем начальную HTML структуру

Начальная HTML структура максимально простая - секция с постами, сам блок поста и лоадер

Сразу же подключаем:

Также подключаем:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Infinite Scroll</title>
    <link rel="stylesheet" href="css/bootstrap-reboot.min.css">
    <link rel="stylesheet" href="css/app.css">
  </head>
  <body>
    <div class="posts">
      <div class="container">
        <div class="posts__title">Infinite Scroll</div>
        <div class="posts__list">
          <div class="posts__post post">
            <div class="post__id"></div>
            <div class="post__title"></div>
            <div class="post__body"></div>
          </div>
        </div>
        <div class="posts__loading">
          <svg viewBox="25 25 50 50">
            <circle cx="50" cy="50" r="20"></circle>
          </svg>
        </div>
      </div>
    </div>
    <script src="js/vue.global.prod.js"></script>
    <script src="js/axios.min.js"></script>
    <script src="js/app.js"></script>
  </body>
</html>


Добавим CSS стили

Для назначения общих цветов, используем CSS-переменные

Стили для анимированного лоадера для примера берём здесь - https://cssfx.netlify.app/

Блоку с vue-атрибутом v-cloak задаём display: none, чуть ниже разберём зачем

:root {
  --main: #473144;
  --second: #df9b6d;
}
body {
  font-family: "Open Sans", sans-serif;
  background: var(--second);
}
[v-cloak] {
  display: none;
}
.container {
  padding: 0 16px;
}
.posts {
  margin: 0 auto;
  max-width: 640px;
  padding: 64px 0;
}
.posts__list {
  display: grid;
  grid-template-columns: 1fr;
  gap: 32px;
  grid-gap: 32px;
  margin-bottom: 48px;
}
.posts__title {
  font-size: 64px;
  font-weight: 900;
  text-align: center;
  margin-bottom: 48px;
  color: var(--main);
}
.posts__loading {
  text-align: center;
  font-size: 24px;
  font-weight: 300;
}
.post {
  padding: 32px;
  background: #fff;
  box-shadow: 0 8px 32px rgba(0,0,0,0.1);
  position: relative;
  color: #333;
  border: 4px solid var(--main);
  border-radius: 16px;
  overflow: hidden;
  transform: translateY(48px) scale(0.9);
  transition: transform 0.8s, opacity 0.8s;
  opacity: 0;
}
.post_active {
  transform: translateY(0px) scale(1);
  opacity: 1;
}
.post__id {
  font-size: 300px;
  font-weight: 900;
  position: absolute;
  right: -24px;
  bottom: -64px;
  color: var(--second);
  z-index: 0;
  line-height: 1;
}
.post__title {
  font-weight: 900;
  font-size: 24px;
  text-transform: uppercase;
  margin-bottom: 24px;
  position: relative;
  z-index: 1;
  color: var(--main);
}
.post__body {
  position: relative;
  z-index: 1;
  line-height: 1.65;
  font-size: 18px;
  opacity: 0.9;
  text-align: justify;
}
svg {
  width: 3.75em;
  transform-origin: center;
  animation: rotate 2s linear infinite;
}
circle {
  fill: none;
  stroke: var(--main);
  stroke-width: 4;
  stroke-dasharray: 1, 200;
  stroke-dashoffset: 0;
  stroke-linecap: round;
  animation: dash 1.5s ease-in-out infinite;
}
@-moz-keyframes rotate {
  100% {
    transform: rotate(360deg);
  }
}
@-webkit-keyframes rotate {
  100% {
    transform: rotate(360deg);
  }
}
@-o-keyframes rotate {
  100% {
    transform: rotate(360deg);
  }
}
@keyframes rotate {
  100% {
    transform: rotate(360deg);
  }
}
@-moz-keyframes dash {
  0% {
    stroke-dasharray: 1, 200;
    stroke-dashoffset: 0;
  }
  50% {
    stroke-dasharray: 90, 200;
    stroke-dashoffset: -35px;
  }
  100% {
    stroke-dashoffset: -125px;
  }
}
@-webkit-keyframes dash {
  0% {
    stroke-dasharray: 1, 200;
    stroke-dashoffset: 0;
  }
  50% {
    stroke-dasharray: 90, 200;
    stroke-dashoffset: -35px;
  }
  100% {
    stroke-dashoffset: -125px;
  }
}
@-o-keyframes dash {
  0% {
    stroke-dasharray: 1, 200;
    stroke-dashoffset: 0;
  }
  50% {
    stroke-dasharray: 90, 200;
    stroke-dashoffset: -35px;
  }
  100% {
    stroke-dashoffset: -125px;
  }
}
@keyframes dash {
  0% {
    stroke-dasharray: 1, 200;
    stroke-dashoffset: 0;
  }
  50% {
    stroke-dasharray: 90, 200;
    stroke-dashoffset: -35px;
  }
  100% {
    stroke-dashoffset: -125px;
  }
}


Получаем данные для постов

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

Сервис {JSON} Placeholder предоставляет нам фейковые данные, которые мы можем использовать для нашего примера с бесконечной прокруткой

Так как мы хотим сделать бесконечную прокрутку постов, будем брать данные по следующему адресу https://jsonplaceholder.typicode.com/posts, который будет нам отдавать список постов в JSON формате

На главной странице сервиса {JSON} Placeholder можно видеть пример использования, то есть получение данных через метод fetch() и вывод их в консоль браузера

fetch('https://jsonplaceholder.typicode.com/todos/1')
  .then(response => response.json())
  .then(json => console.log(json))


Так как для примера мы будем использовать библиотеку Axios для запросов, то адаптируем код, приведенный выше, под нашу задачу

axios.get(`https://jsonplaceholder.typicode.com/posts`).then(response => console.log(response.data))

Если кратко, логика кода следующая - мы просим подключенную библиотеку Axios сделать запрос по указанному адресу, и когда придёт ответ (response), вывести из этого ответа в консоль только массив с данными (data), который содержится в этом ответе

Так будет выглядеть в консоли браузера полный ответ response

Object { data: (100) […], status: 200, statusText: "OK", headers: {…}, config: {…}, request: XMLHttpRequest }

… а так как нам нужен только массив с данными, то выводим в консоль браузера только response.data

Array(100) [ {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, … ]

В результате получаем массив из 100 записей, пока что выведенный в консоль браузера, но теперь у нас есть данные, с которыми мы можем работать



Добавляем Vue в HTML структуру

Если вы не знакомы с Vue.js, можно обзорно познакомится в официальной документации - https://ru.vuejs.org/v2/guide/, и возможно данная статья станет мотивацией к изучению этого фреймворка :)

Кратко опишу изменения, которые мы внесём в HTML-структуру для использования возможностей Vue

Чтобы Vue смог работать с нашей вёрсткой, обернём всю вёрстку в <div id="app"></div>

Атрибут v-cloak можно добавить для того, чтобы в браузере при загрузке страницы сразу отображалась вёрстка, уже обработанная фреймворком (https://ru.vuejs.org/v2/api/#v-cloak)

Для блока с постом добавим vue-атрибут v-for="post in posts", который будет отрисовывать посты на основе полученных данных. Чтобы Vue понимал, что каждый пост уникален, добавляем еще один vue-атрибут :key="post.id"

Данные для каждого поста будем выводить с помощью специальной записи {{post.id}}, {{post.title}}, {{post.body}}

Для <div class="posts__loading"></div> добавляем vue-атрибут v-if="more", который будет отвечать за отображение лоадера, опираясь на значение переменной more

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Infinite Scroll</title>
    <link rel="stylesheet" href="css/bootstrap-reboot.min.css">
    <link rel="stylesheet" href="css/app.css">
  </head>
  <body>
    <div id="app" v-cloak> <!-- обернули верстку в основной блок #app -->
      <div class="posts">
        <div class="container">
          <div class="posts__title">Infinite Scroll</div>
          <div class="posts__list">
            <div
              class="posts__post post"
              v-for="post in posts" <!-- добавили атрибут v-for -->
              :key="post.id" <!-- добавили атрибут :key -->
            >
              <div class="post__id">{{post.id}}</div> <!-- здесь будем выводить идентификатор поста -->
              <div class="post__title">{{post.title}}</div> <!-- здесь будем выводить название поста -->
              <div class="post__body">{{post.body}}</div> <!-- здесь будем выводить текст поста -->
            </div>
          </div>
          <div class="posts__loading" v-if="more"> <!-- добавили условный атрибут v-if -->
            <svg viewBox="25 25 50 50">
              <circle cx="50" cy="50" r="20"></circle>
            </svg>
          </div>
        </div>
      </div>
    </div>
    <script src="js/vue.global.prod.js"></script>
    <script src="js/axios.min.js"></script>
    <script src="js/app.js"></script>
  </body>
</html>


Инициализируем Vue

Пока мы не сделали эффект плавного появления постов, добавим в HTML-структуре блоку <div class="posts__post post"> класс post_active, чтобы блок сразу был отображён

<div class="posts__list">
  <div
    class="posts__post post post_active" 
    v-for="post in posts"
    :key="post.id"
  > <!-- добавили класс post_active -->
    <div class="post__id">{{post.id}}</div>
    <div class="post__title">{{post.title}}</div>
    <div class="post__body">{{post.body}}</div>
  </div>
</div>


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

/* Описываем логику Vue */
const App = {
  data() {
    return {
      posts: [] /* создаем пустой массив posts */
    }
  },  
  mounted() { /* при загрузке страницы, когда Vue готов к работе */
    /* делаем запрос и полученный массив данных помещаем в выше созданный пустой массив posts */
    axios.get(`https://jsonplaceholder.typicode.com/posts`).then(response => this.posts = response.data)
  }
} 
/* Инициализируем выше описанную логику для блока #app, в который мы обернули всю нашу вёрстку */ 
Vue.createApp(App).mount('#app')

Теперь наша вёрстка, а конкретно блок <div class="posts__post post post_active"></div> в атрибуте v-for="post in posts" видит, что массив posts заполнен данными и может отдельно для каждого поста взять необходимые данные и поместить их в соответствующие блоки

<div
  class="posts__post post post_active"
  v-for="post in posts"
  :key="post.id"
>
  <div class="post__id">{{post.id}}</div> <!-- здесь идентификатор поста -->
  <div class="post__title">{{post.title}}</div> <!-- здесь название поста -->
  <div class="post__body">{{post.body}}</div> <!-- здесь текст поста -->
</div>


На данный момент при загрузке страницы у нас отрисовываются все посты, но мы хотим выводить посты по частям при прокрутке, в этом нам поможет Intersection Observer API



Intersection Observer API

Пару слов о том, что такое Intersection Observer API, и какую задачу решает

Раньше для того, чтобы определить, находится ли какой-либо элемент в видимой части браузера, мы делали определенные “тяжелые” для браузера расчёты

Теперь браузер предоставляет нам наиболее производительный и надежный способ определить это с помощью Intersection Observer API

Мы сначала настраиваем наблюдение, затем указываем, за какими элементами необходимо наблюдать

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

Технически мы узнаем, находится ли элемент в видимой области браузера с помощью свойства entry.isIntersecting, которое возвращает false, пока элемент находится вне видимой области, а если элемент хотя бы частично появился, то возвращает true

Для наглядности выведем в консоль значение entry.isIntersecting для лоадера

Не буду перегружать статью дополнительными возможностями и настройками Intersection Observer API, так как для нашего примера достаточно настроек по умолчанию

Подробнее можно почитать и в документации MDN Web Docs - Intersection Observer API


В нашем случае мы рассмотрим два варианта использования:

  • будем подгружать посты по мере прокрутки страницы
  • будем анимировать появление каждого поста, до которого была прокручена страница

Рассмотрим работу Intersection Observer API на нашем примере пока что абстрактно

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

/* настраиваем наблюдение */
const loadingObserver = new IntersectionObserver(entries => {
  entries.forEach(entry => { /* для каждого наблюдаемого элемента */
    if (entry.isIntersecting) { /* если элемент находится в видимой части браузера */
      /* то подгружаем очередные 10 постов */
    }
  })
});

/* указываем, что необходимо наблюдать за лоадером */
loadingObserver.observe(document.querySelector('.posts__loading'))


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

/* настраиваем наблюдение */
const postsObserver = new IntersectionObserver(entries => {
  entries.forEach(entry => { /* для каждого наблюдаемого элемента */
    if (entry.isIntersecting) { /* если элемент находится в видимой части браузера */
      /* добавляем ему активный класс
    }
  })
});

/* указываем, что необходимо наблюдать за каждым постом */
document.querySelectorAll('.posts__post').forEach(post => {
  postsObserver.observe(post)
})


Прежде чем написать итоговый код

До того, как мы напишем итоговый код, нам необходимо еще рассмотреть несколько моментов

Как получать по 10 постов

Сервис {JSON} Placeholder основан на JSON Server, который предоставляет возможность получать данные по частям - https://github.com/typicode/json-server#paginate

В нашем случае, запрос может выглядеть следующим образом…

axios.get('https://jsonplaceholder.typicode.com/posts?_page=1&_limit=10')

…но в итоговом коде, мы будем подставлять значения для _page и _limit динамически


Как в строку динамически подставить значения

Чтобы динамически подставлять в строку значения, воспользуемся шаблонными строками

Вместо обычных одинарных или двойных кавычек используем обратные кавычки `` и подставляем значения таким образом

axios.get(`https://jsonplaceholder.typicode.com/posts?_page=${this.page}&_limit=${this.limit}`)

MDN Web Docs - Шаблонные строки


Деструктуризация объекта

Сделав запрос, мы получаем в ответ объект с множеством свойств, чтобы из всего объекта получить только интересующие нас данные, воспользуемся деструктуризацией объекта

const {data} = axios.get(`https://jsonplaceholder.typicode.com/posts?_page=${this.page}&_limit=${this.limit}`)

learn.javascript.ru - Деструктуризация объекта


Как добавить массив в другой массив

Нам необходимо будет добавлять в уже имеющийся массив постов, очередной массив со следующими постами

Для этого мы воспользуемся Spread syntax - ...

this.posts.push(...data)

MDN Web Docs - Spread syntax



Пишем итоговый JavaScript код

Логика итогового кода следующая - при загрузке страницы, запускаем наблюдение за лоадером. Так как постов пока нет, лоадер сразу же появляется в видимой части браузера, и срабатывает метод получения постов. После того, как посты получены, запускается наблюдение за постами. При прокрутке страницы, как только очередной пост оказывается в видимой области браузера, добавляем ему активный класс для анимированного появления. Как только 10 постов будет прокручено, снова видим лоадер, который снова запускает метод получения очередных постов

Чтобы срабатывала анимация появления постов, не забудем убрать активный класс post_active у блока <div class="posts__post post"></div>

Итоговый код с комментариями

/* Пишем логику Vue */
const App = {
  /* объявляем необходимые переменные */
  data() {
    return {
      posts: [], /* массив с постами */
      page: 1, /* начальная страница для получения постов */
      limit: 10, /* количество постов получаемых за один раз */
      more: true /* как только закончатся посты, назначаем false */
    }
  },
  methods: {
    /* создаём метод для получения постов */
    async getPosts() {
      try {
        const {data} = await axios.get(`https://jsonplaceholder.typicode.com/posts?_page=${this.page}&_limit=${this.limit}`) /* получаем из ответа только массив с данными постов */
        this.posts.push(...data) /* добавляем полученный массив в массив с постами */
      } catch (e) {
        console.log(e.message) /* если произошла ошибка, выводим в консоль */
      }
    },
    /* создаём метод для наблюдения за лоадером */
    setLoadingObserver() {
      /* создаём наблюдение */
      const loadingObserver = new IntersectionObserver(entries => {
        entries.forEach(entry => { /* для каждого элемента */
          if (entry.isIntersecting) { /* если элемент в видимой области браузера */
            if (this.page > 10) { /* если значение страницы уже больше 10 */
              this.more = false /* то назначаем значение false */
              return /* и прекращаем выполнение функции */
            }
            setTimeout(() => { /* для наглядности добавим небольшую задержку */
              this.getPosts() /* вызываем метод для получения постов */
              this.page++ /* увеличиваем значение страницы на 1 */
            }, 1000) /* задержка перед получением постов 1 секунда для наглядности  */

          }
        })
      });
      /* указываем, что наблюдаем за лоадером */
      loadingObserver.observe(document.querySelector('.posts__loading'))
    },
    /* создаём метод для наблюдения за постами */
    setPostsObserver() {
      /* создаём наблюдение */
      const postsObserver = new IntersectionObserver((entries, observer) => {
        entries.forEach(entry => { /* для каждого элемента */
          if (entry.isIntersecting) { /* если элемент в видимой области браузера */
            entry.target.classList.add('post_active') /* добавляем активный класс наблюдаемому элементу, то есть посту */
            observer.unobserve(entry.target); /* и отключаем наблюдение за этим постом */
          }
        })
      });
      
      document.querySelectorAll('.posts__post:not(.post_active)').forEach(post => { /* получаем только неактивные посты */
        postsObserver.observe(post) /* указываем, что наблюдаем за ними */
      })
    }
  },
  /* при загрузке страницы, когда Vue готов к работе, запускаем метод наблюдения за лоадером */
  mounted() { 
    this.setLoadingObserver()
  },
  /* когда очередные новые посты будут загружены, запускаем метод наблюдения за ними */
  updated() {
    this.setPostsObserver()
  }
}

/* Инициализируем Vue */
Vue.createApp(App).mount('#app')


Получаем следующий результат - https://frontips.ru/infinite-scroll

Можно скачать архив с результатом - https://frontips.ru/infinite-scroll/dist.zip



Итоги

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

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

Буду рад, если статья оказалась полезной

Спасибо за ваше внимание и уделённое время!