Infinite Scroll - Intersection Observer API

LNSol·2022년 12월 3일

React

목록 보기
8/10

Intersection Observer API

IntersectionObserver는 두 영역의 교차를 관찰한다. new IntersectionObserver() 를 통해 관찰자를 초기화 하고 관찰할 대상을 지정한다.

/* 관찰자 초기화 */
const observer = new IntersectionObserver(callback, options); 

/* 관찰 대상 등록 */
observer.observe(element);

callback

관찰할 대상이 등록되거나 가시성에 변화가 감지되면 관찰자는 콜백을 실행한다. 콜백은 2개의 인수를 갖는다.

const callback = (entries, observer) => { 
	if (entries[0].isIntersecting) {
		console.log('Intersecting@@@');
	}
};

entries

IntersectionObserverEntry 인스턴스의 배열이다. 다음 읽기 전용 속성들을 포함한다.

  • boundClientRect: 관찰 대상의 사각형 정보
  • intersectionRect: 관찰 대상의 교차한 영역 정보
  • intersectionRatio: 관찰 대상의 교차한 영역 백분율
  • isIntersecting: 관찰 대상의 교차 상태
  • rootBounds: 지정한 루트 요소의 사각형 정보
  • target: 관찰 대상 요소
  • time: 변경이 발생한 시간 정보

Options

  • root: 가시성 검사를 위한 뷰포트 대신 사용할 요소 객체. 타겟의 조상 요소여야 한다.
  • rootMargin: 바깥 여백을 이용해 root 범위를 확장하거나 축소할 수 있다.
  • threshold: 옵저버가 실행되기 위해 타겟의 가시성이 얼마나 필요한지 백분율로 표시한다.

observer.unobserve(target);

/* callback */
const callback = (entries, observer) => {
	if (entries[0].isIntersecting)
		observer.unobserve(entries[0].target);
};

Method

  • observe(): 대상 요소의 관찰을 시작한다.
observer.observe(target);
  • unobserve(): 대상 요소의 관찰을 중지한다. callback의 두 번째 인수가 해당 인스턴스를 참조하므로 콜백 안에서 작성할 수도 있다.
observer.unobserve(target);

/* callback */
const callback = (entries, observer) => {
	if (entries[0].isIntersecting)
		observer.unobserve(entries[0].target);
};
  • disconnect(): 관찰하는 모든 요소의 관찰을 중지한다.
observer.disconnect();


🚀 InfiniteScroll 구현하기

(Next.js로 구현되어 있음)

컨셉

백은 page 번호를 받아 페이지 당 보내줄 데이터만큼의 데이터를 뽑아 보내준다.

1 page ⇒ 1 ~ 7 까지의 데이터

2 page ⇒ 8 ~ 14 까지의 데이터

프론트는 리스트의 마지막 요소 박스가 리트스 박스와 교차할 때 (스크롤이 현재 페이지에서 불러온 데이터의 마지막에 다다랐을 때) page를 증가 시켜 다음 페이지의 데이터를 불러오도록 한다.

page가 증가되면 해당 페이지의 데이터를 요청한다.


서버

/* pages/api/users/index.js */

const USERS = Array.from({ length: 100 }, (_, idx) => ({
	id: idx + 1,
	name: `User${idx + 1}`,
});
const CNT = 7;
const LAST_PAGE = Math.ceil(USERS.length / CNT);

const users = (req, res) => {
	const page = Number(req.query.page);
	const sIdx = (page - 1) * CNT;
	const eIdx = sIdx + CNT;
	res.status(200).json({
		data: USERS.slice(sIdx, eIdx),
		totalPage: LAST_PAGE
	});
};
export default users;

프론트 [1]

/* pages/infinite/index.js */

const Infinite = () => {
	const [page, setPageInfo] = useState({ page: 1, totalPage: null });
	const [users, setUsers] = useState([]);
	const targetRef = useRef(null);

	return (
		<div>
			<h2>Infinite Example</h2>
			<ul id='container'>
				{users.map((user, idx) => (
					<li
						key={user.id}
						ref={users.length - 1 === idx ? targetRef : null}
					>
						{user.id}: {user.name}
					</li>
				))}
			</ul>
			<style jsx>{`
				ul {
					list-style: none;
					padding-left: 0;
					height: 420px;
					border: 1px solid black;
					overflow: scroll;
				}
				li {
					border: 1px solid red;
					height: 60px
				}
			`}</style>
	);
};
export default Infinite;

프론트 [2] - observer

/* 마지막 데이터에 다다랐을 때 page 1 증가 */
const intersectionHandler = (enteries, observer) => {
	if (entries[0].isIntersecting) {
		console.log('intersecting@@@');
		observer.unobserve(entries[0].target);
		
		if (pageInfo.page < pageInfo.totalPage) {
			setPageInfo((prevPageInfo) => ({ 
				...prevPageInfo, 
				page: prevPageInfo.page + 1),
			}));
		}
	}
};

useEffect(() => {
	const $container = document.getElementById('container');
	const option = {
		root: $container,
		threshold: 1,
	};
	const observer = new IntersectionObserver(intersectionHandler, option);
	if (targetRef.current)
		observer.observe(targetRef.current)
}, [users]);

프론트 [3] - 요청 ❗️❗️❗️❗️❗️

/* page가 증가하면 새로운 데이터 요청 */
useEffect(() => {
	**const controller = new AbortController(); // 🚀
	const { signal } = controller; // 🚀**
	axios.get(
		`${process.env.NEXT_PUBLIC_API_URL}api/users?page=${pageInfo.page}`,
		**{ signal } // 🚀**
	)
	.then((res) => res.data)
	.then((data) => {
		console.log('data > ', data);
		setUsers((prevUsers) => [...prevUsers, ...data.data]);
		setPageInfo((prevPageInfo) => ({
			...prevPageInfo,
			totalPage: data.totalPage,
		}));
	})
	.catch(console.error);

	**return () => controller.abort(); // 🚀**
}, [pageInfo.page);

AbortController

AbortController 인터페이스는 하나 이상의 웹 요청을 취소할 수 있게 해준다. 생성자로 새로운 AbortController 객체 인스턴스를 생성하고 요청을 취소하는데 사용되는 signal 객체 인터페이스를 얻는다. 그리고 이 signal을 요청을 보낼 때 옵션으로 넘겨준다. abort() 는 요청이 완료되기 전에 취소시킨다.


🚀 표시가 있는 부분은 아주아주아주 중요하다. React든 Next든 개발환경은 Strict Mode로 동작하는데 useEffect가 두 번씩 탄다. 그래서 자꾸 첫 페이지의 데이터가 배열에 두 번씩 들어가고 페이지가 두 번씩 증가하고…

왜 그러는가 살펴보면 일단 Strict Mode이기 때문에 useEffect가 두 번 타게된다. 그럼 데이터를 요청하는 비동기 함수가 백그라운드로 두 번 넘어간다. 이걸 막을 수 있는 방법이 AbortController를 사용하는 것이다.

useEffect와 cleanup 함수에서 콘솔로 찍고 확인해보면 다음과 같이 출력된다.

useEffect가 처음 탈 때 백그라운드로 넘어간 비동기 요청이 unmount 되면서 취소된다. (cleanup 함수에서 abort 시켰으므로) 그리고 두 번째로 useEffect를 탈 때는 unmount가 되지 않으니 요청이 정상적으로 보내졌다.

즉, AbortController와 cleanup 함수를 이용해 첫 번째로 타는 useEffect에서 보내는 요청을 취소 시킨것이다. useEffect에서 비동기 요청을 할 때는 꼭 이렇게 사용하자!

StrictMode 끄기 ㄴㄴ



프론트 전체 코드

import { useState, useEffect, useRef } from 'react';
import axios from 'axios';
import NavBar from '../../components/NavBar';

const Infinite = () => {
  const [pageInfo, setPageInfo] = useState({ page: 1, totalPage: null });
  const [users, setUsers] = useState([]);
  const targetRef = useRef(null); // 현재 페이지의 마지막 데이터에 걸어줄 ref
	
	/* 관찰 대상 등록 혹은 가시성 변화 감지 시 실행할 콜백 함수 */
  const intersectionHandler = (entries, observer) => {
    if (entries[0].isIntersecting) {
      console.log('intersecting@@@');
      observer.unobserve(entries[0].target); // 교차되면 관찰 종료
      if (pageInfo.page < pageInfo.totalPage) // 마지막 페이지가 아니면 페이지 증가
        setPageInfo((prevPageInfo) => ({
          ...prevPageInfo,
          page: prevPageInfo.page + 1,
        }));
    }
  };

	/* 페이지 증가되면 다음 데이터 요청 */
  useEffect(() => {
    const controller = new AbortController();
    const { signal } = controller;
    axios
      .get(
        `${process.env.NEXT_PUBLIC_API_URL}api/users?page=${pageInfo.page}`,
        { signal }
      )
      .then((res) => res.data)
      .then((data) => {
        console.log('data > ', data);
        setUsers((prevUsers) => [...prevUsers, ...data.data]);
        setPageInfo((prevPageInfo) => ({
          ...prevPageInfo,
          totalPage: data.totalPage,
        }));
      })
      .catch(console.error);

    return () => controller.abort(); // 잊지 말자
  }, [pageInfo.page]);

	/* 새로운 데이터를 받으면 마지막 요소 관찰 */
  useEffect(() => {
    const $container = document.getElementById('container');
    const option = {
      root: $container,
      threshold: 1,
    };
    const observer = new IntersectionObserver(intersectionHandler, option);
    if (targetRef.current) observer.observe(targetRef.current);
  }, [users]);

  return (
    <div>
      <NavBar />
      <h2>Infinite Example</h2>
      <ul id='container'>
        {users.map((user, idx) => (
          <li
            key={user.id}
            ref={users.length - 1 === idx ? targetRef : null}
          >
            {user.id}: {user.name}
          </li>
        ))}
      </ul>
      <style jsx>{`
        ul {
          list-style: none;
          padding-left: 0;
          height: 420px;
          border: 1px solid black;
          overflow: scroll;
        }
        li {
          border: 1px solid red;
          height: 60px;
        }
      `}</style>
    </div>
  );
};
export default Infinite;

0개의 댓글