본문 바로가기
Nuxtjs

[Nuxt.js] 무한 스크롤 구현한 사례

by devebucks 2022. 6. 1.
728x90

개요

Nuxt.js에서 무한 스크롤을 구현한 방법을 소개한다.

무한 스크롤 구현에 사용한 기술은 Javascript에 new IntersectionObserver 이다.

이 객체는 html element를 지정할 수 있으며, viewport에 얼만큼 노출되느냐를 옵션으로 설정해서 사용자가 지정한 함수를 호출할 수 있게 해준다.

 

컴포넌트 구조

components
  +- List.vue   👈 목록 데이터를 패치해서 store에 저장함
  +- ListItem.vue 👈 observe 객체가 있음. 함수가 첫 호출됨
pages
  +- index.vue   👈 첫 번째 페이지 목록 데이터를 패치받음

 

pages/index.vue

첫번째 페이지네이션 page=1페이지 목록 데이터를 받아온다.

template

<template>
  <!-- 피드백이 있는 경우 -->
  <list v-if="hasFeedback" />
  <!-- 피드백 목록 없는 경우 메시지 표시 -->
  <div v-if="hasError" class="no-list font-777"><!-- ... --></div>
  <!--  0 건인 경우 -->
  <div v-if="!hasFeedback" class="no-list font-777"><!-- ... --></div>
  <!-- 로딩 스피너 -->
  <progress-spinner v-if="isLoading" :size="40" class="spinner" />
</template>

script

import List from '@/components/.../List';
export default {
  name: 'somethingPage',
  components: {
    List,
  },
  computed: {
    hasFeedback() {
      return (
        this.$store.state.list?.List &&
        this.$store.state.list.List?.length > 0
      );
    },
  },
  // 첫 번째 페이지 목록을 서버로부터 패치받는다.
  created() {
    this.fetchList();
  },
  // 만약, 목록의 상세 페이지를 갔다가 목록 페이지로 돌아온 경우, 사용자가 보던 스크롤 위치를 그대로 유지해서 보여준다.
  mounted() {
    setTimeout(() => {
      window.scrollTo(0, this.$store.state.list.scrollHeight || 0);
    });
  },
  methods: {
    ...mapActions('list', ['getPageList']),
    async fetchList() {
      try {
        // 이미 리스트를 가지고 있는 상태라면, 패치받지 않음.
        if (this.hasList) return;
        const response = await this.getPageList({
          pageNumber: 1,
          limit: 1,
        });
        if (response.statusCode !== 200) this.hasError = true;
        this.List = response.data;
        this.isLoading = false;
        // throw new Error('에러');
      } catch (err) {
        console.error(`ERROR [${err.name}] : ${err.message}`);
        this.hasError = true;
        this.isLoading = false;
      }
    },
  },
};

 

components/List.vue

ListItem 컴포넌트를 호출해서 렌더링하는 목록 컴포넌트이다. 목록 요소(ListItem.vue) 컴포넌트로부터 emit을 통해 호출되는 메서드를 가지는 컴포넌트이다. ListItem 컴포넌트에서 보여주고 있는 observe요소가 화면에 노출되는 순간 fetchList 메서드가 실행되서 다음 페이지의 목록 데이터를 패치한다.

 

template

<template>
  <ul>
    <list-item
      v-for="(info, i) in $store.state.list.feedbackList"
      :key="info.id"
      :page-number="i + 1"
      :info="info"
      @fetch-list="fetchList"
    />
  </ul>
</template>

script

export default {
  name: "List",
  methods: {
    async fetchList(pageIndex, isNormal = true) {
      this.SET_FETCH_PAGE_NUMBER(pageIndex);
      // 페이지네이션 데이터 패치
      await this.getPageList({
        pageNumber: this.$store.state.list.fetchedPageNumber + 1,
        limit: 1,
      });
    }  
  }
}

 

components/ListItem.vue

이 컴포넌트가 목록의 요소 1개이며, 이 컴포넌트의 mounted 시점에 intersectionObserve 객체를 사용해서 목록 데이터 패치 함수를 트리거 한다. 트리거가 실행된 후에는 intersectionObserve에서 제거(unobserve)한다. 그래야, 스크롤을 위로 올려서 viewport에 다시 관찰 대상의 엘리먼트가 보여져도 트리거가 실행되서 같은 데이터를 새로 패치 받는 참사가 일어나지 않는다.

 

isIntersecting 속성이 true인지도 조건으로 확인하고 함수를 실행시켜야 한다. 이것 역시, 함수가 중복 실행되는 원인이 되기 때문이다.

 

observe된 엘리먼트가 브라우저(viewport)에 노출되는 순간 개발자가 의도한 함수는 호출된다. 나같은 경우, emit으로 상위 컴포넌트(List.vue)로 다음 페이지 목록 데이터를 호출하는 함수를 호출하도록 하였다. 

 

template

<template>
  <li>
    <nuxt-link> ... </nuxt-link>
    <!-- 패치 트리거 엘리먼트 -->
    <div :ref="`triggerDiv${pageNumber}`"></div>
  </li>
</template>

script

export default {
  name: 'ListItem',
  props: {
    pageNumber: {
      type: Number,
      default: -1,
    },
  },
  data() {
    return {
      iob: null,
    }
  },
  /**
   * @description 무한 스크롤 기능 실행 20개 중에서 10번째 리스트 아이템에서 트리거
  */
  mounted() {
    this.iob = new IntersectionObserver(entires => {
      // 이미 패치받아진 데이터에 대해서는 패치 트리거를 실행하지 않는다.
      if (
        entires[0].isIntersecting &&
        this.$store.state.List.fetchedPageNumber < this.pageNumber
      ) {
        this.$emit('fetch-list', this.pageNumber);
        // 패치 후, 관찰 대상에서 제외
        this.iob.unobserve(this.$refs[`triggerDiv${this.pageNumber}`]);
      }
    }, {
        root: null,
        rootMargin: '0px',
        threshold: 1.0,
      },);
    this.$refs[`triggerDiv${this.pageNumber}`] &&
      this.iob.observe(this.$refs[`triggerDiv${this.pageNumber}`]);
  },
}

 

728x90

댓글