[DAY 30] VanillaJS를 통한 자바스크립트 기본역량 강화 Ⅱ (2)

송히·2023년 10월 30일
post-thumbnail

Today I Learn📖

  • 무한 스크롤 UI 구현 (강의)

무한 스크롤 (Infinite scroll)

아래로 스크롤하다가 컨텐츠의 마지막 요소를 볼 쯤에 다음 컨텐츠가 있으면 불러오는 방식
=> 컨텐츠를 페이징 하는 기법 중 하나
=> 무한 스크롤 사용시 footer에는 접근 못함

  • 무한 스크롤 UI 구현 방법
    1. scroll 이벤트로 높이 계산해서 처리하는 방법 (전통적 방법)
      : window의 scroll 이벤트를 이용해 스크롤링이 일어날 때마다 화면 전체의 height와 스크롤 위치를 파악해 스크롤이 컨텐츠 끝 쯤인지 체크해서 처리
      -> 스크롤 할 때마다 이벤트 발생하기 때문에 디바운스로 막을 수 있음
    2. Intersection Observer: 내가 지정한 위치에 닿는지를 감지해서 불러오기 처리
      -> observe, unobserve를 잘 해야함
      -> threshold 값을 통해 감시 대상이 보이는 면적 정할 수 있음
  • 상황에 따라 무한 스크롤보다 버튼 등의 인터랙션을 통한 로딩이 더 나을 수 있음


API 설명

https://도메인/cat-photos?_limit=${limit}&_start=${start}

  • limit: 한 번에 가져올 사진 개수
  • start: 사진을 가져올 시작 위치, limit 개수만큼 계속 더해짐


높이 계산을 통한 무한 스크롤 구현

다음 사진 ${limit}개 불러오기

PhotoList 파라미터 onScrollEnded에 비동기로 fetchPhotos 넣기
-> start에 limit 더하면서 누적합 만들기
-> isLoading 이용해서 fetchPhotos() 실행중에는 재요청 막기

// App.js
this.state = {
  limit: 5, // 하드코딩 되어있지만 바꿔야함
  nextStart: 0, // 하드코딩 되어있지만 바꿔야함
  photos: [],
  isLoading: false, // 한 번에 여러 개 요청 방지 -> 로딩중이면 반응 안 함
}

const photoListComponent = new PhotoList({
  $target,
  initialState: {
    isLoading: this.state.isLoading,
    photos: this.state.photos,
  },
  onScrollEnded: async() => {
    await fetchPhotos()
  }
})

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

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, // nextstart는 누적 합
    photos: this.state.photos.concat(photos), // [...this.state.photos, ...photos]으로 풀어 써도 됨
    isLoading: false,
  })
  
fetchPhotos()

추가 사진 불러올 때 스크롤 유지하기

  • 처음에만 기본 HTML 구조(ul) 생성
  • 그 후 사진을 불러올 때마다 li로 만들어지고, 그 li는 처음에 만든 ul에 추가됨
    -> 이때 현재 ul각 사진의 id값이 있는지 확인해서, 없을 때만 li로 생성 (이미 불러왔던 사진은 다시 추가하지 않아 중복 렌더링 방지됨)
// PhotoList.js
this.render = () => {
  if (!isInitialize) { // 처음만 실행됨 (초기화 안 됐을 때만), 기본 HTML 구조 생성
	$photoList.innerHTML = `
	  <ul class="PhotoList_photos"></ul>
	  <button ="PhotoList__loadMore"> 더 불러오기 </button>
	  // 사진 불러와지는지 확인하기 위해 버튼 임시 생성
	`
	isInitialize = true
  }

  const { photos } = this.state
  const $photos = $photoList.querySelector('PhotoList__photos')

  photos.forEach (photo => {
    /* ⬇️ 새로 불러온 사진인지 id값으로 체크 */
    if ($photos.querySelector(`li[data-id="${photo.id}"]`) === null) { 
      /* ⬇️ 새로 불러온 사진이면 li로 생성하고 $photos에 appendChild */
      const $li = document.createElement('li') // li 생성
      $li.setAttribute('data-id', photo.id) // li에 사진 id 넣기
      $li.style = 'list-style: none;'
      $li.innerHTML = '<img width="100%" src="${photo.imagePath}" />'

      $photos.appendChild($li) // $photos에 추가
    }
  })
}

this.render()

$photoList.addEventListener ('click', e => { // 클릭 이벤트는 스크롤 이벤트 생성한 후 없앨 예정
  if (e.target.className === 'PhotoList__loadMore' && !.this.state.isLoading) { // 로딩중이 아닐 때만 호출됨
	onScrollEnded()
  }
})

다음 사진 자동 로딩 (무한 스크롤링)

isScrollEnded을 이용해 페이지 끝인지 판단 후 끝 && 로딩중 아니면 onScrollEnded() 호출로 다음 사진 로딩
-> isScrollEnded의 반환값은 Boolean

// PhotoList.js
 ...
 
$photoList.addEventListener ('click', e => { ... })

/* (window.innerHeight + window.scrollY): 현재 브라우저 크기(높이) + 스크롤 한 길이 => 현재 화면의 맨 아래 위치
 * (+ 100): 완전 끝에 닿지 않고, 끝 쯤에 가도 인식되도록 여유분 준 것
 * document.body.offsetHeight: 렌더링 된 전체 높이
*/
window.addEventListener ('scroll', () => {
  const isScrollEnded = (window.innerHeight + window.scrollY + 100) >= document.body.offsetHeight // true || false
  /* 수식을 if문이 아닌 변수에 담은 이유는 읽는 사람에게 "페이지 끝에 닿았는지 확인하는 값"이라는 걸 직관적으로 알리기 위해서 ! */
  if (isScrollEnded && !this.state.isLoading) {
	onScrollEnded()
  }
})
  • 디바운스: 이벤트 지연, 같은 이벤트 연속 발생시 가장 마지막 것만 수행
  • 쓰로틀: 일정 시간동안 같은 이벤트 연속 발생시 가장 처음 것만 수행 후 나머지 무시

마지막 컨텐츠인지 판단

지금까지 불러온 개수전체 컨텐츠의 개수를 비교하기
-> photos.length < totalCount를 통해서 가능
=> 마지막을 확인하지 않으면 끊임없이 API를 호출하게 됨

// App.js
this.state = {
  ...
  totalCount: 0, // 전체 개수
  isLoading: false, 
}

const photoListComponent = new PhotoList({
  $target,
  initialState: {
    ...
    totalCount: this.state.totalCount // 전체 개수
  },
})

this.setState = (nextState) => {
  this.state = nextState
  
  photoListComponent.setState({
    ...
    totalCount: this.state.totalCount // 전체 개수
  })
}

  ...
  
const initialize = async () => {
  const totalCount = await request ('/cat-photos/count') // 전체 개수 받아오기
  
  this.setState({
	...this.state,
    totalCount
  })
  
await fetchPhotos()
}

initialize()
  
  
// PhotoList.js
  ...
  
  this.render()
}

window.addEventListener ('scroll', () => {
  const { isLoading, totalCount, photos } = this.state
  const isScrollEnded = (window.innerHeight + window.scrollY + 100) >= document.body.offsetHeight
  
  /* photos.length < totalCount로 개수 비교 */
  if (isScrollEnded && !isLoading && photos.length < totalCount) { 
	onScrollEnded()
  }
})


Intersection Observer 방식

  • 지원하지 않는 브라우저도 존재 (보통 구식 브라우저)
    -> Can I Use에서 해당 기능 지원하는지 확인 가능
  • 무한 스크롤, 이미지 지연 로딩 등에도 사용됨
  • 높이 계산 방식보다 직관적이고, 매번 이벤트를 발생시키지 않아 성능상의 이점도 존재
// PhotoList.js
export default function PhotoList({ $target, initialState, onScrollEnded }) {
  
  ...
  
  this.state = initialState

  /* 첫 번째 파라미터: 콜백, 지켜볼 대상 (여러 개 가능)
   * 두 번째 파라미터: 옵션 (선택 사항)
   * -> root: 뷰 포트(감시할 요소)를 제한시킬 때 사용, null 쓰면 최상위(body || document)
   * -> threshold: 드러난 면적 정도 (0: 아주 조금이라도 보이면 실행 ~ 1: 완전 다 보여야 실행)
   */
  const observer = new IntersectionObserver (entries => {
	entries.forEach (entry => { // 콜백들은 forEach로 순회
	  if (entry.isIntersecting && this.state.isLoading) { // isIntersecting으로 화면 끝인지 감시, 로딩중일 때는 감지 안 함
        if (this.state.totalCount > this.state.photos.length) { // 컨텐츠 끝나면 더 안 불러오도록 방어
          onScrollEnded() // 감시하는 대상이 보였을 때 (화면 끝에 들어왔을 떄) 실행됨
        }
      }
	})
  }, {
    threshold: 0.5 // 이미지가 반 이상 들어오면 콜백 실행
  })

  let $lastLi = null

  this.setState = nextState => {
	
    ...

    photos.forEach (photo => {
      ...
        $li.style = 'list-style: none; min-height: 800px;'
        /* 이미지가 렌더링 되기 전에 작은 li가 전부 붙어서 내려오면 스크롤이 끝났다고 인식할 수도 있기 때문에 li 크기를 크게 잡음 (이미지 크기를 고려해서 정하면 됨)
         * API에서 이미지 크기 내려주거나
         * 이미지 홀더를 넣어서 이미지가 로딩되기 전에 크기 잡고있어도 됨 (바꿔치기)
         */
        $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) // 계속 observer가 마지막 요소를 감시하게 하는중
    }
  }
  
  this.render()
}


😊오늘의 느낀점😊

무한 스크롤과 구현 방식에 대해 배웠다. Intersection Observer를 이용하는 거라고만 대충 들었는데, 그 전에 높이 계산으로 구현하는 방식이 존재했다는 건 몰랐다.
생각해보면 높이 계산을 통해 무한 스크롤을 구현할 수 있는 게 당연하긴 하니까... ㅎㅎ

옵저버를 이용하면 무한 스크롤 뿐만 아니라 이미지 지연 로딩에도 쓸 수 있다고 하니까, 다음에 이미지 지연 로딩을 구현하게 된다면 사용해보고싶다.

요즘은 모바일을 많이 사용하는 시대니까 무한 스크롤은 꼭 알아두어야하는 방식인 것 같다 !

profile
데브코스 프론트엔드 5기

0개의 댓글