// 무한 스크롤 코드
import { useEffect, useState, useCallback } from 'react'
import axios from 'axios'
const CARD_SIZE = 100
const PAGE_SIZE = 10 * Math.ceil(visualViewport.width / CARD_SIZE)
export default function UsersPage() {
const [page, setPage] = useState(1)
const [users, setUsers] = useState([])
const [isFetching, setIsFetching] = useState(false)
const [hasNextPage, setNextPage] = useState(true)
const fetchUsers = useCallback(async () => {
const { data } = await axios.get('/api/users', {
params: { page, size: PAGE_SIZE },
})
setUsers( prevUsers => [...prevUsers, ...data.contents])
setPage(data.pageNumber + 1)
setNextPage(!data.isLastPage)
setIsFetching(false)
}, [page])
useEffect(() => {
const handleScroll = () => {
const { scrollTop, offsetHeight } = document.documentElement
if (window.innerHeight + scrollTop >= offsetHeight) {
setIsFetching(true)
}
}
setIsFetching(true)
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
useEffect(() => {
if (isFetching && hasNextPage) fetchUsers()
else if (!hasNextPage) setIsFetching(false)
}, [isFetching])
return (
<Card>
{users.map((user, index) => (
<div key={user.id} name={user.name} >카드</div>
))}
{isFetching && <div> 로딩중</div>}
</Card>
)
}
// mocking api code
import { http, HttpResponse } from 'msw'
import { setupWorker } from 'msw/browser'
const users = Array.from(Array(1024).keys()).map(
(id) => ({
id,
name: `denis${id}`,
})
)
const handlers = [
http.get('/api/users', async ({ request }) => {
const { searchParams } = new URL(request.url)
const size = Number(searchParams.get('size'))
const page = Number(searchParams.get('page'))
const totalCount = users.length
const totalPages = Math.round(totalCount / size)
return HttpResponse.json(
{
contents: users.slice(page * size, (page + 1) * size),
pageNumber: page,
pageSize: size,
totalPages,
totalCount,
isLastPage: totalPages <= page,
isFirstPage: page === 0,
}
)
}),
]
export const worker = setupWorker(...handlers)
① const PAGE_SIZE = 10 * Math.ceil(visualViewport.width / CARD_SIZE)
② const [page, setPage] = useState(0)
const [users, setUsers] = useState([])
const [isFetching, setIsFetching] = useState(false)
const [hasNextPage, setNextPage] = useState(true)
const fetchUsers = useCallback(async () => {
const { data } = await axios.get('/api/users', {
params: { page, size: PAGE_SIZE },
})
③ setUsers( prevUsers => [...prevUsers, ...data.contents])
④ setPage(data.pageNumber + 1)
⑤ setNextPage(!data.isLastPage)
⑥ setIsFetching(false)
}, [page])
① 페이지당 표시할 사용자 카드 수를 나타내는 값을 지정
② page 값이 0에서 시작하는 이유는 데이터를 가져올 때 slice 메서드를 통해 0~1에 해당하는 인덱스 값을 가져오기 때문
③ 데이터를 패칭, 쿼리 값으로 page와 PAGE_SIZE를 넘겨 요청. 결과 값을 data에 담음
④ 기존의 users 값과 가져온 data 값을 통합
⑤ page 값을 증가시켜 데이터 패칭 기준점을 새로 잡음
⑥ 마지막 페이지에 대한 boolean 값을 받아서 다음 페이지가 있는지 여부를 state로 저장
⑦ 패칭이 완료되었음을 state 값에 저장
※ visualViewport
-viewport : 현재 사용자의 디스플레이 장치에 표시되는 영역을 visualViewport라고 한다.
-사용자가 화면을 확대하거나 축소하는 등의 동작을 할 경우 viewport의 값은 유동적으로 변하게 된다.
Q. PAGE_SIZE 가 필요한 이유?
첫 페칭 데이터 사이즈가 충분하지 않으면 스크롤바가 노출되지 않아 스크롤 이벤트가 발생할 수 없게 된다.
이때 충분한 데이터 사이즈를 페칭하거나 스크롤바가 노출될 때까지 연속 페칭하는 방법이 있는데 화면 크기에 비례해서 충분한 데이터 사이즈를 페칭할 수 있도록 PAGE_SIZE를 만든 것이다.
Q. 무한 스크롤에서 page 값이 필요한 이유?
-page값을 기준으로 데이터 패칭을 하기 위해서 값이 필요.
-페이지 값을 기준점으로 데이터 패칭이 필요한 때를 감지할 수 있다.
useEffect(() => {
③ const handleScroll = () => {
const { scrollTop, offsetHeight } = document.documentElement
if (window.innerHeight + scrollTop >= offsetHeight) {
setIsFetching(true)
}
}
② setIsFetching(true)
④ window.addEventListener('scroll', handleScroll)
⑤ return () => window.removeEventListener('scroll', handleScroll)
}, ① [])
① 컴포넌트가 마운트 되면서 코드를 실행
② 마운트 된 순간 fetching 상태 값을 true로 변경
③ scrollTop 값과 offsetHeight 값을 가져와 변수에 등록, innerHeight 값과 scrollTop 값의 합이 offsetHeight 값보다 클 경우에 fetching 상태를 true로 지정
④ handleScroll을 스크롤 이벤트에 등록
⑤ 컴포넌트가 언마운트될 때 이벤트를 삭제
※ scrollTop
: 스크롤 가능한 요소의 상단에서 스크롤된 거리를 나타내는 속성. 최상단에서 0, 아래로 내릴 수록 값 증가.
※ offsetHeight
: 테두리, 패딩, 가로 스크롤바(렌더링된 경우), 테두리를 포함한 요소의 css 전체 높이를 픽셀 단위로 측정한 값. (참고 이미지 : mdn)
※ window.innerHeight
: 브라우저 창에 보여지는 내용의 높이. 존재한다면 수평 스크롤 막대 높이는 포함하되 수직 스크롤바는 포함되지 않음.(참고 이미지 : mdn)
Q. useEffect로 이벤트를 등록하는 이유?
-useEffect에서 빈 배열을 참조 값으로 넘겨 컴포넌트가 마운트 될 때 이벤트를 등록하고 컴포넌트가 언마운트(소멸) 될 때 관련 리소스를 해제하고 메모리 누수를 방지할 수 있다.
-만약 useEffect가 아닌 채로 등록을 하게 될 경우 위의 이점을 누릴 수 없다.
useEffect(() => {
② if (isFetching && hasNextPage) fetchUsers()
③ else if (!hasNextPage) setIsFetching(false)
}, ① [isFetching])
① isFetching 상태 값의 변화에 따라 코드 실행
② isFetching과 hasNextPage 값이 모두 참일 경우 데이터 패칭
③ 다음 페이지가 없을 경우 fetching 상태 값을 false로 변경 따라서 데이터 패치 조건을 만족하는지 한 번 더 실행되고 해당되지 않으므로 데이터 패칭이 일어나지 않음.
const handlers = [
http.get('/api/users', async ({ request }) => {
① const { searchParams } = new URL(request.url)
② const size = Number(searchParams.get('size'))
③ const page = Number(searchParams.get('page'))
④ const totalCount = users.length
⑤ const totalPages = Math.round(totalCount / size)
return HttpResponse.json(
{
⑥ contents: users.slice(page * size, (page + 1) * size),
pageNumber: page,
pageSize: size,
totalPages,
totalCount,
isLastPage: totalPages <= page,
isFirstPage: page === 0,
}
)
}),
]
① 요청 url에서 query 값을 받아 size와 page에 지정
② size = 프론트의 PAGE_SIZE 값
③ page = 프론트의 page 값
④ totalCount = 저장된 데이터의 길이
⑤ totalPages = 저장된 데이터의 길이를 PAGE_SIZE 값으로 나눠 총 페이지 수 지정(page의 limit)
⑥ contents : 저장된 데이터를 (page x size) 인 값부터 (next page x size)인 값 까지를 잘라서 전송. 따라서 기존에 보냈던 배열 값만큼을 제외하고 다음 차례인 데이터 값들을 보냄.
스크롤에 이벤트를 묶어두었으므로 매번 스크롤을 움직일 때 마다 스크롤 이벤트 핸들러가 호출되게 된다. 게다가 documentElement.scrollTop과 documentElement.offsetHeight는 리플로우(Reflow)가 발생하는 참조이므로 이벤트 호출량을 줄일 수 있는 방법이 필요하다.
이에 대해서는 디바운스와 쓰로틀이 있는데 내용은 아래의 링크에 정리해 두었다.
둘 중 적절한 기술은 쓰로틀이므로 쓰로틀을 채택하였다.