[블로그만들기] Intersection Observer로 무한스크롤 구현하기(typescript)

seo young park·2022년 1월 16일
7

블로그 만들기

목록 보기
2/5
post-thumbnail

✨ 무한스크롤

사용자가 페이지 바닥에 근접했을 때 게시물들을 추가로 로드하는 무한스크롤을 구현할 것이다.
무한스크롤의 장점은,
사용자가 다음 페이지 버튼을 누를 필요가 없어 UX를 향상시키고, 페이지에 오래 머물게 만들어 서비스 참여도를 높일 수 있다.
오늘은 Intersection Observer Api를 활용하여 블로그의 메인페이지에 무한스크롤을 구현해보려고 한다.

  • 기술 스택 : Next js, Typescript, EmotionJs

Intersection Observer

기존 Scroll 이벤트 단점

이전에는 onScroll 이벤트를 활용하는 방식의 단점은 동기적으로 실행되고 스크롤할 때마다 끊임없이 함수를 호출하기 때문에 메인스레드에 과부하가 걸리게 된다. 그리고 특정 지점을 관찰할 때 getBoundingClient() 함수를 사용하는 데, 이 함수는 호출할 때마다 요소의 크기와 위치값을 최신 값으로 받아오기 위해reflow 리플로우 현상을 발생시키는 문제점이 있다. (reflow 리플로우 현상이란 브라우저가 문서의 일부 또는 전체를 다시 렌더링하는 것을 말한다.)

Intersection Observer api

그래서 오늘 사용할 것은 Intersection Observer API(교차 관찰자 API)라는 Web API다. 타겟 요소가 기기 뷰포트나 특정 루트 영역에 교차(intersection)할 때마다 비동기로 이벤트를 발생시킨다. 비동기로 처리하기 때문에 메인스레드에 부하를 주지 않고, getBoundingClient() 함수를 사용하지 않으니 리플로우도 발생하지 않는다.(구글 크롬 51버전/ 엣지 15버전/ 파이어폭스 55버전에서 지원하고 있다.)

options

let options = {
  root: document.querySelector('#scrollArea'),
  rootMargin: '0px',
  threshold: 1.0
}

let observer = new IntersectionObserver(callback, options);

Intersection Observer는 콜백함수와 옵션을 인자로 받는다. 옵션을 통해 콜백함수가 호출되는 상황을 컨트롤 할 수 있다.

  • root : 대상 객체의 가시성을 확인할 때 사용되는 뷰포트 요소다. 대상 객체의 상위 요소여야 한다. 기본값은 브라우저의 뷰포트이며 null이거나 지정하지 않을 때 기본값으로 설정된다.
  • rootmargin : root가 가진 여백으로 css의 margin 속성과 유사하다. root 요소 각 측멱은 수축시키거나 증가시키며 교차성을 계산하기 전에 적용된다. px이나 %로 줄 수 있으며, 기본값은 0이다.
  • threshold : 콜백이 실행될 대상 요소의 가시성 퍼센티지를 나타낸다. 0은 요소가 1픽셀이라도 보이자마자 콜백을 실행하고 1.0은 요소의 모든 픽셀이 화면에 노출되기 전까지 콜백을 실행시키지 않는다. 0부터 1.0까지 있으며 기본값은 0이다.

✨코드

hook

import { useEffect, useState } from 'react';

interface useIntersectionObserverProps {
  root?: null;
  rootMargin?: string;
  threshold?: number;
  onIntersect: IntersectionObserverCallback;
}

const useIntersectionObserver = ({
  root,
  rootMargin,
  threshold,
  onIntersect,
}: useIntersectionObserverProps) => {
  const [target, setTarget] = useState<HTMLElement | null | undefined>(null);
  //감지할 대상 객체는 계속해서 바뀌는데, useRef는 참조값의 변경사항을 알리지 않아 useEffect가 트리거(발생)되지 않는다.
  //callback ref를 사용하거나 setState로 역할을 위임하는 방법이 있고, 이 코드는 후자를 선택했다.
  
  //observer 등록
  //target이라는 상태값이 있으면 IntersectionObserver를 생성하여 observer에 담음
  useEffect(() => {
    if (!target) return;

    const observer: IntersectionObserver = new IntersectionObserver(
      onIntersect,
      { root, rootMargin, threshold }
    );
 	//observer 관찰 시작
    observer.observe(target);

	//observer 관찰 종료
    return () => observer.unobserve(target);
  }, [onIntersect, root, rootMargin, target, threshold]);

  return { setTarget };
  //target을 변경할 수 있도록 setTarget을 넘겨줌
};

export default useIntersectionObserver;

메인페이지 코드

import { PostCard } from './Card';
import { CARD_DATA } from '../../data';
import React, { useState } from 'react';
import useIntersectionObserver from '../../hooks/useIO';

export const TestCardContainer = () => {
  const [isLoaded, setIsLoaded] = useState(false);
  const [itemIndex, setItemIndex] = useState(0);
  const [data, setData] = useState(CARD_DATA.slice(0, 10));


//로딩 테스트를 위해서 가짜 fetch 함수를 넣었다.
  const testFetch = (delay = 1000) =>
    new Promise((res) => setTimeout(res, delay));


//현재 목업 데이터(CARD_DATA)를 사용하고 있기 때문에, 최대한 데이터를 재활용하는 코드를 작성.
//(0~4번 게시물, 1~5번 게시물, 2~6번 게시물 이런 식으로 가져와서 5개씩 concat함수로 붙였다.)
//getMoreItem 함수가 실행되면 isLoaded를 true로 만들어 로딩 컴포넌트를 보여주고,
//함수가 종료될 때 isLoaded를 false로 만들어 로딩컴포넌트를 숨겼다.
  const getMoreItem = async () => {
    setIsLoaded(true);
    await testFetch();
    setItemIndex((i) => i + 1);
    setData(data.concat(CARD_DATA.slice(itemIndex, itemIndex + 5)));
    setIsLoaded(false);
  };

  //intersection 콜백함수
  //entry는 IntersectionObserverEntry 인스턴스의 배열
  //isIntersecting: 대상 객체와 루트 영역의 교차상태를 boolean값으로 나타냄
  //대상 객체가 루트 영역과 교차 상태로 들어갈 때(true), 나갈 때(false) 
  
  const onIntersect: IntersectionObserverCallback = async (
    [entry],
    observer
  ) => {
    //보통 교차여부만 확인하는 것 같다. 코드는 로딩상태까지 확인함.
    if (entry.isIntersecting && !isLoaded) {
      observer.unobserve(entry.target);
      await getMoreItem();
      observer.observe(entry.target);
    }
  };
  
  //현재 대상 및 option을 props로 전달
   const { setTarget } = useIntersectionObserver({
    root: null,
    rootMargin: '0px',
    threshold: 0.5,
    onIntersect,
  });


  return (
    <Container>
      {data.map((e, index) => (
        <PostCard
     		 ....
        />
      ))}
      <div ref={setTarget}>{isLoaded && <Loader>Loading..</Loader>}</div>
    </Container>
  );

📸 기능 구현 화면

🦄 추가 구현 예정

  • 백엔드 연결 시 코드 수정
    교차 이벤트가 발생할 때마다 요청을 보내는 방식으로 코드를 수정할 것 같다.
  • 스켈레톤 로딩창으로 변경예정
    스켈레톤 로딩이란, 데이터가 렌더링 되기 전에 레이아웃의 기본 구조를 사전에 보여주는 방식이다.
    이미 프로젝트 내 다른 페이지에서 스켈레톤 방식으로 로딩을 보여주기 때문에, 메인페이지에서도 같은 방식으로 로딩을 보여주려고 한다.
  • 메모리에 페이지 데이터 저장
    무한스크롤의 문제점 중 하나가 다른 페이지로 나갔다가 돌아오면 스크롤위치가 초기화되어서 다시 스크롤해야한다는 것이다. 메모리에 페이지 데이터를 넣어두면, 마지막 위치로 돌아올 수 있다고 해서 한 번 검토해볼 예정. 링크

🔗 참고링크

링크1
링크2
링크3
링크4
링크5

2개의 댓글

comment-user-thumbnail
2022년 1월 17일

우와.. 저도 한번 도전해봐야겠어요..

답글 달기
comment-user-thumbnail
2022년 7월 31일

옵저버를 구현하려하는데 타입스크립트를 처음 사용해보게 되어서 헤매는 도중에 많은 도움 받아갑니다!

답글 달기