* 본 글은 2022.06.21에 작성한 글을 업데이트한 글입니다.
목록을 표시하는 방법에는 여러가지 방법이 있습니다만 모바일 환경에서 주로 구현되는 방법은 무한 스크롤입니다.
오늘은 이 무한스크롤을 구현하고 독립적인 컴포넌트 형태로 꺼내 쓸 수 있도록 만들어보려합니다.
- 무한스크롤 구현
무한 스크롤을 만들 때 생각해야할 부분은 다음과 같습니다.
1. 사용자가 목록의 끝에 도달했다는 것을 어떻게 인지할 것인가?
2. 목록의 갱신은 어떻게 할 것 인가?
2번의 경우 우리는 자바스크립트의 비구조화 할당(... 문법)과 State를 통하여 쉽게 갱신할 수 있습니다.
갱신된 목록 역시 map 함수를 통하여 손쉽게 렌더링 할 수 있습니다.
그렇다면 문제가 되는 부분은 바로 1번, 사용자가 목록의 끝에 도달했다는 것을 어떻게 인지할 것인가? 입니다.
이를 자바스크립트의 Intersection Observer를 이용하여 해결해보도록 하겠습니다.
Intersection Observer
Intersection Observer란 말 그대로 교차 여부(Intersection)를 관찰(Observer)하는 API입니다.
관찰 대상 타겟에 대하여 이 타겟이 상위 혹은 최상위 요소의 Viewport에 교차되었는지 교차되지 않았는지를
비동기적으로 관찰할 수 있습니다.
물론 Intersection Observer를 통해서 다양한 것을 할 수도 있겠지만, 무한 스크롤이 목적이므로 간소화하여 사용해봅시다.
Intersection Observer는 별도의 설치가 필요하지 않은 기본 제공 API이므로 다음과 같이 생성할 수 있습니다.
let observer = new IntersectionObserver(callback, { threshold = 0.4 });
매개변수로 callback과 {threshold : 0.4}라는 객체가 들어가는 것을 확인할 수 있습니다.
callback 함수는 타겟이 Viewport에 교차 시 실행될 함수를 나타냅니다.
{ threshold : 0.4 } 객체는 Intersection Observer의 options 객체입니다.
여기서는 옵션 중 threshold만 설정하였는데,
threshold는 타겟이 Viewport에 어느 정도 교차할 경우에 callback 함수를 실행할지를 정하는 옵션입니다.
여기서는 0.4로 설정하였으므로 타겟의 40%가 Viewport에 교차할 경우 callback 함수를 실행합니다.
이외에도 타겟이 교차될 Viewport를 정하는 옵션인 root, root의 margin을 설정할 수 있는 rootMargin이 있습니다.
다른 옵션에 대한 정보는 MDN - Intersection Observer 문서를 참고하시기 바랍니다.
const callback = (entries, observer) => {
if (entries[0].isIntersecting) {
//교차할 경우
} else {
//교차하지 않을 경우
}
};
다음으로는 타겟이 교차 시 실행될 callback 함수입니다.
callback 함수는 위와 같은 구조를 가지는데,
entries는 감시할 타겟이 들어있는 배열이고, observer는 타겟을 감시하는 observer 객체를 말합니다.
각 개별 타겟에 isIntersecting 속성을 통해 각 타겟이 Viewport에 교차 중인지 아닌지를 확인할 수 있습니다.
간단한 예제를 통해 확인해보겠습니다.
아래 예제는 target이 화면에 교차(intersect) 시 target의 상하 div의 배경색을 초록색으로 바꾸는 예제입니다.
See the Pen Untitled by Janghun Lee (@bh2980) on CodePen.
const observer = new IntersectionObserver(callback, {threshold: 0.4});
const target = document.querySelector('.target');
if(target) observer.observe(target); //observer는document 내에 target이 있다면, target을 observe
observer는 document 내에 target이 있다면, target을 감시(observe)합니다.
const callback = (entries, observer) => {
if(entries[0].isIntersecting){ // target이 교차하면
const unblocks = document.querySelectorAll('.block'); // Target 상하의 div를 가져온다
unblocks.forEach((unblock) => {
unblock.classList.remove('block'); // 배경 빨간색 CSS 제거
unblock.classList.add('unblock'); // 배경 초록색 CSS 추가
});
}
else{ // target이 교차하지 않으면
const unblocks = document.querySelectorAll('.unblock'); // Target 상하의 div를 가져온다
unblocks.forEach((unblock) => {
unblock.classList.remove('unblock'); // 배경 초록색 CSS 제거
unblock.classList.add('block'); // 배경 빨간색 CSS 추가
});
}
}
observer는 생성 시 감시 대상이 intersect할 경우 실행 할 callback 함수를 받습니다.
이 callback 함수는 observe 함수를 통해 받은 감시 대상들을 entries 라는 배열 매개변수로 받습니다.
이 경우는 observe할 감시 대상이 target 하나 뿐이므로 0번째 성분을 불러와 intersecting 여부를 검사합니다.
이를 if문으로 구분해 내부의 코드를 실행합니다.
그렇다면, Intersection Observer를 통해 어떻게 무한 스크롤을 구현할 수 있을까요?
우리가 해결하지 못했던 부분인 사용자가 목록의 끝에 도달했다는 것을 어떻게 인지할 것인가?를
Intersection Observer를 통해 해결해봅시다.
만약 리스트의 끝에 target 컴포넌트를 놔두고, 이 target 컴포넌트가 화면에 교차되었다면
사용자가 리스트의 끝에 도달했다고 생각할 수 있지 않을까요?
그렇다면, 리스트의 끝에 항상 target을 배치해둔다면, 이를 intersection Observer를 통해 감시하여
무한 스크롤을 구현할 수 있을 것입니다.
이제 Intersection Observer를 무한스크롤을 구현하기 위한 모든 문제를 해결하였으니,
이를 바탕으로 무한스크롤을 구현해보겠습니다.
기본적인 틀은 위에서 설명한 Intersection Observer와 같습니다.
상태 | 설명 |
loading | 로딩 여부 |
target | 리스트의 끝에 위치하는 타켓 |
itemList | 표시할 아이템 목록 |
함수 | 설명 |
addItemList | 아이템 추가 함수 |
myCallback | 교차 시 실행되는 콜백 함수 |
useEffect | observer에게 target 감시 명령 |
리액트는 가상DOM을 만들어 브라우저에 그리기 때문에 실행한 후 target이 실제로 생성되기까지 시간이 걸립니다.
이 사이 target이 없을 경우 오동작을 방지하고자 상태 target과 useEffect가 사용되었습니다.
target Element는 ref 속성을 이용해 가져왔습니다.
실행 과정은 다음과 같습니다.
1. observer가 target 감시
2. target이 Viewport에 교차
3. loading => true / target 미표시, loading... 표시
4. 0.5초의 가상 로딩
5. addItemList 함수로 목록 추가
6. loading => false / target 재표시, loading... 미표시
위 예제에서는 target을 명확히 보여주기 위해 2번과 3번 사이에 0.5초의 지연이 포함되어 있습니다.
이렇게 Intersection Observer를 통해 React에서 무한 스크롤을 구현해볼 수 있었습니다.
- 독립적인 컴포넌트로 만들기
무한 스크롤은 잘 구현되었지만 살짝 아쉬운 점이 남습니다.
바로 아이템 목록과 무한 스크롤 코드가 같이 묶여서 작성되어있다는 점입니다.
위 코드에서 무한 스크롤만을 분리한다면 어떤 아이템 목록이 들어가도 무한 스크롤로 동작하게 만드는,
즉 아이템 목록과 상관없이 동작하는 무한 스크롤 컴포넌트를 만들 수 있지 않을까요?
이를 위해서는 React의 children props를 이용할 수 있습니다.
React의 children props
모든 React의 컴포넌트는 암시적으로 children이라는 prop 속성을 가지고 있습니다.
이 속성에는 컴포넌트 태그 내부에 작성된 모든 자식 컴포넌트가 들어가게 됩니다.
// Parent.jsx
const Parent = ({ children }) => {
return (
<div>
<div>Parent Top</div>
{children}
<div>Parent Bottom</div>
</div>
);
};
export default Parent;
// Children.jsx
const Children = () => {
return <div>children</div>;
};
export default Children;
위의 예제는 Parent 컴포넌트 내부에 작성된 Children 컴포넌트를
Parent의 children prop을 통해 Parent에서 로딩하는 모습입니다.
Children 컴포넌트가 Parent Top, Parent Bottom 사이에서 렌더링 되는 모습을 확인할 수 있습니다.
이처럼 children props을 사용하면, Parent 컴포넌트에서 자식 컴포넌트를 받아 렌더링할 수 있습니다.
이를 무한 스크롤에 적용한다면, 아이템 리스트를 children prop으로 받는 무한 스크롤을 구현할 수 있을 것입니다.
아래는 이를 통해 무한 스크롤 컴포넌트를 구현한 결과입니다.
// App.js
<InfiniteScroll addItem={loadNewItem}>
{itemList.map((_, idx) => (
<Block key={idx} text={idx} />
))}
</InfiniteScroll>
App.js에서는 itemList와 loadNewItem 함수를 선언하여,
InfiniteScroll 컴포넌트 내부에 자식 컴포넌트로 내부 요소를 렌더링합니다.
const InfiniteScroll = ({ children, addItem }) => {
const [target, setTarget] = useState(null);
const [loading, setLoading] = useState(false);
...
const callback = async ([entry], observer) => {
if (entry.isIntersecting) {
setLoading(true);
await addItem();
setLoading(false);
}
};
const observer = new IntersectionObserver(callback, { threshold: 0.4 });
useEffect(() => {
if (!target) return;
observer.observe(target);
return () => observer && observer.disconnect();
}, [target]);
return (
<>
{children}
{loading && <Spinner />}
{!loading && <div style={targetStyle} ref={setTarget}></div>}
</>
);
};
export default InfiniteScroll;
InfiniteScroll 컴포넌트는 자식 컴포넌트를 children 컴포넌트로 받아서,
이를 로딩 스피너와 target보다 위에 렌더링합니다.
observer의 callback 함수는 아이템을 추가하는 addItem 함수의 실행 전에 target을 지우고 spinner를 표시한 다음,
addItem 함수가 동작이 완료되길 기다립니다. (loadNewItem 함수)
addItem 함수의 동작이 끝나면, spinner를 지우고 다시 target을 표시합니다.
이를 통해 아이템 목록과 별도로 동작하는 무한스크롤 컴포넌트를 만들 수 있었습니다.
GitHub
Reference
'Frontend > React' 카테고리의 다른 글
Tailwind에서 재사용 가능한 컴포넌트로(with cva, tailwind-merge) (0) | 2023.08.05 |
---|---|
태그에 따라 바뀌는 React 컴포넌트 만들기(with TypeScript) (0) | 2023.08.03 |
프론트엔드에서의 TDD (0) | 2023.04.08 |
[React] useAxios (0) | 2022.02.03 |
[React] Hook (0) | 2022.01.11 |