현재 진행하고 있는 프로젝트에 react-window 라이브러리를 통해 windowing 기법을 적용해본 경험이 있다(적용기 링크). Windowing 기법을 적용할 수 있는 라이브러리에는 react-virtualized
도 있는데, 이에 비해 react-window
를 선택한 이유는 패키지 사이즈가 훨씬 작고 많은 추가 기능이 필요하지 않았기 때문이다.
하지만 사용하면서 다음과 같은 불편한 점들을 느꼈다.
무조건 다음과 같은 문법으로 사용해야 한다.
import { FixedSizeList as List } from 'react-window';
const Row = ({ index, style }) => (
<div style={style}>Row {index}</div>
);
const Example = () => (
<List
height={150}
itemCount={1000}
itemSize={35}
itemData={{ articles, keywords }}
width={300}
>
{Row}
</List>
);
<List>
컴포넌트는 아이템들을 감싸고 있는 컨테이너라고 생각하면 되고, Row
는 각 아이템 요소를 의미한다.
itemCount
를 지정하면 개수만큼의 Row
컴포넌트를 생성하고, 각 Row
컴포넌트를 생성할 때 index
를 넘겨준다.List
컴포넌트의 itemData
props로 넘겨준다. (아래에 전체 코드가 있으니 참고)여기서, {Row}
의 위/아래에 다른 요소를 넣어줄 수 없다.
기존 코드에서 무한스크롤의 타겟 요소가 아이템들의 가장 하단에 위치했는데, 위 문법을 따를 경우 타겟 요소를 따로 넣어주기가 어려웠다.
→ react-window-infinite-loader라는 라이브러리를 함께 사용해서 해결할 수 있다.
리스트들이 들어있는 컨테이너의 높이(height)를 지정해줘야 한다.
1번과도 얽혀있는 불편한 점이긴 한데, 기존에는 리스트들이 들어있는 컨테이너의 높이를 따로 지정해주지 않았다. 따라서 검색 결과의 높이 합이 페이지 높이를 초과할 경우 아래와 같이 전체 스크롤이 생기게 되었다.
하지만 해당 라이브러리를 사용할 경우 windowing이 적용되는 요소(리스트들의 컨테이너)의 높이를 지정해줘야 하고, 높이를 초과할 경우 아래와 같이 내부 스크롤이 생긴다.
이러한 불편한 점들을 react-virtualized-auto-sizer와, react-window-infinite-loader를 통해 해결해서 프로젝트에 적용은 해보았으나, 내부 스크롤이 생기는 방식으로밖에 구현을 할 수 없다는 것은 극복하기가 어려웠다.
왜 이렇게 구현되어 있을까? 생각해보니, 보여줄 아이템을 고르려면 사용자가 어디까지 스크롤했는지 알아야하는데, FixedSizeList
자체의 스크롤 이벤트만 감지하도록 설계되어 있는 것 같았다.
직접 구현하려면 DOM을 조작해야한다는 생각에 당시에는 시도해보지 않았는데, 리액트를 깊게 공부하면서 DOM을 조작할 필요가 없구나 깨달았다. 그렇다면 직접 구현해볼 수 있지 않을까?하는 생각이 들어서 스크롤 이벤트의 타겟을 직접 설정해서 넣어줄 수 있도록(window
도 가능하게!!) 구현해보고자 하였다.
리액트를 사용하면서 한 번쯤은 다음과 같은 오류를 마주한 적이 있을 것이다.
이는 아래 코드처럼 자바스크립트의 map()
함수를 사용하여 배열 안에 있는 데이터를 가지고 컴포넌트를 렌더링할 때 key
속성을 지정해주지 않으면 생기는 오류이다.
const numbers = [1, 2, 3, 4, 5];
const listItems = numbers.map((number) =>
<li key={number.toString()}>
{number}
</li>
);
보통 key
속성으로 index
를 지정해주면 해결되기는 하던데… 도대체 key
는 왜 필요한 것일까?
✔️ 이 key
는 리액트가 어떤 항목을 변경, 추가 또는 삭제할지 결정할 때 사용되는 중요한 정보이다.
Keys(공식 문서 링크)
리액트는 어떤 컴포넌트가 바뀌었는지 체크하는 재조정(Reconciliation) 과정에서, DOM 노드의 자식들을 재귀적으로 처리할 때 동시에 두 리스트를 순회하고 차이점이 있으면 변경을 생성한다.
<ul>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>
<ul>
<li key="2014">Connecticut</li>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>
위 코드에서 key가 없다면 리액트는 <li>Duke</li>
와 <li>Villanova</li>
가 변경될 필요가 없다는 걸 알지 못하고, 모든 자식을 변경하게 된다.
하지만 key를 지정해주게 되면 리액트는 '2014'
key를 가진 엘리먼트가 새로 추가되었고, '2015'
와 '2016'
key를 가진 엘리먼트는 그저 이동만 하면 되는 것을 알 수 있다.
다음의 배열을 가지고 컴포넌트를 렌더링한다고 해보자.
const [renderItem, setRenderItem] = useState([0, 1, 2, 3]);
...
{renderItem.map((data, index) => {
return <div key={index}>{data></div>
})
}
여기서 만약 renderItem
이라는 상태를 [8, 1, 2, 4]
로 업데이트한다고 했을 때, <div>1</div>
와 <div>2</div>
는 key
값 및 내부 요소가 동일하기 때문에 재랜더링되지 않고 그대로 사용된다.
따라서, 같은 아이템이라면 key
를 동일한 값으로 유지해서 불필요하게 재렌더링되지 않도록 하되, 렌더링할 아이템(renderItem
)을 사용자에게 보이는 요소로 업데이트해주면 된다.
그렇다면 화면에 어떤 요소를 렌더링해줘야 할까? ‘뷰포트 안으로 들어온 요소가 무엇인지’는 어떻게 알 수 있을까?
고민해보다보니 다음과 같은 두 가지 방법이 떠올랐다.
1) IntersectionObserver를 사용하여 뷰포트 내로 들어왔는지 판별하기
2) 아이템 크기가 고정되어 있다면, 사용자의 스크롤 위치를 통해 판별하기
2)의 방법을 사용하면 아이템 크기를 고정시켜줘야하는 반면, 1)의 방법을 사용하면 아이템 크기가 고정되어 있을 필요가 없다.
하지만 1)의 방법은 사용할 수 없다고 결론을 내렸다. 왜냐하면 windowing 기법은 단순히 DOM 요소를 숨기는 것이 아닌, 아예 렌더링하지 않는 기법이기 때문이다. 요소가 렌더링되어있지 않다면 IntersectionObserver도 작동할 수가 없다.
따라서 2)의 방법으로 구현하기로 했다. react-window
또한 동일한 방법으로 구현되어있다.
뷰포트 내로 들어온 가장 첫 번째 요소의 index를 startIndex
라고 하자.
react-window
에서는 스크롤이 List
자체에 생기고, List
의 높이가 고정되어있다.
하지만 내가 구현하고자 하는 windowing에서는 스크롤 타겟 요소를 지정할 수 있기 때문에 보다 많은 요소들에 의해 이 startIndex
가 결정된다.
결론적으로는 다음의 4가지 요소만 알면 startIndex
를 구할 수 있다는 것을 알아냈다.
top
: 스크롤 타겟 요소 내에서 List
의 y축 위치itemHeight
: 아이템의 높이 (고정값)scrollPos
: 현재 스크롤 된 위치window.scrollY
)element.scrollTop
)viewportSize
: 뷰포트의 높이다음과 같이 경우를 나누어 계산할 수 있다.
1) top이 뷰포트 사이즈보다 작은 경우. 요소가 처음에 화면 내에 보이는 경우.
startIndex는 대략 (scroll-top)/itemHeight
를 해서 구할 수 있다.
2) top이 뷰포트 사이즈보다 큰 경우. 요소가 처음에 화면 내에 보이지 않는 경우.
이 경우는 두 가지 경우로 나뉘는데,
startIndex = 0
이다. 그 경계는 scroll == viewportSize + top
일 때이다.scroll
과 viewportSize + top
의 차이를 itemHeight
로 나누어서 구하면 된다.구현된 코드는 다음과 같다.
const calculateStartIndex = ({
top,
itemHeight,
curScrollPos,
viewportHeight,
itemCount,
}: CalculateStartIndex): number => {
if (top < viewportHeight) {
if (curScrollPos < top) return 0;
return Math.min(itemCount - 1, Math.floor((curScrollPos - top) / itemHeight));
} else {
if (curScrollPos < top + viewportHeight) return 0;
return Math.min(itemCount - 1, Math.floor((curScrollPos - (viewportHeight + top)) / itemHeight));
}
};
이는 List
가 현재 화면에 얼만큼 보이든지 상관없이 뷰포트 크기만큼은 보인다고 가정하고 viewportSize/itemHeight
로 계산하기로 결정했다.
스크롤 타겟을 얼마만큼 스크롤했는지는 아래의 커스텀 훅을 통해 구현했다.
HTMLElement
또는 Window
를 스크롤 타겟으로 지정할 수 있다.
import { useEffect, useState } from "react";
export const useScrollDetector = (element: HTMLElement | Window) => {
const [scrollPosition, setScrollPosition] = useState(0);
const [throttle, setThrottle] = useState(false);
useEffect(() => {
const updateScrollPosition = (element: HTMLElement | Window) => {
if (element === window) setScrollPosition(window.scrollY);
else setScrollPosition((element as HTMLElement).scrollTop);
};
const onScroll = () => {
if (throttle) return;
setThrottle(true);
setTimeout(() => {
updateScrollPosition(element);
setThrottle(false);
}, 300);
};
element.addEventListener("scroll", onScroll);
return () => {
if (element) element.removeEventListener("scroll", onScroll);
};
}, [element]);
return scrollPosition;
};
react-window를 사용할 때에는 react-virtualized-auto-sizer의 Autosizer 컴포넌트를 통해서 뷰포트의 높이값을 얻었는데 이 또한 커스텀 훅으로 구현해보기로 했다.
구현된 코드는 아래와 같다. 너무 자주 실행되는 것을 방지하기 위해서 300ms의 쓰로틀링을 적용시켜주었다.
import { useEffect, useState } from "react";
const useViewportHeight = () => {
const [throttle, setThrottle] = useState(false);
const [viewportSize, setViewportSize] = useState(0);
const syncHeight = () => {
if (throttle) return;
setThrottle(true);
setTimeout(() => {
setViewportSize(window.innerHeight);
setThrottle(false);
}, 300);
};
useEffect(() => {
syncHeight();
window.addEventListener("resize", syncHeight);
return () => window.removeEventListener("resize", syncHeight);
}, []);
return viewportSize;
};
export default useViewportHeight;
만약 windowing 기법을 적용해서 스크롤에 따라 렌더링할 요소들을 바꿔주고 있는데, 이에 따라 레이아웃 (사용자가 보는 화면) 또한 변경된다면 매우 비효율적이고 불편할 것이다.
다행히도 지금 상황에서는 아이템의 높이가 고정되어있기 때문에, 리스트 내 아이템의 y축 위치도 리스트의 index에 따라 결정된다.
따라서 아래와 같이 스타일을 설정해주었다.
position: relative
속성 부여position: absolute
적용index*h px
로 계산react-window
를 적용하고 개발자 도구를 켜서 요소들을 살펴보면 아래와 같이 인라인 스타일이 적용되어있다. 비슷한 로직으로 구현되어있음을 알 수 있다.
react-window
에서는 Fixed Size List
, Variable Size List
, Fixed Size Grid
등 다양한 경우에 대해 windowing을 지원하고 있지만 나는 그 중 아이템의 높이가 하나로 고정되어있고, 한 줄에 한 아이템만 렌더링하는 Fixed Size List
만 구현해보기로 하였다.
구현 로직은 다음과 같다.
itemCount
)는 뷰포트 크기에만 의존적이다. 뷰포트 크기가 바뀔 때마다 새로 계산해준다.startIndex
)를 계산해준다. 렌더링할 마지막 요소 인덱스는 itemCount
와 startIndex
로부터 계산한다.startIndex
)와, 종료 요소 인덱스(endIndex
)까지 잘라 이를 렌더링한다.startIndex + index
를 설정하여 같은 아이템인 경우 key가 변경되지 않도록 한다.여기서 몇 개의 아이템을 넉넉히 렌더링할 지 결정할 수 있는 overscanCount
를 설정할 수 있도록 했다. react-window
에도 있는 기능이다.
예를 들어 화면에 첫 번째로 보이기 시작하는 요소의 인덱스가 startIndex
라면, startIndex - overscanCount
번째 인덱스부터 렌더링을 해서 깜빡임이 덜하도록 할 수 있다.
2)번에서 startIndex
를 계산한 후, 이로부터 추가로 itemCount
와 overscanCount
를 인수로 받아서 renderStartIndex
와 renderEndIndex
를 계산하는 함수를 만들었다.
렌더링할 요소의 시작 인덱스, 종료 인덱스를 계산해주는 함수는 다음과 같다.
const calculateRenderIndex = ({ startIndex, renderItemCount, itemCount, overscanCount }) => {
if (!overscanCount || overscanCount < 1) overscanCount = 1;
const renderStartIndex = Math.max(0, startIndex - overscanCount);
const renderEndIndex = Math.min(startIndex + renderItemCount + overscanCount, itemCount);
return {
renderStartIndex,
renderEndIndex,
};
}
Fixed Size List
가 구현된 코드는 다음과 같다.
import React, { CSSProperties } from "react";
import { useEffect, useState } from "react";
import { useScrollDetector, useViewportHeight } from "../../hooks";
import { calculateRenderIndex } from "../../utils";
interface FixedSizeListProps {
scrollTarget: HTMLElement | Window;
top: number;
itemHeight: number;
children: React.ReactElement;
itemData: Array<any>;
itemCount: number;
overscanCount?: number;
style: CSSProperties;
}
export default function FixedSizeList({
scrollTarget = window,
top,
itemHeight,
children,
itemData,
overscanCount = 1,
style,
}: FixedSizeListProps) {
const curScrollPos = useScrollDetector(scrollTarget);
const viewportHeight = useViewportHeight();
const itemCount = itemData.length;
const [renderIndex, setRenderIndex] = useState({
renderStartIndex: 0,
renderEndIndex: itemCount,
});
const [indexOffset, setIndexOffset] = useState(0);
const [renderItem, setRenderItem] = useState<Array<any>>([]);
useEffect(() => {
if (viewportHeight === 0) return;
const newRenderIndex = calculateRenderIndex({
top,
itemHeight,
curScrollPos,
viewportHeight,
itemCount,
overscanCount,
});
if (JSON.stringify(renderIndex) !== JSON.stringify(newRenderIndex)) setRenderIndex(newRenderIndex);
}, [curScrollPos, viewportHeight]);
useEffect(() => {
const { renderStartIndex, renderEndIndex } = renderIndex;
setIndexOffset(renderStartIndex);
setRenderItem(itemData.slice(renderStartIndex, renderEndIndex + 1));
}, [renderIndex]);
return (
<div style={style}>
<div style={{ position: "relative", height: `${itemCount * itemHeight}px` }}>
{renderItem.map((data, index) => {
const realIndex = indexOffset + index;
const calculatedTop = realIndex * itemHeight;
return React.createElement(
"div",
{
key: realIndex,
style: { height: `${itemHeight}px`, width: "100%", top: `${calculatedTop}px`, position: "absolute" },
},
React.cloneElement(children, {
data: data,
index: realIndex,
})
);
})}
</div>
</div>
);
}
사용 예시
<div className="App">
<FixedSizeList
scrollTarget={window}
top={0}
itemHeight={300}
itemData={data}
style={{ position: "absolute", top: "300px", width: "1000px" }}
>
<Item newprops={newprops} />
</FixedSizeList>
</div>
다음과 같이 코드를 작성하면
<FixedSizeList scrollTarget={window} top={0} itemHeight={200} itemData={data}>
<Item />
</FixedSizeList>
FixedSizeList
컴포넌트 내에서 children을 아래와 같이 가져올 수 있다.
function FixedSizeList({ children, ... }) { ...
Item
을 렌더링할 때에는 리스트 내의 각 아이템에 아이템의 index를 넘겨줄 수 있었으면 했다.
즉, children을 렌더링하되 props를 추가로 넘겨줄 수 있었으면 하는 상황이었는데, 이런 경우에는 React.cloneElement
를 활용할 수 있다.
React.cloneElement
는 인수로 받은 element
를 복제(clone)하여 새로운 React 엘리먼트를 반환하는데, 이 때 기존 엘리먼트에 props에 새로운 props가 얕게(shallowly) 병합된다.
사용 방법은 다음과 같다.
React.cloneElement(
element,
[config],
[...children]
)
실제 코드에서는 아래와 같이 사용하여, children에 해당하는 컴포넌트를 첫 번째 인수로 넣어주고, 추가로 넣고 싶은 props들을 두 번째 인수로 넣어주었다.
React.cloneElement(children, {
data: data,
index: realIndex,
})
React.cloneElement를 사용함으로써 얻은 장점
react-window
에서는 아래와 같이 itemData
속성으로만 자식 컴포넌트(Row
)에서 필요로 하는 값을 넘겨줄 수 있었다.
<List
height={150}
itemCount={1000}
itemSize={35}
itemData={{ articles, keywords }}
width={300}
>
{Row}
</List>
React.cloneElement
를 사용해서 구현했더니 아래와 같이 늘 작성하던 방식으로 Item 컴포넌트를 List 내에 넣어줄 수 있고 당연히 props도 설정해줄 수 있어서, 사용 방식이 더 편하고 직관적인 것 같다고 느껴졌다.
<FixedSizeList
scrollTarget={window}
top={0}
itemHeight={300}
itemData={data}
style={{ position: "absolute", top: "300px", width: "1000px" }}
>
<Item newprops={newprops} />
</FixedSizeList>
renderIndex
를 업데이트 해주는 부분에 다음과 같은 조건문이 작성되어있다.
if (JSON.stringify(renderIndex) !== JSON.stringify(newRenderIndex)) setRenderIndex(newRenderIndex);
해당 조건문이 포함된 이유는 객체를 상태로 설정하게 되면 각 속성과 값이 서로 동일하더라도 참조 값이 다르면 다른 객체로 판단하기 때문이다. 관련 내용은 다음 링크에 정리해두었다.
따라서 계산된 index가 동일하더라도 상태가 업데이트되는 문제가 있었는데, 이를 막기 위해서 객체를 stringify한 값을 비교해서 속성과 값이 동일하면 상태를 업데이트하지 않도록 하였다.
간단한 컴포넌트로 테스트했을 땐 화면이 깜빡거리지 않았는데, 이를 프로젝트에 적용해보니 깜빡거리는 현상이 발생했었다.
왜인지 고민해보니, 서로 다른 상태가 바뀔 때 각각 렌더링이 일어나면서 생기는 문제였다.
아래는 이전에 작성했던 코드이다.
// Fixed Size List
useEffect(() => {
const startIndex = calculateStartIndex({ top, itemHeight, curScrollPos, viewportSize });
const newRenderIndex = calculateRenderIndex({
startIndex,
renderItemCount,
itemCount: itemData.length,
overscanCount,
});
if (JSON.stringify(renderIndex) !== JSON.stringify(newRenderIndex)) setRenderIndex(newRenderIndex);
}, [curScrollPos, viewportSize]);
useEffect(() => {
const { renderStartIndex, renderEndIndex } = renderIndex;
setRenderItem(itemData.slice(renderStartIndex, renderEndIndex));
}, [renderIndex]);
return (
<div style={style}>
<div style={{ position: "relative", height: `${itemCount * itemHeight}px` }}>
{renderItem.map((data, index) => {
const realIndex = renderIndex.renderStartIndex + index;
const calculatedTop = realIndex * itemHeight;
return React.createElement(
"div",
{
key: realIndex,
style: { height: `${itemHeight}px`, top: `${calculatedTop}px`, position: "absolute" },
},
React.cloneElement(children, {
data: data,
index: realIndex,
})
);
})}
</div>
</div>
);
첫 번째 useEffect
에서 renderIndex
값을 바꿔주고, 두 번째 useEffect
에서는 이 renderIndex
가 바뀌면 그 값을 바탕으로 renderItem
을 업데이트 한다.
즉, renderIndex
→ renderItem
순으로 값을 업데이트하는 함수가 실행된다.
렌더링을 할 때는 이 두 값을 사용해서 렌더링하고 있으므로, 각각의 값이 업데이트될 때마다 리렌더링이 일어나고 있었다.
그렇다면 값을 동시에 업데이트해줄 수는 없을까?
리액트에서는 ‘batching’을 사용하고 있는데, 이는 여러 개의 state 업데이트를 묶어서 리렌더링이 한 번만 일어나도록 하는 것을 의미한다.
아래의 코드에서,
function handleClick() {
setCount(c => c + 1);
setFlag(f => !f);
// React will only re-render once at the end (that's batching!)
}
handleClick()
함수가 실행되면 count
와 flag
를 바꾸는데, 이에 대한 리렌더링은 한 번만 일어난다.
참고로 React18 이전 버전에서는 batching이 리액트의 event handler에 대해서만 적용되었지만, React18에서부터는 promises, setTimeout, native event handlers에도 적용된다고 한다. (공식 문서 링크)
// Before: only React events were batched.
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// React will render twice, once for each state update (no batching)
}, 1000);
// After: updates inside of timeouts, promises,
// native event handlers or any other event are batched.
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// React will only re-render once at the end (that's batching!)
}, 1000);
따라서 하나의 useEffect
내에서 렌더링에 사용되는 상태를 업데이트 해주기로 하였다.
렌더링 시에는 renderIndex
의 renderStartIndex
만 사용하고 있으므로, 이를 indexOffset
이라는 새로운 상태로 지정하고, indexOffset
과 renderItem
을 하나의 useEffect
내에서 업데이트 해주었다.
수정 결과
useEffect(() => {
const { renderStartIndex, renderEndIndex } = renderIndex;
setIndexOffset(renderStartIndex);
setRenderItem(itemData.slice(renderStartIndex, renderEndIndex));
}, [renderIndex]);
return (
<div style={style}>
<div style={{ position: "relative", height: `${itemData.length * itemHeight}px` }}>
{renderItem.map((data, index) => {
const realIndex = indexOffset + index;
const calculatedTop = realIndex * itemHeight;
return React.createElement(
"div",
{
key: realIndex,
style: { height: `${itemHeight}px`, top: `${calculatedTop}px`, position: "absolute" },
},
React.cloneElement(children, {
data: data,
index: realIndex,
})
);
})}
</div>
</div>
);
개발자 도구를 켜서 보면 다음과 같이 스크롤에 따라 새로운 요소가 추가되고, 뷰포트를 벗어난 요소는 제거되는 것을 알 수 있다.
반면 계속 화면에 존재하는 요소는 리렌더링되지 않고 유지된다.
뷰포트 내로 들어온 요소는 렌더링되기 때문에, 사용자 입장에서는 windowing이 적용되더라도 적용 전처럼 요소들을 정상적으로 확인할 수 있다.
만약 요소들의 개수가 훨씬 많아진다면, windowing을 적용함으로써 버벅거림을 줄일 수 있다는 장점이 있다.
Github 링크
https://github.com/dahyeon405/windowing
참고자료
react-window/FixedSizeList.js at master · bvaughn/react-window