Infinite Scroll
은 페이지 하단에 도달할 때마다 새로운 컨텐츠를 계속해서 로드하는 방법입니다
이를 위해 스크롤이 페이지 하단에 도달했음을 감지해서 데이터를 불러오는 로직이 필요합니다
이런 로직이 여러 페이지에서 반복되지 않도록 하기 위해 composable
로 분리한 내용을 공유합니다
vue3
+ typescript
로 작성했지만, 원활한 이해를 돕기 위해 초반 설명은 typescript
없이 진행합니다
테스트용 API 주소는 JSONPlaceholder 를 활용했습니다
컨테이너와 주소를 파라미터로 전달하면,
컨테이너 하단에 도달했을 때 자동으로 업데이트 되는 리스트를 반환해야 합니다
const { list } = useScrollLoad(scrollContainer, url);
API 사양에 따라 페이징 방법이 다를 수 있으므로 주소는 함수의 반환값으로 전달하도록 하겠습니다
JSONPlaceholder를 활용한 주소는 다음과 같습니다
const { list } = useScrollLoad(scrollContainer, (start, size) => {
return `https://jsonplaceholder.typicode.com/todos?_start=${start}&_limit=${size}`;
});
데이터를 로드하는 함수를 마운트되었을 때 한번 호출합니다
// @/composilbes/scrollLoad.js
import { onMounted, onUnmounted, ref, unref } from "vue";
export const useScrollLoad = (scrollContainer, url) => {
const size = 10;
const start = ref(0);
const list = ref([]);
const loadMore = () => {
start.value = start.value + size;
load();
};
const load = () => {
fetchData();
};
const fetchData = () => {
fetch(url(start.value, size))
.then((res) => res.json())
.then((res) => {
list.value.push(...res);
});
};
onMounted(() => {
load();
});
}
스크롤이 하단에 도달하는 이벤트를 감지하기 위해 이벤트 리스너를 추가합니다
// 컨테이너가 ref인 것으로 가정합니다
// 뒤에서 ref가 아닌 경우를 다루겠습니다
onMounted(() => {
load();
scrollContainer.value.addEventListener("scroll", handleScrollEvent);
});
onUnmounted(() => {
scrollContainer.value.removeEventListener("scroll", handleScrollEvent);
});
const handleScrollEvent = (e) => {
const bottom = getScrollRest(e.target);
if (bottom === 0) {
loadMore();
}
};
// 스크롤 영역 최하단까지의 남은 거리를 구합니다
const getScrollRest = (element) => {
const clientHeight = element.clientHeight;
const scrollHeight = element.scrollHeight;
const scrollTop = element.scrollTop;
return scrollHeight - scrollTop - clientHeight;
};
<!-- @/components/ScrollBox.vue -->
<template>
<div class="container" ref="scrollContainer">
<div v-for="(item, idx) in list" :key="idx">
<p>{{ item.title }}</p>
</div>
</div>
</template>
<script setup>
import { useScrollLoad } from "@/composibles/scrollLoad";
import { ref } from "vue";
const scrollContainer = ref(null);
const { list } = useScrollLoad(scrollContainer, (start, size) => {
return `https://jsonplaceholder.typicode.com/todos?_start=${start}&_limit=${size}`;
});
</script>
여기까지 완성된 코드는 GITHUB에서 확인할 수 있습니다
스크롤이 적용되는 컨테이너가 항상 ref
로 주어지진 않을 것 같습니다
window
로 한번 적용해보겠습니다
const { list } = useScrollLoad(window, (start, size) => {
return `https://jsonplaceholder.typicode.com/todos?_start=${start}&_limit=${size}`;
});
useScrollLoad
로 전달되는 scrollContainer
는 더이상 ref
가 아니므로 다음과 같이 수정할 수 있습니다
onMounted(() => {
scrollContainer.addEventListener("scroll", handleScrollEvent);
});
하지만 ref
로 전달되는 경우도 배제할 수 없습니다
다시 수정합니다
onMounted(() => {
const el = unref(scrollContainer);
if (el) {
load();
el.addEventListener("scroll", handleScrollEvent);
}
});
onUnmounted(() => {
const el = unref(scrollContainer);
if (el) {
el.removeEventListener("scroll", handleScrollEvent);
}
});
handleScrollEvent
에서 event.target
을 잘 찾지 못합니다
다음과 같이 수정합니다
const handleScrollEvent = (e) => {
let el = e.target;
if (el.documentElement) {
el = el.documentElement;
}
const bottom = getScrollRest(el);
if (bottom === 0) {
loadMore();
}
};
여기까지 완성된 코드는 GITHUB에서 확인할 수 있습니다
설명을 위해 빼두었던 Typescript를 다시 적용해보겠습니다
...
import type { Ref } from "vue";
type ContainerType = HTMLElement | Window | Document | null;
export const useScrollLoad = (
scrollContainer: ContainerType | Ref<ContainerType>,
url: (start: number, size: number) => string
) => {
...
const list: Ref<any[]> = ref([]); // `any`가 조금 거슬립니다 뒤에서 수정하도록 하겠습니다
...
const getScrollRest = (element: HTMLElement) => {
...
}
...
}
아래에서는 오류가 발생합니다
HTMLElement
는 documentElement
를 프로퍼티로 가지지 않습니다
const handleScrollEvent = (e: Event) => {
const element = e.target as HTMLElement;
if (element.documentElement) { // ts Error!!
element = element.documentElement; // ts Error!!
}
const bottom = getScrollRest(element);
if (bottom === 0) {
loadMore();
}
};
다음처럼 변경합니다
const handleScrollEvent = (e: Event) => {
let element = e.target as HTMLElement;
if ((e.target as Document).documentElement) {
element = (e.target as Document).documentElement as HTMLElement;
}
const bottom = getScrollRest(element);
if (bottom === 0) {
loadMore();
}
};
이제 다시 정상적으로 동작합니다!
그럼 이제 위에서 신경쓰였던 any
를 수정해 보겠습니다
// @/composilbes/scrollLoad.ts
export const useScrollLoad = <ListItem>(
scrollContainer: ContainerType | Ref<ContainerType>,
url: (start: number, size: number) => string
) => {
...
const list: Ref<ListItem[]> = ref([]);
...
}
// @/components/ScrollBox.vue
type ListItem = {
id: number;
userId: number;
title: string;
completed: boolean;
};
const { list } = useScrollLoad<ListItem>(window, (start, size) => {
return `https://jsonplaceholder.typicode.com/todos?_start=${start}&_limit=${size}`;
});
여기까지 완성된 코드는 GITHUB에서 확인할 수 있습니다
스크롤이 바닥에 닿기를 기다렸다가 데이터를 불러오면
사용자는 데이터를 불러오는 시간을 기다려야 합니다
조금 이른 시점에 새로운 컨텐츠를 미리 로드할 수 있도록 수정합니다
const handleScrollEvent = (e: Event) => {
let element = e.target as HTMLElement;
if ((e.target as Document).documentElement) {
element = (e.target as Document).documentElement as HTMLElement;
}
const bottom = getScrollRest(element);
if (bottom < element.clientHeight * 2) { // 수정됨
loadMore();
}
};
그런데 이게 웬걸, 한번에 여러번의 api 호출이 발생합니다
새로운 컨텐츠가 추가되어서 스크롤 영역이 넓어지기도 전에 또다른 스크롤 이벤트가 발생하기 때문입니다
데이터를 가져오는 동안에는 새로운 데이터를 요청하지 않도록 합니다
const isLoading = ref(false);
const loadMore = () => {
if (isLoading.value) {
return;
}
...
}
const fetchData = () => {
isLoading.value = true;
fetch(url(start.value, size))
.then((res) => res.json())
.then((res) => {
list.value.push(...res);
})
.catch((err) => {
start.value = start.value - size;
})
.finally(() => {
isLoading.value = false;
});
};
handleScrollEvent
에서 로딩 상태를 체크할 수도 있을텐데 그러지 않은 이유는 무엇일까요
const handleScrollEvent = (e: Event) => {
//if (isLoading.value) {
// return;
//}
let element = e.target as HTMLElement;
if ((e.target as Document).documentElement) {
element = (e.target as Document).documentElement as HTMLElement;
}
const bottom = getScrollRest(element);
if (bottom < element.clientHeight * 2) {
loadMore();
}
};
handleScrollEvent
는 target
을 가져오고, 영역 하단까지의 거리를 계산하는 비용을 계속 지불하고 있습니다
여기서 로딩 상태를 체크한다고 해도 로딩중이 아닐 때의 비용은 줄일 수 없습니다
이 비용은 Throttle
을 이용해 줄이려고 합니다
Throttle
처리를 포함하여 몇가지 아이디어와 수정사항이 있지만, 글이 너무 길어져서 여기까지만 작성합니다
감사합니다
// @/components/ScrollBox.vue
const url = "https://jsonplaceholder.typicode.com/todos";
const params = ref({})
const getQueryString = (start: number, size: number) => {
const qeuryParams = {
...params,
_start: start,
_limit: size,
}
return '?' + Object.entries(qeuryParams).map(([key, value])=>{
return `${key}=${value}`
}).join('&');
}
const { list } = useScrollLoad<ListItem>(scrollContainer, url, getQueryString);
// @/composilbes/scrollLoad.ts
useEventListener(element, "scroll", handleScrollEvent);