Home 무한스크롤
Post
Cancel

무한스크롤

📌 리액트 무한스크롤(infinite scroll) 구현하기

✏️ 구현 방법

  1. Scroll Event를 감지하고, 해당 이벤트 핸들러에서 스크롤값을 비교
  2. Javascript에서 지원하는 Observer API를 활용

📌 Scroll Event를 감지하고, 해당 이벤트 핸들러에서 스크롤값을 비교

✏️ MSW(Mock Service Worker)를 이용하여 가상의 api 만들기

https://moonjh9392.github.io/posts/MSW/

src/mocks/handler.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import { rest } from "msw";

// [코드 1] 무한스크롤 응답 인터페이스
export interface PaginationResponse<T> {
  contents: T[];
  pageNumber: number;
  pageSize: number;
  totalPages: number;
  totalCount: number;
  isLastPage: boolean;
  isFirstPage: boolean;
}

export interface User {
  id: number;
  name: string;
}

// [코드 2] MSW 유저 목록 모킹 API
//데이터 생성
const users = Array.from(Array(256).keys()).map((id): User => ({
  id,
  name: `denis${id}`,
}));

export const handlers = [
  //무한스크롤 응답부
  rest.get("/users", async (req, res, ctx) => {
    const { searchParams } = req.url;
    const size = Number(searchParams.get("size"));
    const page = Number(searchParams.get("page"));

    const totalCount = users.length;
    const totalPages = Math.round(totalCount / size);

    return res(
      ctx.status(200),
      ctx.json <
        PaginationResponse <
        User >>
          {
            contents: users.slice(page * size, (page + 1) * size),
            pageNumber: page,
            pageSize: size,
            totalPages,
            totalCount,
            isLastPage: totalPages <= page,
            isFirstPage: page === 0,
          },
      ctx.delay(500)
    );
  }),
];

src/mocks/worker.js

1
2
3
4
import { setupWorker } from "msw";
import { handlers } from "./handlers.ts";

export const worker = setupWorker(...handlers);

src/index.tsx

1
2
3
4
5
6
7
8
9
import ...
...
//아래의 내용 추가
//MSW
import { worker } from './mocks/worker';
if (process.env.NODE_ENV === 'development') {
  worker.start();
}
...

MSW로 /users api를 만들어 데이터를 들고올 수 있도록 만들어줌

✏️ 설명

src/components/UserPage.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
import axios from 'axios';
import { useCallback, useEffect, useState } from 'react';

import styled from 'styled-components';
import { PaginationResponse, User } from '../mocks/handlers';
import Loading from './common/Loading';

// [코드 3] Scroll event를 이용한 무한스크롤 예시

//Card 컴포넌트의 heigth 사이즈
export const CARD_SIZE = 50;
//visualViewport : 뷰포트 사이즈
//PAGE_SIZE : 데이터가 몇개씩 보일지 정하는 변수
export const PAGE_SIZE = Math.ceil(visualViewport!.width / CARD_SIZE);

function UsersPage() {
  const [page, setPage] = useState(0);
  const [users, setUsers] = useState<User[]>([]);
  const [isFetching, setFetching] = useState(false);
  const [hasNextPage, setNextPage] = useState(true);

  const fetchUsers = useCallback(async () => {
    const { data } = await axios.get<PaginationResponse<User>>('/users', {
      params: { page, size: PAGE_SIZE },
    });
    //user data 추가
    setUsers(users.concat(data.contents));
    // 페이지 추가
    setPage(data.pageNumber + 1);
    // 마지막 페이지 인지 확인
    setNextPage(!data.isLastPage);
    //isFetching : false 초기화
    setFetching(false);
  }, [page]);

  //1. useEffect로 스크롤 이벤트 생성
  useEffect(() => {
    const handleScroll = () => {
      console.log('handleScroll');

      //scrollTop : 스크롤 위치 px 반환
      //offsetHeight : 요소의 높이
      const { scrollTop, offsetHeight } = document.documentElement;
      //innerHeight : 뷰포트 높이
      // 바닥감지
      if (window.innerHeight + scrollTop >= offsetHeight) {
        //바닥일 경우 isFetching 값을 true 로 바꿔 데이터를 들고온다
        setFetching(true);
      }
    };
    //처음 페이지 왔을때 데이터 get 하기 위해 isFetching : true로 바꿈
    setFetching(true);
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);

  //2. 1번에서 isFetching 값이 바뀌면서 아래의 메서드 실행
  useEffect(() => {
    //isFetching : ture , hasNextPage : true 이면 fetchUsers 실행
    if (isFetching && hasNextPage) fetchUsers();
    else if (!hasNextPage) setFetching(false);
  }, [isFetching]);

  return (
    <Container>
      {users.map((user) => (
        <Card key={user.id} size={CARD_SIZE}>
          {user.name}
        </Card>
      ))}
      {isFetching && <Loading />}
    </Container>
  );
}

export const Container = styled.div`
  border: 1px solid black;
`;
export const Card = styled.div<{ size?: number }>`
  height: ${(props) => (props.size ? props.size + `px` : `50px`)};
  border: 1px solid black;
`;

export default UsersPage;

스크롤이 바닥을 감지할때마다 fetch에서 데이터를 불러와 화면에 뿌려준다

ezgif com-gif-maker (1)

이렇게 하면 위의 사진처럼 스크롤 이벤트가 과다하게 발생되는데

lodash의 throttle을 이용해 컨트롤 가능하다.

1
2
3
4
5
6
7
8
9
10
11
...
import { throttle } from 'lodash';
...
//1. useEffect로 스크롤 이벤트 생성
  useEffect(() => {
    ...
    //처음 페이지 왔을때 데이터 get 하기 위해 isFetching : true로 바꿈
    setFetching(true);
    window.addEventListener('scroll', throttle(handleScroll, 500));//여기서 쓰로틀 사용
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);

ezgif com-gif-maker (3)

시연시간 500ms를 주어 스크롤 이벤트 발생 횟수가 많이 줄은것을 확인할 수 있다.

📌 2. Javascript에서 지원하는 Observer API를 활용

✏️ Observer API

Observer pattern은 객체 상태변화를 관찰하는 관찰자들(옵저버)을 등록하여 상태변화가 있을 때마다 각 옵저버에 알리는 패턴이다.

이 논리에 기반한 Javascript 내장 API가 Observer API 인 것이다.

  1. MutationObserver

DOM 변경을 감지하는 옵저버이다. 요소의 삽입, 수정, 삭제 및 자식요소가 수정되는 경우 등을 감지한다.

  1. ResizeObserver

타겟 요소가 리사이징 이벤트를 통해 크기가 변경될 때를 감지하는 옵저버다. 반응형으로 보이는데 유용하다.

  1. IntersectionObserver

타겟 요소와 상위 요소(혹은 최상위 document)의 viewport 사이에 교차지점을 비동기적으로 관찰한다.

타겟요소가 화면에 얼마나 보이는지에 따라 다양한 이벤트를 줄 수 있다.

✏️ 사용 방법

인스턴스 생성

1
let observer = new IntersectionObserver(callback, options);
  • callback : 교차시에 실행되는 함수이다. 로딩구현이나 패치 등의 함수가 통상 할당된다.
  • options : Intersection Observer에 관한 설정을 할 수 있는 부분이다.
  • root : 교차를 감지하는 root 요소. observe로 등록할 요소의 상위요소여야 한다. 기본값은 null(이 땐 브라우저 viewport)
  • rootMargin : root 요소의 마진값. 기본값은 0px.
  • threshold : 0.0 ~ 1.0 사이의 숫자들을 배열로 받는다. 이는 %로 치환되어, 해당 비율만큼 교차된 경우 콜백이 실행된다.

✏️ 구현

  • react-query의 useInfiniteQuery를 이용하여 데이터를 들고올것이기에 커스텀 훅을 만들어준다
  • observer도 마찬가지로 커스텀 훅을 이용하여 만들어줌
  1. src/hooks/useFetchUsers.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import axios from 'axios';
import { QueryFunctionContext, useInfiniteQuery } from 'react-query';
import { PaginationResponse, User } from '../mocks/handlers';

interface PaginationParams {
  size: number | null;
}

// [코드 12] React Query를 이용한 유저 목록 API 호출 함수
//고유 키값 만드는데 사용됨 detail 왜있는지 모름 kakao꺼 긁어왔음
export const userKeys = {
  all: ['users'] as const,
  lists: () => [...userKeys.all, 'list'] as const,
  list: (filters: string) => [...userKeys.lists(), { filters }] as const,
  details: () => [...userKeys.all, 'detail'] as const,
  detail: (id: number) => [...userKeys.details(), id] as const,
};

export const useFetchUsers = ({ size }: PaginationParams) => {
  console.log('useFetchUsers');
  //useInfiniteQuery : 'react-query' 에서 무한스크롤을 사용하기위해 만든 메서드
  //useInfiniteQuery(queryKey,queryFn,...)
  //queryKey :  useQuery마다 부여되는 고유 Key, React Query가 query 캐싱을 관리할 수 있도록 도와줍니다.
  //queryFn : query Function으로 promise 처리가 이루어지는 함수
  return useInfiniteQuery(
    //queryKey
    //['users','list']
    userKeys.lists(),
    //queryFn
    //pageParam : useInfiniteQuery가 현재 어떤 페이지에 있는지를 확인할 수 있는 파라미터 값
    ({ pageParam = 0 }: QueryFunctionContext) => {
      //data get
      return axios.get<PaginationResponse<User>>('/users', {
        params: { page: pageParam, size },
      });
    },
    {
      //getNextPageParam과 fetchNextPage은 공통적으로 다음 페이지에 있는 데이터를 조회해올 때 사용됩니다.
      //getNextPageParam : 다음 api를 요청할 때 사용될 pageParam값을 정할 수 있습니다.
      //getNextPageParam : undefined가 아닌 다른값을 반환 할 경우 hasNextPage : true
      //마지막 페이지가 아닐경우 pageParam += 1
      //fetchNextPage : 다음 페이지의 데이터를 호출할 때 사용 useInfiniteQuery의 return에 포함됨
      //fetchNextPage를 이용해 호출된 데이터는 배열의 가장 우측에 새롭게 담겨 전달됨
      getNextPageParam: ({ data: { isLastPage, pageNumber } }) => (isLastPage ? undefined : pageNumber + 1),
    }
  );
};

  1. src/hooks/useIntersect.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { useCallback, useEffect, useRef } from "react";

// [코드 11] IntersectionObserver custom hook
export type IntersectHandler = (
  entry: IntersectionObserverEntry,
  observer: IntersectionObserver
) => void;

export const useIntersect = (
  onIntersect: IntersectHandler,
  options?: IntersectionObserverInit
) => {
  console.log("useIntersect");
  const ref = useRef < HTMLDivElement > null;
  const callback = useCallback(
    //entries : IntersectionObserverEntry 인스턴스의 배열
    (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
      entries.forEach((entry) => {
        //isIntersecting : 대상 요소가 교차 관찰자의 루트와 교차하는 경우
        //여기서는 대상 요소가 target 컴포넌트와 교차하는 경우 onIntersect함수 호출
        //onIntersect => 값에따라 데이터 들고옴
        if (entry.isIntersecting) onIntersect(entry, observer);
      });
    },
    [onIntersect]
  );

  useEffect(() => {
    //ref = UserPage2에 맨밑의 target 컴포넌트
    if (!ref.current) return;
    //observer 선언
    const observer = new IntersectionObserver(callback, options);
    //target 컴포넌트에 관찰자 등록
    observer.observe(ref.current);
    return () => observer.disconnect();
  }, [ref, options, callback]);

  return ref;
};
  1. src/components/UserPage2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import { useMemo } from "react";
import styled from "styled-components";
import { useFetchUsers } from "../hooks/useFetchUsers";
import { useIntersect } from "../hooks/useIntersect";
import Loading from "./common/Loading";
//1번 방법에서 export 하여 들고옴
import { Card, CARD_SIZE, Container, PAGE_SIZE } from "./UsersPage";

// [코드 13] IntersectionObserver 적용한 최종 예시
export function UsersPage2() {
  const { data, hasNextPage, isFetching, fetchNextPage } = useFetchUsers({
    size: PAGE_SIZE,
  });

  //flatMap : 각 엘리먼트에 대해 map 수행 후, 결과를 새로운 배열로 평탄화함
  const users = useMemo(
    () => (data ? data.pages.flatMap(({ data }) => data.contents) : []),
    [data]
  );

  const ref = useIntersect(
    //entry
    async (entry, observer) => {
      //관찰 중단
      observer.unobserve(entry.target);
      //다음페이지가 있고 isFetching : false 이면 데이터 들고옴
      if (hasNextPage && !isFetching) {
        fetchNextPage();
      }
    },
    //options
    //root : 기본값 null = 브라우저 뷰포트
    //rootMargin : root 요소의 마진값. 기본값은 0px.
    //threshold : 0.0 ~ 1.0 사이의 숫자들을 배열로 받는다. 이는 %로 치환되어, 해당 비율만큼 교차된 경우 콜백이 실행된다.
    { root: null, rootMargin: "0px", threshold: 1.0 }
  );

  return (
    <Container>
      {users.map((user) => (
        <Card key={user.id} size={CARD_SIZE}>
          {user.name}
        </Card>
      ))}
      {isFetching && <Loading />}
      <Target ref={ref} />
    </Container>
  );
}
const Target = styled.div`
  height: 1px;
`;
  1. UserPage2 컴포넌트를 사용하는곳에 내용 추가
1
2
3
4
5
6
7
8
import { QueryClient, QueryClientProvider, useQuery } from 'react-query';

const queryClient = new QueryClient();
...

  <QueryClientProvider client={queryClient}>
    <UsersPage2 />
  </QueryClientProvider>

ezgif com-gif-maker (2)

✏️ 참고

카카오 엔터프라이즈 테크사이트

https://tech.kakaoenterprise.com/149

예제

https://github.com/moonjh9392/infiniteScroll

This post is licensed under CC BY 4.0 by the author.