글 작성자: 망고좋아
반응형

 

🎯 무한 스크롤 구현 (feat. IntersectionObserver, Event Delegation)

  • TMDB API를 이용한 Vanilla JS SPA 홈페이지를 구현하고 있었다.
  • 메인 페이지에는 Upcoming 영화 1개를 가져오고 아래에는 각 장르별 추천 영화 5개씩 가져오고 있었다.
  • 무언가 부족해서 좀 더 많은 장르의 영화를 가져오려고 시도했지만 많은 데이터 요청으로 인하여 로딩 속도가 너무 느려졌다.
  • 개선점을 찾으려고 고민하던 끝에 스크롤이 맨 밑으로 내려갔을 때 api를 요청해서 다음 장르의 추천 영화를 가져오는 무한 스크롤을 적용하기로 결정했다.
  • 깃허브 코드 보기

 

🔍 결과 미리 보기

기존 화면

 

무한 스크롤 적용 화면

 

💡 생각

  • 무한 스크롤 구현에 대표적인 방법에는 스크롤 이벤트 or IntersectionObserver API가 있다.
  • 그중 debounce, throttle를 사용하지 않아도 되며 reflow 현상이 발생되지 않는 IntersectionObserver API를 사용해서 구현하기로 결정했다.

 

📝 추천 영화 템플릿

import { getUpcomingMovie, getMovieDetail, getSearchMovie, getMovieList } from "../api/api.js"
import clkRoute from "../functions/handleClickRoute.js";

const genereMovieView = async () => {
    const section = document.createElement('section');
    section.classList.add('genere__section')

    const genreName = ['액션', '어드벤처', '코미디', '애니메이션', '드라마', '로맨스', '공포', '스릴러', 'SF', '코미디', '범죄'];
    const generApiName = ['Action', 'Adventure', 'Comedy', 'Animation', 'Drama', 'Romance', 'Horror', 'Thriller', 'SF', 'Comedy', 'Crime'];


    let cnt = 0;

    const pageStart = async (cnt) => {
        let genreMovieList = [];
        const movieList = await getMovieList(generApiName[cnt]);

        let genreMovieTemplate = `
            <div class="my-0 mx-auto w-90vw xl:w-1220">
                <div>
                    <h2 class="text-3xl font-bold my-9">추천 ${genreName[cnt]} 영화!</h2>
                    <ul class="flex justify-between">
                        {{__movie_list_}}
                    </ul>
                </div>
            </div>
        `;

        const movieLi = [];
        movieList.forEach(element => {
            const year = element.release_date.substring(0, 4);
            const vote_average = String(element.vote_average).substring(0, 3);
            const posterPath = `https://image.tmdb.org/t/p/original/${element.poster_path}`;
            movieLi.push(`
                <li class="movie-detail cursor-pointer" id="${element.id}" route="/detail/${element.id}">
                    <a route="/detail/${element.id}">
                        <!-- <div class= "w-44 h-60 bg-left bg-no-repeat bg-contain" style="background-image: url(${posterPath})"></div> -->
                        <img src="${posterPath}" class= "w-44 h-60 bg-left bg-no-repeat bg-contain"></img>
                        <strong class="block w-44">${element.title}</strong>
                        <span class="block" class="inline-block">${year}</span>
                        <span class="block">⭐${vote_average}</span>
                    </a>
                </li>
            `);
        });
        genreMovieTemplate = genreMovieTemplate.replace("{{__movie_list_}}", movieLi.join(''));
        genreMovieList.push(genreMovieTemplate);

        if (cnt === 0) {
            section.innerHTML = genreMovieList.join('');
            document.querySelector('main').appendChild(section);
        }
        return genreMovieList;
    }
    await pageStart(cnt);
  • 메인 페이지에서 장르별 추천 영화를 제공해주는 gener.js 컴포넌트이다.
  • getMovieList는 api에 요청해서 해당 장르에 대한 영화 리스트를 배열 형태로 가져온다.
  • 시작과 동시에 1개의 장르는 화면에 보여줘야 되기 때문에 await pageStart(cnt);을 실행시켜 준다.
  • cnt === 0일 때만 main에 appendChild를 해준다. 그다음 리스트들은 section 아래로 들어가야 되기 때문에!

 

📝 이벤트 위임(Event Delegation)

  • 무한 스크롤을 구현하면서 마주한 첫 번째 에러는 DOM으로 생성한 요소에 이벤트 핸들러가 적용되지 않는 현상이었다.
  • 이벤트 핸들러는 당시에 생성된 dom 객체에만 적용되지만 추후 생성된 요소에는 적용되지 않는다.
  • 이 점을 해결하기 위해서 이벤트 위임을 사용하면 된다.
  • 이벤트 위임이란 하위 요소에 각각 이벤트를 붙이지 않고 상위 요소에서 하위 요소의 이벤트들을 제어하는 방식이다.
  • 즉, 이벤트 버블링을 이용해서 상위 요소에 이벤트 위임을 해주면 하위에 DOM으로 생성한 요소에 이벤트 핸들러를 적용시킬 수 있다.

 

handleClickRoute.js

const genereClkRoute = () => {
    const changePage = document.querySelector('.genere__section');

    changePage.addEventListener("click", event => {
        event.preventDefault();
        if (event.target.parentNode.matches("[route]")) {
            const pagePath = event.target.parentNode.getAttribute("route")
            navigateTo(pagePath);
        }
    })
}
  • 전체 추천 영화를 감싸고 있는 section 태그에 이벤트 위임을 해준다.
  • 그리고 클릭한 요소에 대한 정보는 event.target.parentNode로 영화 정보를 감싸고 있는 li태그 아래 a태그에 접근해준다. 접근 후 route 정보를 빼와서 navigateTo로 넘겨서 영화 상세 페이지를 라우팅 해준다.
    • 인라인 태그들을 블록 태그로 변환시켜주면 동일한 parentNode에 접근할 수 있다. (여기서 삽질한 건 안 비밀..)

 

📝 IntersectionObserver

  • 브라우저의 viewport와 설정한 요소(element)의 교차점을 관찰하며, 요소가 뷰포트에 포함되는지 포함되지 않는지, 사용자 화면에 지금 보이는 요소인지 아닌지를 구별하는 기능을 제공한다.
const io = new IntersectionObserver(callback, options) // 인터섹션 옵저버를 생성
io.observe(element) // 관찰할 대상(요소) 등록
  • callback함수는 관찰한 대상이 등록되거나 가시성에 변화가 생기면 콜백 함수를 실행
    • 콜백은 2개의 매개변수를 가진다. (entries, observer)
const io = new IntersectionObserver((entries, observer) => {}, options)
io.observe(element)

 

📕 entries

  • boundingClientRect: 관찰 대상의 사각형 정보(DOMRectReadOnly)
  • intersectionRect : 관찰 대상의 교차한 영역 정보(DOMRectReadOnly)
  • intersectionRatio : 관찰 대상의 교차한 영역 백분율(intersectionRect 영역에서 boundingClientRect 영역까지 비율, Number)
  • isIntersecting : 관찰 대상의 교차 상태(Boolean)
  • rootBounds : 지정한 루트 요소의 사각형 정보(DOMRectReadOnly)
  • target : 관찰 대상 요소(Element)
  • time : 변경이 발생한 시간 정보(DOMHighResTimeStamp)

 

📕 options

  • root
    • 타겟의 가시성을 검사하기 위해 뷰포트 대신 사용할 요소 객체(루트 요소)를 지정한다
    • { root: document.getElementById('my-viewport') }
  • rootMargin
    • 바깥 여백(Margin)을 이용해 Root 범위를 확장하거나 축소할 수 있다.
    • (callback, { rootMargin: '200px 0px'})
  • threshold
    • 옵저버가 실행되기 위해 타겟의 가시성이 얼마나 필요한지 백분율로 표시한다.
    • 0 -> 타겟의 가장자리 픽셀이 root 범위를 교차하는 순간 옵저버 실행
    • 0.3 -> 타겟의 가시성이 30%일 때 옵저버 실행
    • [0, 0,3, 1] -> 타겟의 가시성이 0%, 30%, 100%일 때 모두 옵저버 실행
    • (callback, { threshold: 0.3})

 

📕 Methods

  • observe()
    • 대상 요소의 관찰을 시작
  • unobserve()
    • 대상 요소의 관찰을 중지
  • disconnect()
    • IntersectionObserver 인스턴스가 관찰하는 모든 요소의 관찰을 중지

 

📝 무한 스크롤 구현(Infinite Scroll)

const lastSection = document.querySelector('.genere__section');

const io = new IntersectionObserver((entries, observer) => {
    entries.forEach(async entry => {
        if (entry.isIntersecting) {
            io.unobserve(lastSection);

            cnt++;
            if (cnt === genreName.length) {
                observer.disconnect();
            } else {
                const div = document.createElement('div');
                div.innerHTML = await pageStart(cnt);
                document.querySelector('.genere__section').appendChild(div);
                io.observe(div);
            }
        }
    },
        {
            threshold: 0.3
        }
    );
});
io.observe(lastSection);
  • lastSection는 관찰할 대상 -> 화면 마지막에 있는 영화 리스트
  • 관찰 대상이 threshold: 0.3으로 lastSection의 가시성이 30% 일 때 isIntersecting이 되면 관찰하고 있는 대상을 unobserve 해주고 await pageStart(cnt)으로 가져온 영화 리스트를 appendChild해준다.
  • 그리고 새롭게 appendChild된 항목에 대해서 다시 observe 시작!
  • 그리고 배열에 넣어준 장르를 모두 순회했을 때 observer.disconnect();으로 무한 스크롤을 종료시켜준다.

 

🏷 후기

  • 처음에는 IntersectionObserver을 복잡하게 생각했는데 무한 스크롤 구현 후에는 복잡하지 않고 편리한 api라고 느껴진다.
  • 추후에 IntersectionObserver를 사용해서 이미지 레이지 로딩을 구현해보려고 한다.
  • 공부하면서 정리한 내용으로 틀린 내용이 있을 수 있으므로 댓글 부탁드립니다!

 

📌 참고

반응형