Intersection Observer API를 사용한 무한스크롤 구현 과정을 기록했습니다.
무한스크롤이란 사용자가 특정 페이지 하단에 도달했을 때, API가 호출되며 콘텐츠가 계속 로드되는 UX를 말합니다. 페이지를 클릭하면 다음 페이지 주소로 이동하는 페이지네이션과 달리 스크롤만으로 새로운 컨텐츠를 볼 수 있게 되죠.
페이지네이션은 사용자에게 통제감을 제공하고 특정 항목의 위치를 파악할 수 있죠. 하지만 특정 컨텐츠를 찾는 것이 아닌 많은 컨텐츠를 탐색한 뒤 원하는 항목을 발견하는 걸 원할 경우엔 무한스크롤이 유용할 것 입니다.
무한스크롤을 구현하는 데 간단한 방법으론 스크롤이벤트를 이용하여 구현해볼수도 있겠습니다. 저의 경우엔 Intersection Observer API를 사용하여 구현해보겠습니다.
스크롤이벤트 구현시 주의점
스크롤이벤트로 구현할때 주의할 점은 첫 데이터의 사이즈가 충분하지 않다면 스크롤바가 노출되지 않는 점과 스크롤 이벤트 핸들러에 쓰로틀 기법을 사용하여 API 호출빈도를 줄여야 합니다.
또, 쓰로틀이 setTimeout기반으로 동작할 것이기 때문에 콜스택 상태에 따라 원하는대로 동작하지 않을 수 있습니다. 이를 requestAnimationFrame을 사용하여 해결해야 합니다. requestAnimationFrame의 콜백은 태스크 큐보다 우선순위가 높은 Animation Frames에서 처리되어 setTimeout보다 실행시간을 보장할 수 있습니다.
Intersection Observer API는 스크롤이벤트로 무한스크롤을 구현했을 때 생기는 리플로우에 의한 렌더링 성능과 기대한 대로 동작하지 않을 수 있는 문제점을 해결하기 위해 사용하는 API입니다.
기본적으로 브라우저의 Viewport와 Target으로 설정한 요소의 교차점을 관찰하여 Target이 Viewport에 포함되는지 구별하는 기능을 제공합니다.
Intersection Observer API는 파라미터로 콜백과 옵션값을 받습니다.
new IntersectionObserver(callback[, options])
타겟 엘리먼트가 교차되었을 때 실행할 함수로 entries
와 observer
를 파라미터로 받습니다.
entries는 IntersectionObserverEntry 객체의 리스트로 배열 형식으로 반환합니다. 따라서 단일 타켓의 경우와 아닐 경우를 고려해야 합니다.
IntersectionObserverEntry 객체
이 객체의 정보로 어떤 동작을 등록할 때 유용하게 사용할 수 있습니다.
- IntersectionObserverEntry.intersectionRatio: 교차 영역에 타겟 엘리먼트가 얼마나 교차되어 있는지(비율, 0.0~1.0)에 대한 정보를 반환합니다.
- IntersectionObserverEntry.isIntersecting: 타겟 엘리먼트가 교차 영역에 있는 동안 true를 반환하고, 그 외의 경우 false를 반환합니다.
- IntersectionObserverEntry.target: 타겟 엘리먼트를 반환합니다.
- IntersectionObserverEntry.time: 교차가 기록된 시간을 반환합니다.
콜백함수가 호출되는 IntersectionObserver를 나타냅니다.
/**
* @param root target의 가시성을 확인할 때 사용되는 상위 속성 이름- null 입력 시, 기본값으로 브라우저의 Viewport가 설정됨
* @param rootMargin root에 마진값을 주어 범위를 확장 가능- 기본값은 0px 0px 0px 0px이며, 반드시 단위 입력 필요
* @param threshold 콜백이 실행되기 위해 target의 가시성이 얼마나 필요한지 백분율로 표시- 기본값은 배열 [0] 이며, Number 타입의 단일 값으로도 작성 가능
*/
interface IntersectionObserverInit {
root?: Element | Document | null;
rootMargin?: string;
threshold?: number | number[];
}
정리하자면
type IntersectHandler = (entry: IntersectionObserverEntry, observer: IntersectionObserver) => void;
/**
* @param root target의 가시성을 확인할 때 사용되는 상위 속성 이름- null 입력 시, 기본값으로 브라우저의 Viewport가 설정됨
* @param rootMargin root에 마진값을 주어 범위를 확장 가능- 기본값은 0px 0px 0px 0px이며, 반드시 단위 입력 필요
* @param threshold 콜백이 실행되기 위해 target의 가시성이 얼마나 필요한지 백분율로 표시- 기본값은 배열 [0] 이며, Number 타입의 단일 값으로도 작성 가능
*/
interface IntersectionObserverInit {
root?: Element | Document | null;
rootMargin?: string;
threshold?: number | number[];
}
export const useIntersect = (onIntersect: IntersectHandler, options?: IntersectionObserverInit) => {
const ref = useRef<HTMLDivElement>(null);
const callback = useCallback(
(entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
entries.forEach(entry => {
if (entry.isIntersecting) onIntersect(entry, observer);
});
},
[onIntersect]
);
useEffect(() => {
if (!ref.current) return;
const observer = new IntersectionObserver(callback, options);
observer.observe(ref.current);
return () => observer.disconnect();
}, [ref, options, callback]);
return ref;
};
const callback = useCallback(
(entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
entries.forEach(entry => {
if (entry.isIntersecting) onIntersect(entry, observer);
});
},
[onIntersect]
);
const observer = new IntersectionObserver(callback, options);
observer.observe(ref.current);
return () => observer.disconnect();
const ref = useIntersect(async (entry, observer) => {
observer.unobserve(entry.target);
if (!loading) {
fetchData(page, 4);
}
});
...
{loading ? <Loading /> : <Target ref={ref} />}
...
참조
https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
https://tech.kakaoenterprise.com/149
http://blog.hyeyoonjung.com/2019/01/09/intersectionobserver-tutorial/