원티드 프리온보딩 과제를 진행하던 중 무한 스크롤 기능을 구현해야 했다.
평소에는 React-Query의 useInfiniteQuery
를 이용해 무한 스크롤을 구현하였으나, 이번 과제를 진행하는 동안에는 해당 라이브러리를 사용할 수 없어서 직접 만들 수 밖에 없었다.
App.tsx
// ... prev code
const fetchIssues() => {
// ... fetching data
}
const handleScroll = () => {
const scrollHeight = document.documentElement.scrollHeight;
const scrollTop = document.documentElement.scrollTop;
const clientHeight = document.documentElement.clientHeight;
if (scrollTop + clientHeight >= scrollHeight) {
setPage((prev) => prev + 1);
}
};
}
useEffect(()=>{
fetchIssues();
},[page]);
useEffect(() => {
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, []);
처음에는 윈도우 스크롤 이벤트를 사용하여 무한스크롤을 구현하였으나, 이 방식을 적용하게 되면 불필요하게 많은 호출이 이루어져 브라우저의 성능 저하를 일으켰다.
그래서 이를 제어하기 위해 쓰로틀링 기법을 이용해야 했으나, 혹시 다른 방법은 없을까 하고 찾던 중 Intersection Observer API를 알게 되어 해당 API를 이용한 무한 스크롤을 구현하기로 생각했다.
공식 문서에 따르면 Intersection Observer는 타겟 엘리멘트와 타겟의 부모 혹은 상위 엘리멘트의 뷰표트가 교차되는 부분을 비동기적으로 관찰하는 API라고 한다.
뷰포트
컴퓨터 그래픽스에서, 뷰포트(viewport)는 현재 화면에 보여지고 있는 다각형(보통 직사각형)의 영역입니다. 웹 브라우저에서는 현재 창에서 문서를 볼 수 있는 부분(전체화면이라면 화면 전체)을 말합니다. 뷰포트 바깥의 콘텐츠는 스크롤 하기 전엔 보이지 않습니다.
대상 요소를 관찰하고 관찰된 대상이 화면에 표시되거나, 다른 요소와 교차할 때 반응하는 이벤트 라고 이해하면 될 거 같다.
바로 적용해보기 위해서 다음과 같은 커스텀 훅 코드를 작성하였다.
useObserver.tsx
export function useObserver(callback: () => void) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const target = ref.current;
const handler: IntersectionObserverCallback = (entries) => {
entries.forEach((target) => {
if (target.isIntersecting) {
callback();
}
});
};
const options = {
threshold: 1.0,
};
// 새 인스턴스 생성
const observer = new IntersectionObserver(handler, options);
//대상 관찰 시작
if (target) {
observer.observe(target);
}
//대상 관찰 종료
return () => {
if (target) {
observer.disconnect();
}
};
}, [callback]);
return ref;
이번 과제 뿐만 아니라 추후 다른 프로젝트를 진행할 때 유용하게 사용할 수 있을 거 같아서, 기존 코드에서 분리하여 커스텀 훅으로 작성하였다.
import { useState, useEffect, useCallback, useRef } from "react";
import { useObserver } from "./hooks/useObserver";
import instance from "./apis/axios";
interface Issue {
id: number;
number: number;
title: string;
}
const per_page = 25;
const App = () => {
const [issues, setIssues] = useState<Issue[]>([]);
const [page, setPage] = useState<number>(1);
const [loading, setLoading] = useState(true);
const issuesRef = useRef<Issue[]>([]);
issuesRef.current = issues;
const fetchIssues = useCallback(async () => {
setLoading(true);
try {
const response = await instance.get<Issue[]>(
`https://api.github.com/repos/facebook/react/issues`,
{
params: {
page,
per_page,
},
}
);
const filterIssues = response.data.filter((newIssue) => {
return !issuesRef.current.some(
(exist) => exist.id === newIssue.id
);
});
setIssues((prevIssues) => [...prevIssues, ...filterIssues]);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
}, [page]);
useEffect(() => {
fetchIssues();
}, [fetchIssues, page]);
const loadMore = () => {
if (!loading) {
setPage((prevPage) => prevPage + 1);
}
};
const ref = useObserver(loadMore);
return (
<div>
<h1>Facebook React Issues</h1>
{issues.map((issue) => {
return (
<div key={issue.id}>
<p>
Issue #{issue.number}: {issue.title}
</p>
</div>
);
})}
<div ref={ref} />
{loading && <p>Loading...</p>}
</div>
);
};
export default App;
이제 ref가 설정된 DOM 요소의 가시성이 확보될 때 마다 데이터가 추가로 패칭된다.
개발자로서 성장하는 데 큰 도움이 된 글이었습니다. 감사합니다.