Windowing 직접 구현하기

dahyeon·2023년 1월 31일
5
post-thumbnail

Windowing을 직접 구현하게 된 계기

현재 진행하고 있는 프로젝트에 react-window 라이브러리를 통해 windowing 기법을 적용해본 경험이 있다(적용기 링크). Windowing 기법을 적용할 수 있는 라이브러리에는 react-virtualized도 있는데, 이에 비해 react-window를 선택한 이유는 패키지 사이즈가 훨씬 작고 많은 추가 기능이 필요하지 않았기 때문이다.

하지만 사용하면서 다음과 같은 불편한 점들을 느꼈다.

  1. 무조건 다음과 같은 문법으로 사용해야 한다.

    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를 넘겨준다.
    • Row 컴포넌트 안에서 index를 통해 어떤 데이터를 표시할 지 결정할 수 있다. 이 때 데이터는 List 컴포넌트의 itemData props로 넘겨준다. (아래에 전체 코드가 있으니 참고)

    여기서, {Row}의 위/아래에 다른 요소를 넣어줄 수 없다.

    기존 코드에서 무한스크롤의 타겟 요소가 아이템들의 가장 하단에 위치했는데, 위 문법을 따를 경우 타겟 요소를 따로 넣어주기가 어려웠다.

    → react-window-infinite-loader라는 라이브러리를 함께 사용해서 해결할 수 있다.

  2. 리스트들이 들어있는 컨테이너의 높이(height)를 지정해줘야 한다.

    1번과도 얽혀있는 불편한 점이긴 한데, 기존에는 리스트들이 들어있는 컨테이너의 높이를 따로 지정해주지 않았다. 따라서 검색 결과의 높이 합이 페이지 높이를 초과할 경우 아래와 같이 전체 스크롤이 생기게 되었다.

    https://velog.velcdn.com/images/dahyeon405/post/4711b46d-0967-411c-84a0-c5ecfafc44a5/image.png

    하지만 해당 라이브러리를 사용할 경우 windowing이 적용되는 요소(리스트들의 컨테이너)의 높이를 지정해줘야 하고, 높이를 초과할 경우 아래와 같이 내부 스크롤이 생긴다.

    https://velog.velcdn.com/images/dahyeon405/post/b527fa74-00dd-4d07-bfb9-b79f42825b95/image.png

이러한 불편한 점들을 react-virtualized-auto-sizer와, react-window-infinite-loader를 통해 해결해서 프로젝트에 적용은 해보았으나, 내부 스크롤이 생기는 방식으로밖에 구현을 할 수 없다는 것은 극복하기가 어려웠다.

왜 이렇게 구현되어 있을까? 생각해보니, 보여줄 아이템을 고르려면 사용자가 어디까지 스크롤했는지 알아야하는데, FixedSizeList 자체의 스크롤 이벤트만 감지하도록 설계되어 있는 것 같았다.

직접 구현하려면 DOM을 조작해야한다는 생각에 당시에는 시도해보지 않았는데, 리액트를 깊게 공부하면서 DOM을 조작할 필요가 없구나 깨달았다. 그렇다면 직접 구현해볼 수 있지 않을까?하는 생각이 들어서 스크롤 이벤트의 타겟을 직접 설정해서 넣어줄 수 있도록(window도 가능하게!!) 구현해보고자 하였다.

Windowing 구현의 아이디어

1. Key를 활용해서 불필요한 재렌더링 방지하기

React에서 Key의 중요성

리액트를 사용하면서 한 번쯤은 다음과 같은 오류를 마주한 적이 있을 것이다.

이는 아래 코드처럼 자바스크립트의 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)을 사용자에게 보이는 요소로 업데이트해주면 된다.

2. 렌더링할 아이템 고르기

그렇다면 화면에 어떤 요소를 렌더링해줘야 할까? ‘뷰포트 안으로 들어온 요소가 무엇인지’는 어떻게 알 수 있을까?

고민해보다보니 다음과 같은 두 가지 방법이 떠올랐다.

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라면, 전체 화면의 스크롤 위치(window.scrollY)
    • 타겟이 특정 요소라면, 요소 내에서의 스크롤 위치(element.scrollTop)
  • viewportSize: 뷰포트의 높이

다음과 같이 경우를 나누어 계산할 수 있다.

1) top이 뷰포트 사이즈보다 작은 경우. 요소가 처음에 화면 내에 보이는 경우.

startIndex는 대략 (scroll-top)/itemHeight를 해서 구할 수 있다.

2) top이 뷰포트 사이즈보다 큰 경우. 요소가 처음에 화면 내에 보이지 않는 경우.

이 경우는 두 가지 경우로 나뉘는데,

  • 화면 내에 List가 보이지 않을 때 ~ 첫 번째 아이템이 보일 때까지는 startIndex = 0이다. 그 경계는 scroll == viewportSize + top일 때이다.
  • 이후에는 scrollviewportSize + 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;

3. 레이아웃이 변경되지 않게 하기

만약 windowing 기법을 적용해서 스크롤에 따라 렌더링할 요소들을 바꿔주고 있는데, 이에 따라 레이아웃 (사용자가 보는 화면) 또한 변경된다면 매우 비효율적이고 불편할 것이다.

다행히도 지금 상황에서는 아이템의 높이가 고정되어있기 때문에, 리스트 내 아이템의 y축 위치도 리스트의 index에 따라 결정된다.

따라서 아래와 같이 스타일을 설정해주었다.

  • 아이템들을 감싸는 부모에 position: relative 속성 부여
  • 각 아이템에 position: absolute 적용
  • 아이템의 y축 위치는 index*h px로 계산

react-window를 적용하고 개발자 도구를 켜서 요소들을 살펴보면 아래와 같이 인라인 스타일이 적용되어있다. 비슷한 로직으로 구현되어있음을 알 수 있다.

Fixed Size List 구현

react-window에서는 Fixed Size List, Variable Size List, Fixed Size Grid 등 다양한 경우에 대해 windowing을 지원하고 있지만 나는 그 중 아이템의 높이가 하나로 고정되어있고, 한 줄에 한 아이템만 렌더링하는 Fixed Size List만 구현해보기로 하였다.

구현 로직은 다음과 같다.

  1. 렌더링할 아이템 개수(itemCount)는 뷰포트 크기에만 의존적이다. 뷰포트 크기가 바뀔 때마다 새로 계산해준다.
  2. 사용자의 스크롤 위치 또는 뷰포트 크기가 바뀔 때마다 렌더링 시작 요소 인덱스(startIndex)를 계산해준다. 렌더링할 마지막 요소 인덱스는 itemCountstartIndex로부터 계산한다.
  3. 데이터가 담긴 배열을 렌더링 시작 요소 인덱스(startIndex)와, 종료 요소 인덱스(endIndex)까지 잘라 이를 렌더링한다.
    • 이 때 배열을 slice할 경우 인덱스가 변경되므로, key에는 startIndex + index를 설정하여 같은 아이템인 경우 key가 변경되지 않도록 한다.

여기서 몇 개의 아이템을 넉넉히 렌더링할 지 결정할 수 있는 overscanCount를 설정할 수 있도록 했다. react-window에도 있는 기능이다.

예를 들어 화면에 첫 번째로 보이기 시작하는 요소의 인덱스가 startIndex라면, startIndex - overscanCount 번째 인덱스부터 렌더링을 해서 깜빡임이 덜하도록 할 수 있다.

2)번에서 startIndex를 계산한 후, 이로부터 추가로 itemCountoverscanCount를 인수로 받아서 renderStartIndexrenderEndIndex를 계산하는 함수를 만들었다.

렌더링할 요소의 시작 인덱스, 종료 인덱스를 계산해주는 함수는 다음과 같다.

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>

React.cloneElement: children에 props 추가로 넘겨주기

다음과 같이 코드를 작성하면

<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한 값을 비교해서 속성과 값이 동일하면 상태를 업데이트하지 않도록 하였다.

Troubleshooting: Batching으로 리렌더링은 한 번만

간단한 컴포넌트로 테스트했을 땐 화면이 깜빡거리지 않았는데, 이를 프로젝트에 적용해보니 깜빡거리는 현상이 발생했었다.

왜인지 고민해보니, 서로 다른 상태가 바뀔 때 각각 렌더링이 일어나면서 생기는 문제였다.

아래는 이전에 작성했던 코드이다.

// 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을 업데이트 한다.

즉, renderIndexrenderItem 순으로 값을 업데이트하는 함수가 실행된다.

렌더링을 할 때는 이 두 값을 사용해서 렌더링하고 있으므로, 각각의 값이 업데이트될 때마다 리렌더링이 일어나고 있었다.

그렇다면 값을 동시에 업데이트해줄 수는 없을까?

리액트에서는 ‘batching’을 사용하고 있는데, 이는 여러 개의 state 업데이트를 묶어서 리렌더링이 한 번만 일어나도록 하는 것을 의미한다.

아래의 코드에서,

function handleClick() {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React will only re-render once at the end (that's batching!)
}

handleClick() 함수가 실행되면 countflag를 바꾸는데, 이에 대한 리렌더링은 한 번만 일어난다.

참고로 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내에서 렌더링에 사용되는 상태를 업데이트 해주기로 하였다.

렌더링 시에는 renderIndexrenderStartIndex만 사용하고 있으므로, 이를 indexOffset이라는 새로운 상태로 지정하고, indexOffsetrenderItem을 하나의 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

참고자료

재조정 (Reconciliation) - React

React v18.0 - React Blog

React Top-Level API - React

react-window/FixedSizeList.js at master · bvaughn/react-window

profile
https://github.com/dahyeon405

0개의 댓글