사용자가 페이지 바닥에 근접했을 때 게시물들을 추가로 로드하는 무한스크롤을 구현할 것이다.
무한스크롤의 장점은,
사용자가 다음 페이지 버튼을 누를 필요가 없어 UX를 향상시키고, 페이지에 오래 머물게 만들어 서비스 참여도를 높일 수 있다.
오늘은 Intersection Observer Api를 활용하여 블로그의 메인페이지에 무한스크롤을 구현해보려고 한다.
이전에는 onScroll
이벤트를 활용하는 방식의 단점은 동기적으로 실행되고 스크롤할 때마다 끊임없이 함수를 호출하기 때문에 메인스레드에 과부하가 걸리게 된다. 그리고 특정 지점을 관찰할 때 getBoundingClient() 함수
를 사용하는 데, 이 함수는 호출할 때마다 요소의 크기와 위치값을 최신 값으로 받아오기 위해reflow 리플로우
현상을 발생시키는 문제점이 있다. (reflow 리플로우
현상이란 브라우저가 문서의 일부 또는 전체를 다시 렌더링하는 것을 말한다.)
그래서 오늘 사용할 것은 Intersection Observer API(교차 관찰자 API)라는 Web API다. 타겟 요소가 기기 뷰포트나 특정 루트 영역에 교차(intersection)할 때마다 비동기로 이벤트를 발생시킨다. 비동기로 처리하기 때문에 메인스레드에 부하를 주지 않고, getBoundingClient() 함수
를 사용하지 않으니 리플로우
도 발생하지 않는다.(구글 크롬 51버전/ 엣지 15버전/ 파이어폭스 55버전에서 지원하고 있다.)
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
이다.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>
);
우와.. 저도 한번 도전해봐야겠어요..