목록 가상화 또는 "윈도잉"
무한 스크롤처럼 동적으로 node가 늘어나는 UI를 사용할때 node가 100개 1000개 늘어나면 어떻게 될까요
많은 행이 포함된 큰 테이블이나 리스트를 표시해야 하는 경우가 있습니다. 이러한 리스트의 모든 단일 항목을 로드하면 성능에 상당한 영향을 미칠 수 있습니다.
목록 가상화 또는 "윈도잉"은 사용자에게 보이는 것만 렌더링하는 개념입니다. 먼저 렌더링되는 요소의 수는 전체 목록의 아주 작은 부분 집합이며 사용자가 스크롤을 계속하는 경우 보이는 콘텐츠의 "window"이 움직입니다. 이렇게 하면 리스트의 렌더링 및 스크롤 성능이 모두 향상됩니다.
react-window
react-window는 애플리케이션에서 가상화된 목록을 쉽게 생성할 수 있게 해주는 작은 서드 파티 라이브러리입니다. 다양한 유형의 목록 및 테이블에 사용할 수 있는 여러 기본 API를 제공합니다.
고정 크기 리스트를 사용하는 경우
동일한 크기의 항목으로 구성된 긴 1차원 리스트가 있는 경우 FixedSizeList 구성 요소를 사용하세요.
import React from 'react';
import { FixedSizeList } from 'react-window';
const items = [...] // 일부 항목 리스트
const Row = ({ index, style }) => (
<div style={style}>
{/* define the row component using items[index] */}
</div>
);
const ListComponent = () => (
<FixedSizeList
height={500}
width={500}
itemSize={120}
itemCount={items.length}
>
{Row}
</FixedSizeList>
);
export default ListComponent;
- FixedSizeList 구성 요소는 height , width 및 itemSize 속성을 사용하여 리스트 내의 항목 크기를 제어합니다.
- 행을 렌더링하는 함수는 자식으로 FixedSizeList에 전달됩니다. 특정 항목에 대한 세부 정보는 index 인수(items[index])를 사용하여 액세스할 수 있습니다.
- style 매개변수는 행 요소에 첨부되어야 하는 행 렌더링 메서드에도 전달됩니다. 리스트 항목은 인라인으로 할당된 높이 및 너비 값으로 절대적으로 배치되며 style 매개변수가 이를 담당합니다.
구현해보기
https://github.com/dosanHoon/toy-project/tree/main/self-react-window
1. intersection observer
귀찮아서 어떻게든 쉽게 만들어보려고 intersection observer 를 이용해보려고 했으나 불가능하다는 결론입니다. 특정영역에서 벗어나는 dom을 아예 렌더링하지 않아야 하는데 없는 dom에서 intersection observer 는 감지가 안되니 당연한 결과이겠죠.
2. scroll 기반
뭔가 머리써야 되는게 지저분해 보이지만 node들이 보이는 영역과 node의 높이 스크롤 위치 등을 가지고 계산해서 보여주는 방식입니다.
function getIndex(
scrollTop: number,
listHeight: number,
length: number,
viewSize: number,
itemHeight: number
) {
const startIndex = Math.floor((scrollTop / listHeight) * length);
return [startIndex, startIndex + 1 + viewSize / itemHeight];
}
- scrollTop : 스크롤위치
- listHeight : node 높이 * 총 개수 = 실제 node가 모두 있을때 높이
- length : node 총 개수
- viewSize : node가 실제로 보이는 영역의 높이
- ltemHeight : node 높이
스크롤과 전체 높이의 비율을 가지고 몇번째 node부터 보여주는지 구했습니다.
endIndex는 viewSize와 itemHeight를 가지고 몇개 보이는지 갯수를 구해서 더했습니다.
useEffect(() => {
if (listRef.current) {
listRef.current.style.height = itemHeight * items.length + "px";
}
}, []);
- node 높이 * 총 개수 = 실제 node가 모두 있을때 높이로 height를 고정해서 scroll이 자연스럽게 생기게 했습니다.
useEffect(() => {
const viewPort = viewPortRef.current;
if (viewPort) {
const onScroll = () => {
requestAnimationFrame(() => setIndex(viewPort?.scrollTop || 0));
};
viewPort.addEventListener("scroll", onScroll);
return () => {
viewPort.removeEventListener("scroll", onScroll);
};
}
}, []);
scroll시 startIndex를 구해서 state에 저장하는 함수를 호출합니다.
return (
<Container ref={viewPortRef} width={width} height={height}>
<StyledList ref={listRef}>
{items.slice(startIndex, endIndex).map((item, index) => (
<StyledItem
key={item}
style={{ transform: `translateY(${startIndex * itemHeight}px)` }}
>
{cloneElement(children, {
style: { height: itemHeight },
children: item,
})}
</StyledItem>
))}
</StyledList>
</Container>
);
items.slice(startIndex, endIndex)
설정한 startIndex와 endIndex에 해당하는 node만 가져옵니다.
style={{ transform: `translateY(${startIndex * itemHeight}px)` }}
스크롤은 이동하지만 실제로는 해당위치에 node가 없기 때문에 top 위치값을 지정해줘야됩니다.
{cloneElement(children, {
style: { height: itemHeight },
children: item,
})}
react-window와 달리 row를 children형태로 받습니다.
<div className="App">
<ViewPortList items={items} height={600} itemHeight={100}>
<ListItem>text</ListItem>
</ViewPortList>
</div>
사용할때는 이렇게 item높이와 보여지는 영역의 높이를 props로 받을수 있고 리스트 Node의 컴포넌트를 chilren으로 받을수 있습니다.
구현 전체 코드
import { FC, cloneElement, useEffect, useRef, useState } from "react";
import { styled } from "styled-components";
interface ListProps {
items: string[];
width?: number;
height?: number;
itemHeight?: number;
children: React.ReactElement;
}
function getIndex(
scrollTop: number,
listHeight: number,
length: number,
viewSize: number,
itemHeight: number
) {
const startIndex = Math.floor((scrollTop / listHeight) * length);
return [startIndex, startIndex + 1 + viewSize / itemHeight];
}
export const ViewPortList: FC<ListProps> = ({
items,
width,
height,
itemHeight = 200,
children,
}) => {
const listRef = useRef<HTMLUListElement>(null);
const viewPortRef = useRef<HTMLDivElement>(null);
const [startIndex, setStartIndex] = useState(0);
const [endIndex, setEndIndex] = useState(10);
useEffect(() => {
if (listRef.current) {
listRef.current.style.height = itemHeight * items.length + "px";
}
}, []);
const setIndex = (scrollTop: number) => {
if (listRef.current) {
const listHeight = listRef.current.clientHeight;
const [startIndex, endIndex] = getIndex(
scrollTop,
listHeight,
items.length,
viewPortRef.current?.clientHeight || 0,
itemHeight || 0
);
setStartIndex(() => (startIndex > 2 ? startIndex - 2 : startIndex));
setEndIndex(() => endIndex + 1);
}
};
useEffect(() => {
const viewPort = viewPortRef.current;
if (viewPort) {
const onScroll = () => {
requestAnimationFrame(() => setIndex(viewPort?.scrollTop || 0));
};
viewPort.addEventListener("scroll", onScroll);
return () => {
viewPort.removeEventListener("scroll", onScroll);
};
}
}, []);
return (
<Container ref={viewPortRef} width={width} height={height}>
<StyledList ref={listRef}>
{items.slice(startIndex, endIndex).map((item, index) => (
<StyledItem
key={item}
style={{ transform: `translateY(${startIndex * itemHeight}px)` }}
>
{cloneElement(children, {
style: { height: itemHeight },
children: item,
})}
</StyledItem>
))}
</StyledList>
</Container>
);
};
const Container = styled.div<{ width?: number; height?: number }>`
height: ${({ height }) => (height ? `${height}px` : "100vh")};
width: ${({ width }) => (width ? `${width}px` : "100%")};
overflow-y: scroll;
background-color: black;
}`;
const StyledList = styled.ul`
position: relative;
margin: 0;
width: 100%;
`;
const StyledItem = styled.li`
position: relative;
`;
참고
https://web.dev/i18n/ko/virtualize-long-lists-react-window/
https://github.com/bvaughn/react-window
https://velog.io/@dahyeon405/Windowing-%EC%A7%81%EC%A0%91-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0
p.s 조금 더 최적화가 필요한데 누가 PR좀...
'개발관련 > 리액트' 카테고리의 다른 글
React는 virtual-dom인가? (0) | 2023.08.27 |
---|---|
React는 rendering 중에 계산하는 것이 더 빠르다. (0) | 2023.08.20 |
[번역] 리액트 기술을 한 단계 업그레이드하세요: 2023년에 마스터할 고급 패턴 5가지 (0) | 2023.08.07 |
리액트 컴포넌트 무한 스크롤 구현 (0) | 2023.05.11 |
CRA test 할때 axios import outside 에러 관련 (0) | 2023.05.01 |