화면을 다 그리고 나서 맨 처음으로 시도했던 것이 Virtual List 였다.
그리고 여기에서 가장 오랫동안 막혀있었다.ㅠㅠ
처음에는 react-virtualized 라이브러리를 사용하려 했다.
그런데 커스텀이 쉽지 않았다.
헤더와 결과의 열 간격을 상호반응하도록 맞추고 싶었는데 그게 되지 않았다.
그리고 demo도 잘 정리되어있지 않고 (당시의 내가 느끼기에)
documentation도 너무 간략하고 불친절하게 느껴졌다ㅠㅠ
라이브러리도 처음 써보는데 그걸 보고 있자니 오히려 혼란스럽기만 해서
아직 Virtual List에 대한 개념도 확실하지 않으니 원리도 이해할 겸
라이브러리 대신 직접 만들어보기로 했다.
Virtual List는 지금 당장 필요한 영역의 데이터만 렌더링하고,
나머지 영역은 공간만 차지하는 빈 영역으로 두는 리스트다.
개념은 이해하기 어렵지 않지만, 이걸 구현하는 원리를 이해하는데에는 한참이 걸렸다.
그도 그럴것이 useRef, translate, scrollTop 등등 처음 보는 것들이 한가득이어서 겁을 먹기도 했다.
그러다 찾게 된 친절한 Normal Coder님의 가상스크롤 시리즈를
3번정도 정독을 하고 나서야 원리를 이해할 수 있었다.
|| Normal Coder님의 가상스크롤 시리즈 ||
가상스크롤(1) < 여기까지만 읽어도 Virtual List의 기본을 알 수 있다.
가상스크롤(2) < 여기서부터는 Infinite Scroll이 추가된다.
가상스크롤(3)
그런데 이것도 따라하다보니 useScroll의 타입오류에서 막혀서
거의 하루종일을 끙끙거리다가
그냥 이해한 개념을 바탕으로 내가 처음부터 다시 만들었다.
👉 ResultList.tsx - VirtualList only
👉 ResultList.tsx - 검색필터 포함
아래부터는 전체 코드 중 내가 이해하기 어려웠던 부분만 잘라서 다시 정리해본다.
velog도 notion 처럼 접었다 펼쳤다 할 수 있는 토글리스트 기능이 있었음 좋겠다ㅠㅠ
처음에는 Normal Coder님의 코드대로 useScroll을 만들었으나
addEventListener의 function 부분에서 계속 타입에러가 났다. scroll event에 대한 타입정의를 뭘로 해야 scrollTop을 사용할 수 있는지 타입에러에서 알려주는 부분을 이렇게도 적용해보고 저렇게도 해봤지만 도대체가 해결이 되지 않았다.
그래서 ResultListBox의 ref를 아예 scrollContainerRef로 받아와서 거기서 scrollTop을 찾으니 해결이 되었다. scrollTop도 손쉽게 이용할 수 있게 되었고, 골칫거리였던 scroll 이벤트의 타입을 정의해줄 필요도 없어졌다.
(생략)
const scrollContainerRef = useRef<HTMLDivElement>(null);
const scrollContainer = scrollContainerRef.current;
const [scrollTop, setScrollTop] = useState<number>(0);
const [containerHeight, setContainerHeight] = useState<number>(
itemHeight * 30
);
const handleScrollHeight = () => {
if (!scrollContainer)
return console.log("no scrollContainer", "handleScrollHeight");
setContainerHeight(scrollContainer.scrollHeight);
setScrollTop(scrollContainer.scrollTop);
};
(후략)
throttle이 왜 필요한지 직접 느껴보기 위해서 처음에는 throttle을 적용하지 않았다. 그랬더니 바로 위 useScroll의 handleScrollHeight함수가 실시간으로 계속 실행되고, 그에 따라 scrollHeight, scrollTop에 의존하는 요소들에도 잦은 빈도로 영향을 미쳤다.
그래서 handleScrollHeight에 225ms의 딜레이마다 한번만 실행되도록 하는 throttle을 걸어주었다.
import { useRef } from "react";
const useThrottle = (fn: (arg?: any) => void, delay = 225) => {
const timer = useRef<NodeJS.Timeout | null>(null);
return (...params: any) => {
if (timer.current) return;
timer.current = setTimeout(() => {
fn(...params);
timer.current = null;
}, delay);
};
};
export default useThrottle;
그 결과 scroll에 따라 수시로 발생하는 수치변화를 일정 빈도로 줄일 수 있었다.
처음에는 overflow: auto를 해주면 scroll이 생기는데 translateY는 왜 해주는건지 당최 이해가 되질 않았다. 이걸 해주지 않으면 렌더링한 일부 영역이 전체 데이터 리스트 박스의 상단에 붙어 스크롤을 하면 위로 올라가게 되고, 그럼 유저가 보는 영역에서 사라진다는 것을 이해하지 못했기 때문이다. 그래서 각 영역의 역할을 아래 이미지와 코드 주석으로 기록해보았다.
return (
<ResultListBox ref={scrollContainerRef} height={scrollViewPortHeight}>
// virtual list가 담길 wrapper이자, 유저에게 보이는 영역.
<TotalItemBox height={totalContainerHeight}>
// 데이터 전체 길이 만큼의 빈 박스.
// 오버되는 부분은 ResultListBox에서 overflow: auto를 통해 scroll을 생성한다.
<VisibleContentsBox offsetY={offsetY}>
// 전체 데이터 중 렌더링한 일부 데이터의 영역
// 보여질 메인 영역과 앞뒤 패딩 영역을 포함하고 있다.
// 아무처리없이 그냥 렌더링 하면 TotalItemBox 영역의 맨 위에 붙기 때문에 유저의 시야에 들어오지 않는다.
// 따라서 offsetY만큼 translateY를 통해 내려준다.
{slicedList.length === 0 ? (
<NoResult>
<div>결과가 없어요</div>
<div>검색조건을 확인해주세요</div>
</NoResult>
) : (
slicedList.map((item) => (
<ResultItem key={item.payID} itemHeight={itemHeight} {...item} />
// 각 행을 그릴 ResultItem 컴포넌트를 따로 만들어주었다.
// 열 너비는 flex-grow로 맞추고, 헤더의 열 너비도 동일하게 하여 반응형 웹에도 대처할 수 있도록 했다.
))
)}
</VisibleContentsBox>
</TotalItemBox>
</ResultListBox>
);
};
👉 ResultList.tsx 전체 보기 (여기에는 검색필터 코드가 포함되어있음)
👉 only VirtualList 코드는 노션에서 확인
처음 라이브러리를 사용하다가 애를 먹었던 이 부분은
flex-grow를 통해 해결했다.
ResultHeader 컴포넌트의 Styled-Components 부분을 살펴보면
각 열의 너비는 지정해줬지만 flex-grow: 1
을 주어서 화면의 크기가 넓어져도 간격이 유동적이고 일정 비율로 유지된다. ResultItem도 이와 유사하게 해주어 헤더와 리스트 아이템의 열 간격을 맞춰 주었다.
(앞부분 생략)
export default ResultHeader;
//styled-components
const ResultHeaderBox = styled.div`
display: flex;
align-items: center;
padding: 0 0.5rem 1rem 0;
border-bottom: 0.07rem solid var(--borderGray);
> div {
flex-grow: 1;
text-align: center;
font-weight: 500;
font-size: 1.2rem;
line-height: 1.7rem;
:first-child {
width: 6.5rem;
}
:nth-of-type(2) {
width: 7rem;
}
:nth-of-type(3) {
width: 8rem;
}
:nth-of-type(4) {
width: 4.5rem;
}
:last-child {
width: 5rem;
padding: 0 0.5rem;
}
}
`;
👉 ResultHeader.tsx 전체 보기
👉 ResultItem.tsx 전체 보기
혹시 현명한 선배님들이 계시다면 이 질문을 드리고 싶습니다..ㅠㅠ
다음 포스팅에는 Express 서버 구현 기록과
Pagination, Infinite Scroll을 넣었다가 다시 뺀 이유에 대해 쓸 예정이다.