NextJS - 무한스크롤 & 스크롤 위치 되돌리기 구현

yoon Y·2022년 10월 11일
0

요구사항

  • 스크롤이 페이지 하단에 도달하면 다음 상품을 이어서 보여줍니다.
  • 더 이상 가져올 데이터가 없는 경우 요청이 나가지 않아야 합니다.
  • 상품 상세 화면으로 이동했다가 다시 이전 페이지(/infinite-scroll)로 돌아오면 기존의 스크롤 위치로 되돌아와야합니다.

무한 스크롤 구현 방법

  • 상품 리스트 가장 하단에 빈 div태그를 만듭니다.
  • intersection observer기능을 이용해 빈 div태그를 target으로 설정합니다.
  • target요소가 화면에 보여질 때마다 currentPage에 해당하는 데이터를 불러와 기존 데이터(상태)에 누적시키고 상품 리스트를 렌더링합니다.
  • 다음 요청을 위해 currentPage에 1을 증가시킵니다.
  • next의 Image태그를 사용해 lazyLoding되도록했습니다.
// page/infinite-scroll.tsx 
const InfiniteScrollPage: NextPage = () => {
  const [products, setProducts] = useState<Product[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [isScrollable, setIsScrollable] = useState(false);
  const currentPage = useRef(1);

  const fetchProducts = async (page: number) => {
    setIsLoading(true);
    try {
      const { data } = await getProducts(page, PRODUCTS_LENGTH);
      const { products } = data.data;
      
      // 데이터 누적
      setProducts((prev) => [...prev, ...products]);
    } catch (error: any) {
      alert(error.message);
    }
    setIsLoading(false);
  };

  // useIntersect훅에 타겟 감지 시 실행해야할 콜백함수 전달
  const ref = useIntersect((entry, observer) => {
    // 데이터 요청 실패 시 연속 호출을 막기 위해 타겟 해제
    observer.unobserve(entry.target);
    
    // 불러올 데이터가 더 이상 없는지 체크
    const isLastPage = allProducts.length === products.length;
    
    if (!isLastPage && !isLoading) {
      fetchProducts(currentPage.current);
      // 다음 데이터를 부르기 위해 page숫자 증가
      currentPage.current++; 
    }
  });

  return (
    <>
      <Container>
        <ProductList products={products} onClick={handleClickProduct} />
        // 리스트 밑에 빈 태그를 두어 타겟으로 지정
        <div ref={ref}>{isLoading ? '로딩 중입니다' : ''}</div>
      </Container>
    </>
  );
};

동작 방식 및 useIntersect훅 설명

  • 타겟이 감지되면 데이터가 패칭되고 페이지가 리렌더링된다.
  • 페이지 내의 useIntersect도 다시 실행되고 콜백함수가 새로 만들어져 전달된다.
    • 콜백함수 내부에 상태가 사용된다.
    • 업데이트된 상태를 반영하기 위해서는 리렌더링마다 함수가 새로 만들어져야 한다.
  • 콜백이 새로 만들어졌기 때문에 IntersectionObserver생성자를 다시 실행하고 관찰자를 다시 등록한다.
    • 타겟 엘리먼트 자체는 변경되지 않는다.
// useIntersect.tsx

const useIntersect = (onIntersect: IntersectHandler, options?: IntersectionObserverInit) => {
  
  // 타겟을 담을 ref
  const ref = useRef<HTMLDivElement>(null); 
  
  // onIntersect가 변경될 때마다(새로 만들어질 때마다) 생성된다.
  const callback = useCallback(
    (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) onIntersect(entry, observer);
      });
    },
    [onIntersect]
  );

  useEffect(() => {
    if (!ref.current) return;
    const observer = new IntersectionObserver(callback, options);
    observer.observe(ref.current);
    return () => observer.disconnect();
  }, [options, callback]); // callback이 변경될 때마다 실행된다.

  return ref;
};

export default useIntersect;

타겟 감지 시에 실행될 콜백에 상태가 쓰여 리렌더링될 때마다 observer인스턴스를 새로 만드는 방법을 사용했지만 효율적인 방법은 아닌 것 같다. 다른 방법도 생각해봐야겠다.


스크롤 위치 되돌리기 구현 방법

nextjs router에 내장된 beforePopState와 sessionStorage를 활용합니다.
beforePopState는 뒤로가기를 이용한 페이지 이동 직전에 실행할 로직을 정의할 수 있습니다.

필요한 데이터와 로직 준비

상품페이지에서 이전페이지(무한스크롤 페이지)로 이동하기 직전에 세션 스토리지에 스크롤을 되돌린다는 정보를 저장해줍니다.

// pages/products/[id].tsx
  useEffect(() => {
  router.beforePopState(() => {
    sessionStorage.setItem('InfiniteScrollPage', 'restore');
    return true;
  });
}, []);

상품 클릭 시 현재의 페이지의 정보들을 세션 스토리지에 저장합니다.
스크롤 위치 뿐만 아니라 현재 불려진 데이터들과 다음 데이터를 부르기 위한 현재 페이지 숫자도 되돌려야하기 때문에 전부 저장해줍니다.

 // page/infinite-scroll.tsx 
  const handleClickProduct = () => {
    // 상품 클릭 시 현재의 데이터와 스크롤 위치를 저장
    sessionStorage.setItem('PRODUCTS', JSON.stringify(products)); 
    sessionStorage.setItem('SCROLL_HEIGHT', `${window.scrollY}`); 
    sessionStorage.setItem('CURRENT_PAGE', `${currentPage.current}`); 
  }; 

준비된 데이터로 되돌리기 적용

무한 스크롤 페이지에 들어왔을 때 이전 페이지가 상품 페이지일 경우 세션 스토리지에 저장된 데이터들로 상태 값들을 초기화합니다.

// page/infinite-scroll.tsx 
const setStoredData = () => {
  const savedProducts = sessionStorage.getItem('PRODUCTS');
  const savedCurrentPage = sessionStorage.getItem('CURRENT_PAGE');
  if (!savedProducts || !savedCurrentPage) return;
  setProducts(JSON.parse(savedProducts));
  currentPage.current = Number(savedCurrentPage);
};

// 이전 페이지가 상품 페이지일 경우 세션 스토리지에 저장된 데이터로 초기화
useEffect(() => {
  if (!checkShouldRestore()) return;
  sessionStorage.removeItem('InfiniteScrollPage');
  setStoredData();
  setIsScrollable(true);
}, []);

화면이 전부 렌더링되면 저장된 스크롤 위치로 이동합니다.

 // page/infinite-scroll.tsx 
 useEffect(() => {
   // 리스트가 렌더링 되면(isScrollable === true) 저장된 스크롤 위치로 이동
   const savedScroll = sessionStorage.getItem('SCROLL_HEIGHT');
   if (!isScrollable || !savedScroll) return;
   window.scrollTo(0, Number(savedScroll));
 }, [isScrollable]);

NextJS의 라우팅 이벤트

react-router-dom을 이용하면 스크롤 상태 유지를 하기 용이합니다.
router에서 넘어온 history객체 내에 action을 감지해서 pop을 식별 해 처리할 수 있기 때문입니다.
하지만 next는 자체 라우팅 시스템을 사용함에 따라 react-router-dom을 사용한 기존 방식을 사용할 수 없습니다.
대신 next Router객체에는 라우팅 도중 발생하는 이벤트에 대해 리스너를 등록할 수 있는 메소드들이 있습니다.

beforePopState() : 이전 페이지 이동 전에 무언가를 해야할 때 사용
routeChangeStart() : 경로가 변경되기 시작할 때 발생
routeChangeComplete() : 경로가 완전히 변경되면 발생
...

next 공식문서 - router

profile
#프론트엔드

0개의 댓글