📌 리액트 무한스크롤(infinite scroll) 구현하기
✏️ 구현 방법
- Scroll Event를 감지하고, 해당 이벤트 핸들러에서 스크롤값을 비교
- 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에서 데이터를 불러와 화면에 뿌려준다
이렇게 하면 위의 사진처럼 스크롤 이벤트가 과다하게 발생되는데
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);
}, []);
시연시간 500ms를 주어 스크롤 이벤트 발생 횟수가 많이 줄은것을 확인할 수 있다.
📌 2. Javascript에서 지원하는 Observer API를 활용
✏️ Observer API
Observer pattern은 객체 상태변화를 관찰하는 관찰자들(옵저버)을 등록하여 상태변화가 있을 때마다 각 옵저버에 알리는 패턴이다.
이 논리에 기반한 Javascript 내장 API가 Observer API 인 것이다.
- MutationObserver
DOM 변경을 감지하는 옵저버이다. 요소의 삽입, 수정, 삭제 및 자식요소가 수정되는 경우 등을 감지한다.
- ResizeObserver
타겟 요소가 리사이징 이벤트를 통해 크기가 변경될 때를 감지하는 옵저버다. 반응형으로 보이는데 유용하다.
- 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도 마찬가지로 커스텀 훅을 이용하여 만들어줌
- 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),
}
);
};
- 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;
};
- 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;
`;
- 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>
✏️ 참고
카카오 엔터프라이즈 테크사이트
https://tech.kakaoenterprise.com/149
예제