무한 스크롤... 사실은 피하고 싶었다🥹... 우리 회사에서 필요한 UI는 인스타그램 피드 처럼 쭉 쭉 내리면서 보는 형태도 아니고... Picker Menu의 아이템 리스트를 무한 스크롤로 보여달라는 요구사항이다.
Next, useSWRInfinite, Intersection Observer 전부 처음 사용해봐서 요상하게 구현되었을 가능성이 있습니다. 피드백은 환영입니다 😉
(밑 두 개는 Rsuite CheckPicker 컴포넌트에 관한 거라 나누었어요)
좋아 구현해보자!
이 프로젝트는 Next 기반 어플리케이션이라 자연스럽게 useSWR 을 사용하고 있다. useSWRInfinite는 서버 요청을 통해 데이터를 가져오는데 페이지네이션이 필요할 때 쓴다.
const getKey = (pageIndex: number, previousPageData: any[] | null) => {
// if (previousPageData && !previousPageData.length) return null; // 끝에 도달
return `/api/v1/brands?page=${
pageIndex + 1
}&size=100&search=${searchValue}`; // SWR 키
};
const { data: brands, size, setSize } = useSWRInfinite(getKey, infiniteFetcher, {
revalidateOnFocus: false,
revalidateFirstPage: false,
});
data: 서버에서 가져온 data 리스트
size: 서버에서 몇 페이지까지 가져온 상태인 지 알려줌, 디폴트 size는 1, size가 2 라면 2페이지 까지 들고왔다는 뜻!
setSize: size를 변경할 때 사용, 특정 엘리먼트가 화면에 보이면 setSize(size => size + 1)
이렇게 써서 다음 페이지를 불러올 것
data(brands) 는 가져온 데이터의 배열로 이루어져있다.
처음 페이지가 로딩될 때 1 페이지 데이터 100개가 들어있고
size 2가 되면 이렇게 보일 것이다.
100개짜리 데이터 배열 2개 묶음...!
첫 100개는 1번~100번 데이터, 두 번째 100개는 101번~200번 데이터가 있다.
getKey: api call 의 url 을 만든다고 생각하면 된다. getKey 함수에서 pageIndex
는 당연히 0부터 시작하는데, 우리 회사 api는 page가 1부터 시작하도록 설계 되어있어 나는 pageIndex + 1
을 넣어주었다. previousPageData
는 null 로 시작, 두 번째 페이지를 불러올 땐 그 전에 불러왔던 데이터 100개가 들어있다.
보통 useSWRInfinite 만 사용하면 if (previousPageData && !previousPageData.length) return null
로 마지막 페이지인지 구분한다는데 이 구문을 주석 처리한 이유는 내 경우 IntersectionObserver 사용 측에서 마지막 페이지까지 불러왔으면 size를 올려주지 않도록 구현해놓아서 필요 없었다.
infiniteFetcher
이건 서버 response 인터페이스에 따라 조금씩 달라질거다!
나의 경우 아래처럼 정의해줬다.
export const infiniteFetcher = (url: string) =>
Axios.create(config)
.get(`${url}`)
.then((res) => res.data.data);
options: 공식문서 를 참고 하길!
revalidateOnFocus:false
는 브라우저 포커스 마다 다시 불러오는 걸 막는 옵션, revalidateFirstPage:false
는 첫 페이지를 항상 다시 불러오는 걸 막는 옵션이다. brand 리스트를 가져오는 게 그리 자주 불러오지 않아도 되어서 설정해주었다. 아마 운영하면서 바뀔수도 있다.
리턴 값이나 파라미터 옵션은 더 많고 다양하므로 원하는 상황에 맞게 바꿔서 써야한다
전에는 무한스크롤 구현할 때 scroll event 를 캐치해서 target element 와 viewport 위치를 계산해서 어쩌고 저쩌고 reflow도 많이 일어나고... 그걸 막기 위해서 throttle을 쓰고 뭐 그랬는데 IntersectionObserver 로 그런 문제 없이 간단히 구현할 수 있었다.
const io = new IntersectionObserver((entries, observer) => {
entries.forEach((entry) => {
if (entry.intersectionRatio > 0) {
// 관찰 대상이(targetElement) viewport 안에 들어온 경우
}
});
}, {
root: rootElement, threshold: 1.0
})
io.observe(targetElement)
간단히 적어보면 위와 같다. 중요한 포인트는
rootElement: viewport가 될 대상, 안 적으면 window 가 적용된다. 내 경우는 Picker 의 Menu 역할을 하는 div
targetElement: io가 관찰 하는 대상, 내 경우는 MenuItem 중에 뒤에서 10번째 정도를 설정했다. 즉 처음 100개를 불러왔으면 90번째 즈음의 아이템을 targetElement로 설정하고 그걸 지나면 다음 100개를 불러올 수 있도록 했다. 200 개를 불러왔으면 190번째 아이템으로 변경하고... 끝까지 반복
다른 옵션들은 공식문서를 참고해보세요
io.observe(targetElement)
꼭 이렇게 등록해줘야 제대로 동작한다. 필요 없어지면 io.unobserve(targetElement)
로 해제도 해줘야 한다.
그럼 내 코드
const io = useMemo(
() =>
new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
// 관찰 대상이 viewport 안에 들어온 경우
if (entry.intersectionRatio > 0) {
setSize((size) => size + 1);
if (targetElement) io.unobserve(targetElement);
}
});
},
{ root: document.querySelector(rootElementSelector), threshold: 1 }
),
[rootElementSelector, setSize, targetElement]
);
useEffect(() => {
if (targetElement) {
io.observe(targetElement);
}
}, [io, targetElement]);
위에서 적은 코드에서 아래 구문이 추가
되었다.
if (entry.intersectionRatio > 0) {
setSize((size) => size + 1);
if (targetElement) io.unobserve(targetElement);
}
targetElement
가 화면에 노출되었으면 size + 1
해서 다음 페이지 데이터를 가져오고,
현재 targetElement
는 이제 관찰대상이 되지 못하니 unobserve
를 해준다.
root: document.querySelector(rootElementSelector)
:
root를 props.rootElement
로 등록하지 않고 쿼리셀렉터로 바로 바로 등록하는 이유는 개발을 하다보니 Rsuite CheckPicker 가 search를 하거나 해서 데이터가 바뀌었을때 MenuElement 를 새로 그리는 경우가 있다.(그대로 일 때도 있다😧 영문을 모르겠다.) 그럼 지금 보고있는 메뉴가 처음 등록했던 props.rootElement
인지 아닌지 확실치 않기 때문에 new IntersectionObserver()
를 콜 할 때마다 등록하도록 구현했다.
그렇담 새 targetElement
를 등록하는 부분은?
useEffect(() => {
if (brands?.length === size) {
const items: NodeListOf<HTMLDivElement> | undefined =
document.querySelectorAll(targetElementSelector);
const lastNth: HTMLDivElement | undefined =
items && items?.length > 10 ? items?.[items?.length - 10] : undefined;
if (lastNth) setTargetElement(lastNth);
}
}, [brands?.length, size, rootElement, targetElementSelector]);
brands?.length === size
size = 1
, brands.length = 0
처럼 size는 +1 됐고, fetching 중이라 brands.length 가 하나 적을 때가 있어 조건을 걸어주었다.
document.querySelectorAll
로 현재 MenuItem 리스트를 불러왔고,
items && items?.length > 10 ? items?.[items?.length - 10] : undefined
MenuItem이 10개 이상 일때 targetElement로 MenuItem 중 뒤에서 10번째 Element를 넣어줬다.(물론 20개 이상일 때 10번째 Element이다. 그래서 lastNth 로 변수명을 정했다)
오 이제 핵심 기능은 다 됐다! 좀 더 코드를 정리해야 겠지만 전체적으로 보면 이렇게 된다.
useBrandsForPicker.ts
import useSWRInfinite from "swr/infinite";
import { infiniteFetcher } from "../api";
import { useEffect, useMemo, useState } from "react";
export default function useBrandsForPicker({
rootElement,
rootElementSelector,
targetElementSelector,
searchValue,
}: {
rootElement: HTMLElement | undefined | null;
rootElementSelector: string;
targetElementSelector: string;
searchValue: string;
}) {
const [targetElement, setTargetElement] = useState<
HTMLElement | undefined | null
>(null);
const getKey = (pageIndex: number) => {
return `/api/v1/brands?page=${
pageIndex + 1
}&size=100&search=${searchValue}`; // SWR 키
};
const {
data: brands,
size,
setSize,
} = useSWRInfinite(getKey, infiniteFetcher, {
revalidateOnFocus: false,
revalidateFirstPage: false,
});
const io = useMemo(
() =>
new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
// 관찰 대상이 viewport 안에 들어온 경우
if (entry.intersectionRatio > 0) {
setSize((size) => size + 1);
if (targetElement) io.unobserve(targetElement);
}
});
},
{ root: document.querySelector(rootElementSelector), threshold: 1 }
),
[rootElementSelector, setSize, targetElement]
);
useEffect(() => {
if (targetElement) {
io.observe(targetElement);
}
}, [io, targetElement]);
useEffect(() => {
if (brands?.length === size) {
const items: NodeListOf<HTMLDivElement> | undefined =
document.querySelectorAll(targetElementSelector);
const lastNth: HTMLDivElement | undefined =
items && items?.length > 10 ? items?.[items?.length - 10] : undefined;
if (lastNth) setTargetElement(lastNth);
}
}, [brands?.length, size, rootElement, targetElementSelector]);
// [[Array(100)], [Array(100)], [Array(100)], ...] 형태인 brands 를 pickerData가 필요한 형태로 변경했다.
const brandsData =
brands?.flatMap((brandArr: { id: number; name: string }[]) => {
return brandArr.map((b) => ({ label: b.name, value: b.id }));
}) || [];
return {
data: brandsData,
};
}
위 코드를 사용하는 곳
const Index = () => {
const [menuElement, setMenuElement] = useState<
HTMLElement | undefined | null
>(null);
const [searchValue, setSearchValue] = useState("");
const [brandsValue, setBrandsValue] = useState<(number | string)[]>([]);
const { data: brandsData } = useBrandsForPicker({
rootElement: menuElement,
searchValue: searchValue,
rootElementSelector: "brands-menu > .rs-picker-check-menu-items",
targetElementSelector: `.brands-menu > .rs-picker-check-menu-items > div[role="option"]`,
});
const disabledItemValues =
brandsValue.length === 3
? brandsData
.filter((b) => !brandsValue.includes(b.value))
.map((b) => b.value)
: [];
return (
<>
<CheckPicker
style={{ width: 300, padding: 10 }}
placeholder={"브랜드/상품명 선택"}
data={brandsData}
renderValue={(value: any[], items: ItemDataType[]) => {
return (
<div>
{value.length === 1 && items[0]?.label}
{value.length > 1 &&
`${items[0]?.label} 외 ${value.length - 1}개`}
</div>
);
}}
onOpen={() => {
setMenuElement(
document.querySelector(
".brands-menu > .rs-picker-check-menu "
) as HTMLDivElement
);
}}
menuClassName={"brands-menu"}
disabledItemValues={disabledItemValues}
value={brandsValue}
onChange={setBrandsValue}
onSearch={setSearchValue}
onClose={() => setSearchValue("")}
/>
</>
);
};
export default Index;
disabledItemValues : brandsValue.length가 3이 되면 현재 선택하고 있는 value 값 외 다른 아이템의 value는 disabled 되도록 설정했다.
onOpen: CheckPicker는 맨 처음 렌더될 때 Menu(div) 가 없다. Picker를 열어야만 해당 Element가 생기기때문에 onOpen
콜백에서 rootElement 를 세팅해줬다.
onSearch: onSearch
콜백에서 searchValue
state 를 세팅하고 이를 useBrandsForPicker
에 넘겨주어 useSWRInfinite
의 key 값에 적용되도록 했다.
잘 된다~~👏
useBrandsForPicker
로 한 번에 묶었는데, 무한스크롤을 하는 부분(Intersection Observer)과 데이터를 가져오는 부분(useSWRInfinite) 을 나눠서 리팩토링할 수 있을 것 같다.useBrandsForPicker
에서 document.querySelector(rootElementSelector)
로 rootElement
를 찾을 수 없기 때문에 처음 렌더될 때만을 위하여 props.rootElement
를 넘겨 주었는데 props.rootElementSelector
도 넘기고 props.rootElement
도 넘기는 게 뭔가 마음에 들지 않는다. 어떻게 고치면 좋을까?안 읽어도 되는 사족
참고
https://swr.vercel.app/ko/docs/pagination
https://heropy.blog/2019/10/27/intersection-observer/
https://rsuitejs.com/components/check-picker/
https://intrepidgeeks.com/tutorial/use-intersection-observer-and-usswrinfinite-to-realize-unlimited-scrolling-steve-development-log-6