[vue3] useScrollLoad

빛트·2023년 4월 14일
0
post-thumbnail

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에서 확인할 수 있습니다


컨테이너가 window라면?

스크롤이 적용되는 컨테이너가 항상 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 적용

설명을 위해 빼두었던 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) => {
    ...
  }
  ...
}

아래에서는 오류가 발생합니다
HTMLElementdocumentElement를 프로퍼티로 가지지 않습니다

  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();
  }
};

handleScrollEventtarget을 가져오고, 영역 하단까지의 거리를 계산하는 비용을 계속 지불하고 있습니다

여기서 로딩 상태를 체크한다고 해도 로딩중이 아닐 때의 비용은 줄일 수 없습니다

이 비용은 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);

GITHUB

profile
https://kangbit.github.io/posts

0개의 댓글