무한 스크롤 UI 구현하기

나혜수·2023년 3월 6일
0

자바스크립트 실전

목록 보기
16/19

무한스크롤

컨텐츠를 페이징하는 기법 중 하나로, 아래로 스크롤하다 컨텐츠의 마지막 요소를 볼 즘 다음 컨텐츠가 있으면 불러오는 방식이다. Facebook, Instargram 등 SNS에서 주로 사용된다.

무한 스크롤 UI는 모바일 화면과 같은 작은 화면에서 주로 쓰이는 방법이기 때문에 모바일 환경임을 가정하고 실습한다. 개발자 도구에서 Toggle device toolbar를 iphone SE로 설정한다.

구현 방식

  1. window의 scroll 이벤트 이용
    스크롤링이 일어날 때마다 화면 전체 높이와 스크롤의 위치를 통해 스크롤이 컨텐츠 끝 즘에 다다랐는지 체크한다.

  2. intersection observer 방식

API

https://misc.edu-api.programmers.co.kr/cat-photos

  1. cat-photos?_limit=5&_start=0
    • limit : 한 번에 가져올 사진의 개수
    • start : 사진 시작 번호
  2. cat-photos/count 사진 전체 개수
[
    {
        "id":1,
        "imagePath":"https://misc-api-static.s3.ap-northeast-2.amazonaws.com/cat-photos/20200428_052455.jpg",
        "cats":"1, 2",
        "created_at":"2021-08-22T12:13:26.364Z",
        "updated_at":"2021-08-22T12:26:50.467Z",
        "photo_in_cats":[
            {
                "id":1,
                "name":"나나",
                "colors":"yellow, white",
                "birthday":null,
                "profileImage":"https://misc-api-static.s3.ap-northeast-2.amazonaws.com/cat-photos/20201217_012751.jpg",
                "published_at":"2021-08-22T12:09:21.753Z",
                "created_at":"2021-08-22T12:09:20.857Z",
                "updated_at":"2021-08-22T12:11:16.963Z"
            },
            {
                "id":3,
                "name":"모나",
                "colors":"black, white",
                "birthday":null,
                "profileImage":"https://misc-api-static.s3.ap-northeast-2.amazonaws.com/cat-photos/20210821_075630.jpg",
                "published_at":"2021-08-22T12:09:49.615Z",
                "created_at":"2021-08-22T12:09:48.793Z",
                "updated_at":"2021-08-22T12:12:14.809Z"
            }
        ]
    }
]

1. window의 scroll 이벤트 이용

스크롤링이 일어날 때마다 화면 전체 높이와 스크롤의 위치를 통해 스크롤이 컨텐츠 끝 즘에 다다랐는지 체크한다.

index.html

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <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
})

api.js

const API_END_POINT = "https://misc.edu-api.programmers.co.kr/"

export const request = async(url) => {
    try{
        const res = await fetch(`${API_END_POINT}${url}`)
        if(!res.ok){
            throw new Error("api 호출 오류")
        }
        return await res.json()    
    } catch(e){
        alert(e.message)
    }
   
}

photoList.js

export default function PhotoList({$target,initialState,onScrollEnd}){
    let isInitialize = false

    const $photoList = document.createElement('div')
    $target.appendChild($photoList)

    this.state = initialState
    /*{   photos: [
                    id: 1,
                    imagePath: ''
                  ],
          isLoading: this.state.isLoading
          totalCount: }  */
    

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

    this.render = () => {
        if(!isInitialize){
            $photoList.innerHTML = `
                <ul class = "PhotoList_photos">
                </ul>
                <button class='PhotoList_loadMore'>Load more</button>
            `

            isInitialize = true     
        }

        const {photos} = this.state

        const $photos = $photoList.querySelector(".PhotoList_photos") // <ul> 태그 

        photos.forEach( (photo) => {
            // id를 기준으로 렌더링이 되어있는지 확인
            if ($photos.querySelector(`li[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()

    // 1.버튼을 누르면 사진을 불러옴

    // $photoList.addEventListener('click',e => {
    //     // 로딩 중이 아닐때만 호출 
    //     if(e.target.className === 'PhotoList_loadMore' && !this.state.isLoading){
    //         onScrollEnd()
    //     }
    // })


    // 2. 스크롤바가 화면 끝에 다다르면 사진을 불러옴 
    
    window.addEventListener('scroll', (e) => {
        const {isLoading, totalCount, photos} = this.state

        // 스크롤이 화면 끝에 닿았는지 아닌지 확인 => 닿으면 true
        const scrollEnded = (window.innerHeight + window.scrollY) + 100 >= document.body.offsetHeight
        if(scrollEnded && !isLoading && photos.length < totalCount) {
            onScrollEnd()
        }
    })
}

App.js

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

export default function App ({$target}){
    const $h1 = document.createElement('h1')
    $h1.innerHTML = '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: {
            photos: this.state.photos,
            isLoading: this.state.isLoading,
            totalCount: this.state.totalCount},
        onScrollEnd: async() => {
            fetchPhotos()
        }
    })

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

    const fetchPhotos = async() => {
        // 데이터 패치를 시작하면 isLoading을 true
        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,
                ...photos
            ],
            // 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()
}

2. intersection observer 방식

기존 scroll 이벤트의 문제

웹사이트를 개발할 때 특정 위치에 도달했을 때 어떤 액션을 취해야 한다면 어떻게 구현할 수 있을까? 보통 addEventListener( ) scroll 이벤트가 먼저 떠오른다. document에 스크롤 이벤트를 등록하고, 특정 지점을 관찰하며 엘리먼트가 위치에 도달했을 때 실행할 콜백함수를 등록하는 것이다.

하지만 scroll 이벤트는 단시간에 수백번, 수천번 호출될 수 있고 동기적으로 실행되기 때문에 메인 스레드에 영향을 준다. 또한 한 페이지 내에 여러 scroll 이벤트가 등록되어 있을 경우, 각 엘리먼트마다 이벤트가 등록되어 있기 때문에 사용자가 스크롤할 때마다 이를 감지하는 이벤트가 끊임없이 호출된다. (디바운싱 Debouncing & 쓰로틀링 Throttling을 통해 이러한 문제를 개선시킬 수도 있다.)

그리고 특정 지점을 관찰하기 위해서는 getBoundingClientRect( ) 함수를 사용해야 하는데, 이 함수는 reflow 현상이 발생한다는 단점이 있다.
(reflow : 리플로우는 브라우저가 웹 페이지의 일부 또는 전체를 다시 그려야하는 경우 발생)

Intersection Observer API의 등장

Intersection Observer API (교차 관찰자 API)를 사용하면 위와 같은 문제를 해결할 수 있다. 비동기적으로 실행되기 때문에 메인 스레드에 영향을 주지 않으면서 변경 사항을 관찰할 수 있다. 또한 IntersectionObserverEntry 속성을 활용하면 getBoundingClientRect( )를 호출한 것과 같은 결과를 알 수 있기 때문에 따로 getBoundingClientRect( ) 함수를 호출할 필요가 없어 리플로우 현상을 방지할 수 있다.

Intersection Observer 사용 방법

Intersection observer API란 어떠한 특정한 요소를 target으로 설정하여 observer가 그 target이 상위 요소 혹은 뷰포트와 교차가 발생하는지를 비동기적으로 관찰하게 하는 API이다.
관찰할 target을 생성하고, IntersectionObserver( ) 생성자를 통해 target을 관찰할 새로운 IntersectionObserver 객체를 생성한다.

const target = document.querySelector("#target");
const observer = new IntersectionObserver(callback(entries,observer), options]);

Callback
관찰자는 target이 뷰포트나 특정 요소와 교차하는 지에 대해 관찰을 하다가 교차가 발생하면 인자에 전달된 콜백 함수를 실행한다. 콜백 함수는 entries와 observer가 인자로 전달된다.

  • entries : IntersectionObserverEntry 객체의 리스트. 배열 형식으로 반환하기 때문에 forEach를 사용해서 처리를 하거나, 단일 타겟의 경우 배열인 점을 고려해서 코드를 작성해야 합니다.

  • observer : 콜백함수가 호출되는 IntersectionObserver

Options
Root
어떤 요소를 기준으로 target이 들어오고 나가는 것을 확인할 것인지 지정
기본값은 null, 브라우저 ViewPort이다.
RootMargin
root 범위를 확장하거나 축소할 수 있다. 기본값은 '0px, 0px, 0px, 0px'
threshold
target과 root의 교차가 얼마나 일어나야 callback을 호출할지 표시
0.0 (target이 root 영역에 진입 시작) ~ 1.0 (target 전체가 root와 교차) 사이의 숫자로 표시
기본값은 0, 0.0 ~ 1.0 사이의 숫자 혹은 이 숫자들로 이루어진 배열


IntersectionObserver 메소드

observer.observe(target);      // 관찰자가 target의 관찰을 시작한다.
observer.unobserve(target);    // 관찰자가 target의 관찰을 중단한다.
observer.disconnect();         // 관찰자가 모든 관찰을 중단한다.

IntersectionObserverEntry Properties

  • boundingClientRect
    target의 정보를 반환한다.
    getBoundingClientRect( )를 사용하면 같은 값을 얻을 수 있다.
    (bottom, height, left, right, top, width, x, y)

  • intersectionRatio
    target과 root가 교차되는 부분의 정보를 반환한다.

  • intersectionRect
    target과 root가 얼마나 교차되는 지를 수치로 반환한다 (0.0 ~ 1.0사이 숫자)

  • isIntersecting
    target과 root가 교차된 상태인지 (true) 아닌지 (false)를 boolean값으로 반환한다.

  • rootBounds
    root요소에 대한 정보를 반환한다. 아무런 옵션을 전달하지 않으면 viewport를 기준으로 한다.

  • target
    관찰하고 있는 target element를 반환한다.

  • time
    target과 root의 교차가 일어난 시간을 반환한다.

example

스크롤이 해당 이미지의 위치에 도달했을 때 이미지를 로딩하는 코드이다.

// IntersectionObserver의 options를 설정합니다.
const options = {
  root: null,
  // 타겟 이미지 접근 전 이미지를 불러오기 위해 rootMargin을 설정
  rootMargin: '0px 0px 30px 0px',
  threshold: 0
}

// IntersectionObserver 등록
const io = new IntersectionObserver((entries, observer) => {
  entries.forEach(entry => {
    // 관찰 대상이 viewport 안에 들어온 경우 image 로드
    if (entry.isIntersecting) {
      // data-src 정보를 타켓의 src 속성에 설정
      entry.target.src = entry.target.dataset.src;
      // 이미지를 불러왔다면 타켓 엘리먼트에 대한 관찰을 멈춘다.
      observer.unobserve(entry.target);
    }
  })
}, options)

// 관찰할 대상을 선언하고, 해당 속성을 관찰시킨다.
const images = document.querySelectorAll('.image');
images.forEach((el) => {
  io.observe(el);
})
<div class="example">
  <img src="https://picsum.photos/600/400/?random?0" alt="random image" class="image-default">
  <img data-src="https://picsum.photos/600/400/?random?1" alt="random image" class="image">
  <img data-src="https://picsum.photos/600/400/?random?2" alt="random image" class="image">
  <img data-src="https://picsum.photos/600/400/?random?3" alt="random image" class="image">
  <img data-src="https://picsum.photos/600/400/?random?4" alt="random image" class="image">
  <img data-src="https://picsum.photos/600/400/?random?5" alt="random image" class="image">
  <img data-src="https://picsum.photos/600/400/?random?6" alt="random image" class="image">
  <img data-src="https://picsum.photos/600/400/?random?7" alt="random image" class="image">
</div>

photoList.js

export default function PhotoList({$target,initialState,onScrollEnd}){
    let isInitialize = false

    const $photoList = document.createElement('div')
    $target.appendChild($photoList)

    this.state = initialState
    /*{   photos: [
                    id: 1,
                    imagePath: ''
                  ],
          isLoading: this.state.isLoading
          totalCount: }  */


    // IntersectionObserver
    const observer = new IntersectionObserver(entries => {
        entries.forEach(entry => {
            if(entry.isIntersecting && !this.state.isLoading){
                observer.unobserve(entry.target)}
            if (this.state.totalCount > this.state.photos.length){  
                onScrollEnd()
            }
        })
    },{
        root: null,
        threshold: 0.5
    })  
    

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

    this.render = () => {
        if(!isInitialize){
            $photoList.innerHTML = `
                <ul class = "PhotoList_photos">
                </ul>
                <button class='PhotoList_loadMore'>Load more</button>
            `

            isInitialize = true     
        }

        const {photos} = this.state

        const $photos = $photoList.querySelector(".PhotoList_photos") // <ul> 태그 

        photos.forEach( (photo) => {
            // id를 기준으로 렌더링이 되어있는지 확인
            if ($photos.querySelector(`li[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:50px;'
                $li.innerHTML = `<img width="100%" src = ${photo.imagePath} />`

                $photos.appendChild($li)
            }   
        })

        const $lasttLi = $photos.querySelector('li:last-child')

        if($lasttLi != null){
            observer.observe($lasttLi)
        } 
    }  
     
    this.render()

}
profile
오늘도 신나개 🐶

0개의 댓글