무한 스크롤 구현하기

김영준·2023년 7월 10일
0

TIL

목록 보기
10/91
post-thumbnail

무한 스크롤이란?

컨텐츠를 페이징 하는 기법 중 하나로,
아래로 스크롤 하다가 컨텐츠의 마지막 요소를 볼 즈음 다음 컨텐츠가 있으면 불러오는 방식이다.

Facebook, Twitter, Instagram 등 SNS에서 주로 사용된다.

구현 방식은 크게 두 가지가 있다.

1. scroll 이벤트를 사용해 구현하기

Window의 scroll 이벤트를 통해 스크롤링이 일어날 때마다 화면 전체의 height와 스크롤 위치를 통해
스크롤이 컨텐츠 끝 즈음에 다다랐는지 체크해서 처리하는 방식

<!-- index.html -->

<html>
  <head>
    <title>무한 스크롤</title>
  </head>
  <body>
    <main class="app"></main>
    <script src="/src/main.js" type="module"></script>
  </body>
</html>
// main.js

import App from "./App.js";

const $target = document.querySelector(".app");

new App({
  $target,
});
// App.js

import PhotoList from "./PhotoList.js";
import { request } from "./api.js";

export default function App({ $target }) {
  const $h1 = document.createElement("h1");
  $h1.innerText = "Cat Photos";
  $h1.style.textAlign = "center";
  $target.appendChild($h1);

  this.state = {
    limit: 5,
    nextStart: 0, // limit 갯수만큼 계속 더해짐
    photos: [],
    totalCount: 0,
    isLoading: false,
  };
  const photoListComponent = new PhotoList({
    $target,
    initialState: {
      isLoading: this.state.isLoading,
      photos: this.state.photos,
      totalCount: this.state.totalCount,
    },
    onScrollEnded: async () => {
      await fetchPhotos();
    },
  });

  this.setState = (nextState) => {
    this.state = nextState;
    photoListComponent.setState({
      isLoading: this.state.isLoading,
      photos: this.state.photos,
      totalCount: this.state.totalCount,
    });
  };

  const fetchPhotos = async () => {
    this.setState({
      ...this.state,
      isLoading: true,
    });
    const { limit, nextStart } = this.state;
    const photos = await request(`/cat-photos?_limit=${limit}&_start=${nextStart}`);
    this.setState({
      ...this.state,
      nextStart: nextStart + limit,
      photos: this.state.photos.concat(photos),
      isLoading: false,
    });
  };

  const initialize = async () => {
    const totalCount = await request("/cat-photos/count");
    this.setState({
      ...this.state,
      totalCount,
    });
    await fetchPhotos();
  };

  initialize();
}

// PhotoList.js

export default function PhotoList({ $target, initialState, onScrollEnded }) {
  let isInitailize = false;
  const $photoList = document.createElement("div");
  $target.appendChild($photoList);
  this.state = initialState;

  this.setState = (nextState) => {
    this.state = nextState;
    this.render();
  };

  this.render = () => {
    if (!isInitailize) {
      $photoList.innerHTML = `
      <ul class="PhotoList__photos"></ul>
      `;
      isInitailize = true;
    }
    const { photos } = this.state;
    const $photos = $photoList.querySelector(".photoList__photos");

    photos.forEach((photo) => {
      // photo의 id 기준으로 렌더링이 되어있는지 체크
      if ($photos.querySelector(`[data-id="${photo.id}"]`) === null) {
        // 없으면 li 생성하고 $photos에 appendChild
        const $li = document.createElement("li");
        $li.setAttribute("data-id", photo.id);
        $li.style = "list-style:none";
        $li.innerHTML = `<img width="100%" src="${photo.imagePath}" />`;

        $photos.appendChild($li);
      }
    });
  };

  this.render();

  window.addEventListener("scroll", () => {
    const { isLoading, totalCount, photos } = this.state;
    const isScrollEnded = window.innerHeight + window.scrollY + 100 >= document.body.offsetHeight;

    // 스크롤이 맨 아래에 닿았을 때, 로딩 중이지 않을 때, 모든 데이터를 가져오기 전일 때
    if (isScrollEnded && !isLoading && photos.length < totalCount) {
      onScrollEnded();
    }
  });
}


2. Intersection Observer로 구현하기

현재 보고 있는 화면을 감시해서 노출이 되면 동작하는 방식

  • observe, unobserve를 잘 사용해야 한다.
  • threshold 값으로 observe 대상이 얼마나 노출되어 있는지에 따라 동작하게 할 수 있다.
  • 상황에 따라 무한 스크롤 UI보다는 직접 더 불러오는 인터렉션을 통해 로딩하는 게 나을 수 있다.
// PhotoList.js

export default function PhotoList({ $target, initialState, onScrollEnded }) {
  let isInitailize = false;
  const $photoList = document.createElement("div");
  $target.appendChild($photoList);
  this.state = initialState;

  this.setState = (nextState) => {
    this.state = nextState;
    this.render();
  };

  const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        // 감지 되었고, 로딩 중이 아닐 때
        if (entry.isIntersecting && !this.state.isLoading) {
          console.log("화면 끝 감지", entry);
          if (this.state.totalCount > this.state.photos.length) {
            onScrollEnded();
          }
        }
      });
    },
    {
      threshold: 1, // 뷰포트에 완전히 감지가 된 경우에만 불러옴
    }
  );

  let $lastLi = null;

  this.render = () => {
    if (!isInitailize) {
      $photoList.innerHTML = `
      <ul class="PhotoList__photos"></ul>
      `;
      isInitailize = true;
    }
    const { photos } = this.state;
    const $photos = $photoList.querySelector(".photoList__photos");

    photos.forEach((photo) => {
      // photo의 id 기준으로 렌더링이 되어있는지 체크
      if ($photos.querySelector(`[data-id="${photo.id}"]`) === null) {
        // 없으면 li 생성하고 $photos에 appendChild
        const $li = document.createElement("li");
        $li.setAttribute("data-id", photo.id);
        $li.style = "list-style:none; min-height: 500px;"; // 처음에 li가 딱 붙어있을 경우 예외 때문에 min-height을 지정
        $li.innerHTML = `<img width="100%" src="${photo.imagePath}" />`;

        $photos.appendChild($li);
      }
    });

    const $nextLi = $photos.querySelector("li:last-child");

    if ($nextLi !== null) {
      if ($lastLi !== null) {
        observer.unobserve($lastLi);
      }

      $lastLi = $nextLi;
      observer.observe($lastLi);
    }
  };

  this.render();
}
profile
꾸준히 성장하는 개발자 블로그

0개의 댓글