IntersectionObserver로 구현하는 React 무한 스크롤
오늘은 React에서 IntersectionObserver를 통해 무한 스크롤을 구현해보려 합니다. 요즘 SPA에서는 보통 페이징을 무한 스크롤을 통해 구현하는 경우가 많은데, 무한 스크롤을 구현하는 방법으로는 여러가지가 있습니다. 이번에는 자바스크립트의 Intersection Observer API를 사용해서 구현해보겠습니다.
Intersection Observer가 뭔데요?
IntersectionObserver API는 element가 viewport, 다른 엘리먼트와의 관계에서 보이는지 안보이는지를 알 수 있도록 하는 API입니다. 많이 사용되는 경우는 지금 제가 구현하려고 하는 무한 스크롤이나, 이미지의 lazyload 등이 있겠습니다. 현재 IE를 제외한 대부분 브라우저의 최신 버전에서는 지원하고 있고, 디테일한 버전은 caniuse 에서 확인 하실 수 있습니다.
Intersection Observer를 어떻게 활용하나요?
IntersectionObserver 객체의 경우 callback, options를 매개변수로 받습니다. options 객체의 경우 root, rootMargin, threshold 값을 받습니다.
callback
callback 함수는 관측 대상이 threshold 만큼 보일 때, 호출되는 함수입니다. 관측 대상으로 지정한 DOM Element 객체가 entires, observer 자기 자신인 observer가 매개 변수로 전달됩니다.
root
root의 값은 element, null 중 하나입니다. 관측 대상을 감싸는 element가 들어가거나, null로 지정할 경우 viewport가 들어갑니다. 문서 내에 따로 스크롤이 가능한 요소가 있고, 관측 대상이 해당 요소 안에 있다면 root에는 스크롤 가능한 요소가 들어가게 됩니다.
rootMargin
rootMargin의 경우 위에서 지정한 root 요소를 감싸는 margin 값이 들어갑니다. css margin과 같이 px, % 등의 단위로 작성할 수 있습니다.
threshold
관측 대상이 root와 몇 % 교차했을때, 지정해준 callback 함수를 실행할지 결정하는 값입니다. threshold의 값은 float 값이나, float의 배열이 들어갈 수 있습니다. 만약 마지막 요소가 10% 정도 보일때, 다음 페이지 요소를 로딩하는 callback을 실행한다면, threshold의 값은 0.1이 됩니다.
IntersectionObserver의 구성 요소를 알아봤으니, 실제로 구현해보도록 하겠습니다.
무한 스크롤 구현
우선 제가 구현하고 있는 페이지는 아래 코드와 같이 작성되어 있습니다.
import React, { useState } from "react";
import { useQuery } from "react-apollo-hooks";
import {
Page,
Header,
Content,
Search,
GridList,
Card
} from "../components/Page";
import { getThumbURL } from "../utils";
import GET_VIDEOS from "../Queries/Video";
const Main = () => {
const [page, setPage] = useState(1);
const { data } = useQuery(GET_VIDEOS, {
variables: {
page
}
});
return (
<Page>
<Header>
<Search placeholder=" 검색어를 입력해주세요" />
</Header>
<Content>
<GridList>
{data !== undefined &&
data.videos.map(video => (
<Card
key={video._id}
thumb={getThumbURL(video.filePath)}
name={video.name}
/>
))}
</GridList>
</Content>
</Page>
);
};
export default Main;
마지막 엘리먼트의 ref를 추가하고, 해당 ref를 통해 IntersectionObserver를 통한 무한 스크롤을 구현해보도록 하겠습니다.
import React, { useState, useRef, useEffect } from "react";
import { useQuery } from "react-apollo-hooks";
import {
Page,
Header,
Content,
Search,
GridList,
Card
} from "../components/Page";
import { getThumbURL } from "../utils";
import GET_VIDEOS from "../Queries/Video";
const Main = () => {
const [page, setPage] = useState(1);
const [videos, setVideos] = useState([]);
const lastCardRef = useRef(null);
const intersectionObserver = new IntersectionObserver((entries, observer) => {
const lastCard = entries[0];
if (lastCard.intersectionRatio > 0) {
observer.unobserve(lastCard.target);
lastCardRef.current = null;
setTimeout(() => {
setPage(page + 1);
}, 100);
}
});
const query = useQuery(GET_VIDEOS, {
variables: {
page
}
});
useEffect(() => {
if (lastCardRef.current) {
intersectionObserver.observe(lastCardRef.current);
}
});
useEffect(() => {
if (!query.loading) {
setVideos(videoList => [...videoList, ...query.data.videos]);
}
}, [query]);
return (
<Page>
<Header>
<Search placeholder=" 검색어를 입력해주세요" />
</Header>
<Content>
<GridList>
{videos.length &&
videos.map((video, idx) =>
idx !== videos.length - 1 ? (
<Card
key={video._id}
thumb={getThumbURL(video.filePath)}
name={video.name}
/>
) : (
<Card
key={video._id}
thumb={getThumbURL(video.filePath)}
name={video.name}
ref={lastCardRef}
/>
)
)}
</GridList>
</Content>
</Page>
);
};
export default Main;
저는 이런 식으로 작성했습니다. 우선 lastCardRef에 마지막 카드의 ref를 넣었습니다. 그리고 페이지가 렌더링 될 때마다 useEffect 훅을 이용해, lastCardRef.current의 값을 observe 해주도록 했습니다. 그 다음 intersectionObserver의 콜백 함수에서는 intersectionRatio를 이용해 해당 엘리먼트가 보이는지 체크하고, 보일 경우 lastCardRef의 current를 null로 변경하고, 기존 관측 대상을 unobserve 한뒤 페이지를 1 더해주는 방식으로 처리했습니다. 이렇게 무한 스크롤 기능을 구현해보니, 기존 viewport의 높이를 구하고 스크롤 이벤트를 줘서 무한 스크롤을 구현하는 방식보다 편리했습니다.