무한 스크롤을 구현 해보았다!

Hwang Tae Young·2023년 1월 1일
0

🎯오늘 기록할 내용은 무한 스크롤(Infinite scroll)!

🤔 무한 스크롤(Infinite scroll)이란?

  • 무한 스크롤(Infinite Scroll)이란 사용자가 특정 페이지 하단에 도달했을 때, API가 또는 콘텐츠가 호출되며 끊기지 않고 계속 콘텐츠를 로드되게 하는 UX를 생각한 방식이다.

✅ 장점

  • 페이지를 클릭하면 다음 페이지 주소로 이동하게 하는 방식과 다르게 한 페이지에서 스크롤만으로 새로운 콘텐츠를 보여주게 되므로, 많은 양의 콘텐츠를 스크롤하여 볼 수 있다.

✅ 단점

  • 사용자가 본 특정 항목으로 찾아가는게 어렵다.

  • 방식에따라 페이지 로드 시간이 오히려 오래 걸릴 수 있다.
    -> 모든 게시물을 클라이언트에 저장해서 콘텐츠를 로드하는 방식
    -> 다음 게시물을 api호출해서 받아오는 방식

  • 게시물의 실제 양을 가늠하기 어렵다.(이건 단점으로 애매,,?)

✅ 기능

  • 스크롤이 바닥에 닿으면 다음 게시물 20개를 불러온다.
  • 한번 쌓인 게시물들은 다른 페이지를 이동하더라도, 기존의 스크롤이 유지된다.
  • 모든 게시물이 불러와지면 더이상 동작하지 않는다.

완성 화면

✅ 구현 방법

Intersection Observer API VS Scroll Event

🤔 Scroll Event란?

말 그대로 브라우저에서 사용자가 스크롤을 할때마다, 지정해준 함수가 동작하게 해주는 이벤트다. 사용자가 스크롤을 내릴때마다 동작하기 때문에 불필요한 동작이 많이 일어나서 스크롤 이벤트는 사용하지 않았다!

🤔 Intersection Observer API란?

현재 보고있는 viewport와 target으로 설정한 요소의 교차점을 찾아내주며, 해당 요소가 viewport의 포함 되는지 아닌지 쉽게 말하자면 현재 사용자의 화면에 보이는지 안보이는지 구별해 주는 기능이다. Scroll Evnet 와 달리 비동기로 동작하여 렌더링 성능이나 호출 같은 문제가 없이 사용가능하다. 그래서 얘를 채택하였다!

✅ 구현을 위해 사용한 것들

  • React-Query => useInfiniteQuery
  • Intersection Observer API
  • React Element속성인 ref (useRef는 사용하지 않았습니다)
  • useCallback

✅ 완성 코드

PokemonList 컴포넌트

export default function PokemonList({ isSearch }: { isSearch: Boolean }) {
  const {
    data: pokemonList,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useGetPokemonListQuery();
  
  ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ
export const useGetPokemonListQuery = () =>
  useInfiniteQuery(
    ["pokemonList"],
    async ({ pageParam }) => await pokemonApis.getPokemonList({ pageParam }),
    {
      getNextPageParam: ({ next }): getPokemonListI => {
        return next;
      },
    },
  );
 ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ
  

  const searchPokemon = useAtomValue(searchedPokemonAtom);

  const target = useCallback(
    (node: HTMLElement | null) => {
      if (!node) {
        return;
      } else {
        const observer = new IntersectionObserver(
          ([entry]) => {
            const { isIntersecting, target } = entry;
            if (isIntersecting && hasNextPage && !isFetchingNextPage) {
              fetchNextPage();
              observer.unobserve(target);
            }
          },
          {
            root: null,
            threshold: 0,
          },
        );
        observer.observe(node);
      }
    },
    [hasNextPage, fetchNextPage, isFetchingNextPage],
  );

  return (
    <ul className="grid grid-cols-3 gap-6 max-w-[1200px] m-auto">
      {isSearch
        ? pokemonList?.pages?.map(
            ({ results }, pageIndex: number, { length: pagesLength }) =>
              results.map(
                (
                  { name, url }: pokemonNameUrlI,
                  cardIndex: number,
                  { length: cardLength }: { length: number },
                ) => {
                  const isTarget =
                    pageIndex + 1 === pagesLength &&
                    cardIndex + 1 === cardLength;
                  return (
                    <PokemonCard
                      key={name}
                      name={name}
                      url={url}
                      isTarget={isTarget}
                      target={target}
                    />
                  );
                },
              ),
          )
        : searchPokemon?.map(({ name, url }: pokemonNameUrlI) => {
            return <PokemonCard key={name} name={name} url={url} />;
          })}
    </ul>
  );
}

pokemonCard 컴포넌트
export default memo(function PokemonCard({
  name,
  url,
  isTarget,
  target,
}: pokemonNameUrlI) {
  const { data: imgUrl } = useGetPokemonInfoQuery({ url, key: "imgUrl" });
  const checkKo = /[ㄱ-ㅎ|ㅏ-ㅣ|가-힣]/;
  const pokemonName = checkKo.test(name) ? name : pokemonKoName[name];

  const targetCard = (node: HTMLElement | null) => {
    target && target(node);
  };

  return (
    <li ref={isTarget ? targetCard : null}>
      <div className="relative h-[20em] border">
        <Image
          src={imgUrl ? imgUrl : "/image/monsterBall.png"}
          className="p-4"
          alt={pokemonName}
          fill
          priority
          sizes="auto"
        />
      </div>
      <p>{pokemonName}</p>
    </li>
  );
});

✅ 동작방식

useInfiniteQuery의 아래의 기능을 사용했다

const {
  fetchNextPage,        // 다음 페이지 가져오는 함수
  hasNextPage,          // 다음 페이지는 있는지 구별은 getNextPageParam의 리턴값으로 한다 undefine이면 false
  isFetchingNextPage,   // false면 다음 페이지 가져오는중, true면 다 가져옴
} = useInfiniteQuery({
  queryKey,             // 쿼리키
  queryFn: ({ pageParam = 1 }) => fetchPage(pageParam), //pageParam값으로 데이터를 가져온다 초기값은 할당해 주지 않으면 undefine가 나옴
  ...options,
  getNextPageParam: (lastPage, allPages) => lastPage.nextCursor,
  //return값으로 지정해 준것이 pageParam값으로 들어감 그리고 다음 페이지가 없다면 undefine값을 리턴 해주면 된다.
})
출처 : react-query 공식문서
  • useInfiniteQuery는 useQuery를 사용해서 데이터를 받아오는것과 다르게 pages와 pageParams를 묶어서 데이터를 전송해준다.
{
    "pages": [
       [첫번째 불러온 데이터],
       [두번째 불러온 데이터]
//이렇게 불러온 데이터들을 array에 담아서 준다.
    ],
    "pageParams": [
        //현재 데이터를 불러오기위해 사용한 api url
    ]
}
이렇게 받아온 데이터를 아래와 같이 map을 동작시켜주었다.
map은 첫번째 인자로 배열안에 있는 각각 값, 두번째 인자로는 index값, 세번째 값으로는 배열 자체를 반환해준다.

pokemonList?.pages?.map(
            ({ results }, pageIndex: number, { length: pagesLength }) =>
              results.map(
                (
                  { name, url }: pokemonNameUrlI,
                  cardIndex: number,
                  { length: cardLength }: { length: number },
                ) => {
                  const isTarget =
                    pageIndex + 1 === pagesLength &&
                    cardIndex + 1 === cardLength;
                // 여기서 현재 맵이 돌고있는 페이지와 전체 페이지의 수가 같을 때, 가장 마지막에 있는 card가 일치할 때만
                // true값을 반환해 주어 가장 맨 마지막에 있는 card를 target로 삼을 수 있게 해두었다.
                  return (
                    <PokemonCard
                      key={name}
                      name={name}
                      url={url}
                      isTarget={isTarget}
                      target={target}
                    />
                  );
                },
              ),
          )
  • 그 다음으로는 target함수를 실행시킨다.
  const target = useCallback(
    (node: HTMLElement) => {
      const observer = new IntersectionObserver(
        ([entry]) => {
          const { isIntersecting, target } = entry;
          //isIntersecting은 현재 요소가 있는지 없는지 true, false 값으로 나온다.
          //target는 말 그대로 타겟에 대한 핸들링
          if (isIntersecting) {
          //현재 요소가 있고, 다음 페이지가와 페칭준비가 끝났다면 아래의 코드를 실행한다
            fetchNextPage();
          //다음 페이지를 받아오고
            observer.unobserve(target);
          //원래 있던 타겟을 더이상 관찰하지 않는다.
          }
        },
        {
          root: null,
          //viewport의 기준값 null이면 브라우저를 대상으로함
          threshold: 0,
          //타깃의 가시성이 얼마나 확보되었을때, 0으로 해두어 타겟이 전부 보일때 함수가 실행됩니다.
          
        },
      );
      observer.observe(node);
      //다시 받아온 데이터중 가장 마지막 요소를 타겟으로 지정
    },
    [fetchNextPage],
  );
useCallback를 사용한 이유는, 자식요소의 불필요한 리렌더링을 막기위해 사용하였다.
  • 그렇다면 target함수에서 node는 어디서 받아오는가! 바로 PokemonCard컴포넌트에서 받아온다.
export default memo(function PokemonCard({
  name,
  url,
  isTarget,
  target,
}: pokemonNameUrlI) {
  const { data: imgUrl } = useGetPokemonInfoQuery({ url, key: "imgUrl" });
  const checkKo = /[ㄱ-ㅎ|ㅏ-ㅣ|가-힣]/;
  const pokemonName = checkKo.test(name) ? name : pokemonKoName[name];

  const targetCard = (node: HTMLElement | null) => {
    !!node && target && target(node);
    //여기서 타겟을 지정해 준다!
  };

  return (
    <li ref={isTarget ? targetCard : null}>
      <div className="relative h-[20em] border">
        <Image
          src={imgUrl ? imgUrl : "/image/monsterBall.png"}
          className="p-4"
          alt={pokemonName}
          fill
          priority
          sizes="auto"
        />
      </div>
      <p>{pokemonName}</p>
    </li>
  );
});

✅ 마무리

전에 프로젝트 할때는, 기능 구현에 급급해서 제대로된 원리는 파악하지 않고 그대로 사용했었는데 이번에 다시 보니 정말 아무것도 모르고 복붙으로만 코드를 짯었던 것 같다! 이제는 전보다는 더 나은 방식으로 사용할 줄 알게 된거 같아서 기분이 뭔가 좋다,,!

profile
더 나은 개발자가 되기 위해...☆

0개의 댓글