본문 바로가기

programming/React

무한 스크롤 구현기(React, Scrollevent, Throttle, Intersection Observer API)

목표 : React 에서 무한 스크롤 구현하기 (Infinity Scroll in React)

여정 : scroll Event Listener -> Throttle 로 최적화 -> Intersection Observer API 사용

 

1. 단순 구현

  • 스크롤 이벤트를 참조하여 다음 데이터 API 호출

코드 구현

  • 가정
    • fetchData() 는 API 를 요청하는 함수
    • 예제에서는 console.log('fetch API 요청') 찍히는 걸로 API 요청을 대신함
useEffect(() => {
	const handleScroll = () => {
		const { scrollTop, offsetHeight } = document.documentElement;
		if (window.innerHeight + scrollTop >= offsetHeight) {
			// 스크롤 위치 바닥일때 데이터 페치
			fetchData() // -> API 요청하는 함수
			console.log('fetch API 요청');
		}
	};
	// 화면 진입시 데이터 페치
	fetchData() // -> API 요청하는 함수
	console.log('fetch API 요청');
	window.addEventListener('scroll', handleScroll);
	return () => window.removeEventListener('scroll', handleScroll);
}, []);
  • 개선 포인트
    • documentElement.scrollTop과 documentElement.offsetHeight는 리플로우(Reflow)가 발생하는 참조이므로 API를 과다로 요청하는 경우 발생 가능

 

2. 쓰로틀 (Throttle) 적용

  • 마지막 함수가 호출된 후 일정 시간이 지나기 전에 다시 호출되지 않도록 하는 것
  • 동일 이벤트가 반복적으로 시행되는 경우 이벤트의 실제 반복 주기와 상관없이 임의로 설정한 일정 시간 간격(밀리세컨드)으로 콜백 함수의 실행을 보장

코드 구현

  1. 기본 Throttle 함수(ver1)
function throttle(callback, delay = 300) {
	let waiting = false

	return (...args) => {
		if (waiting) return

		callback(...args)
		waiting = true
		setTimeout(() => {
			waiting = false
		}, delay)
	}
}
  • callback: 실행 대상이 되는 콜백 함수
  • delay : 얼마 간격으로 함수를 실행할 지 결정하며 millisecond 단위, 기본값으로 0.3초를 주었음
  • waiting 상태가 true 인 경우 실행 없음
  • 최초 함수가 호출되었을 때 waiting은 false
  • 이 때 콜백 함수를 실행한 뒤 다시 waiting은 true가 됩니다.
  • waiting이 true가 되었을 때 delay 밀리초 후에는 waiting이 강제로 false가 되고, 다시 콜백 함수가 실행이 됩니다.

1-2. 기본 Throttle 함수(ver2)

function throttle(callback, delay = 3000) {
	let timer;
	return (...args) => {
		if (timer) return;
		timer = setTimeout(() => {
			callback(...args);
			timer = null;
		}, delay);
	};
}
  • timer 를 생성하는 역할
  • 생성해둔 timer 가 있으면, 콜백함수 실행 없이 return
  • timer 가 없으면, timer 를 만듦
  • 만든 timer 는 delay 시간 이후에 콜백 함수를 실행하는 타이머

⇒ 두가지 throttle 함수를 각각 적용해봤을 때 동작에 차이가 발생했음

 

2. handleScroll 함수에 throttle 함수를 적용

useEffect(() => {
	const handleScroll = throttle(() => {
		const { scrollTop, offsetHeight } = document.documentElement;
		if (window.innerHeight + scrollTop >= offsetHeight) {
			// 스크롤 위치 바닥일때 데이터 페치
			console.log('fetch API 요청');
		}
	});
	// 화면 진입시 데이터 페치
	console.log('fetch API 요청');
	window.addEventListener('scroll', handleScroll);
	return () => window.removeEventListener('scroll', handleScroll);
}, []);
  • ver2 throttle 함수의 경우 : 정상 동작
    1. 스크롤 바닥
    2. 3000ms 대기
    3. timer (setTimeout) 생성
    4. callback 함수 실행
    5. timer 해제
    6. 스크롤 위로 갔다가 다시 바닥
    7. 3000ms 대기
  • ver1 throttle 함수의 경우 이상 동작
    • 함수의 코드 순서를 확인해 보자
      • throttle 함수 ver1
      function throttle(callback, delay = 3000) {
      	let wait = false;
      
      	return (...args) => {
      		if (wait) {
      			return;
      		}
      
      		callback(...args);
      		wait = true;
      		setTimeout(() => {
      			wait = false;
      		}, delay);
      	};
      }
      
      • 이상동작 원인
        • 일단 스크롤 이벤트가 생길 때 마다 callback 함수가 실행되는데,
        • 스크롤 이벤트가 트리거 되는 시작점의 스크롤 위치 값을 들고 감
        • if (window.innerHeight + scrollTop >= offsetHeight) {
        • 그래서 스크롤 바닥 조건에 걸리지 않음
        • fetch 함수를 실행하지 않음
  • 화면 예시
    • 한번에 끝까지 스크롤 한 상태
    • callback 함수가 호출됨

  • scrollTop 위치를 “3” 으로 물고 있음
  • fetch 할 조건 (scrollTop이 아래에 위치해 있는가?) 에 해당되지 않아서 fetch 함수가 실행되지 않는다
  • delay 시간 이후에 callback 함수를 실행해야 원하는 scrollTop 값으로 조건체크를 할 수 있음

  • 함수 변형
function throttle(callback, delay = 3000) {
		let wait = false;

		return (...args) => {
			if (wait) {
				return;
			}
			wait = true;
			setTimeout(() => {
				callback(...args);
				wait = false;
			}, delay);
		};
	}

  • delay 시간 이후 callback 함수가 호출 되어서 최신 scrollTop 위치값을 가져올 수 있

 

⇒ 스크롤 이벤트 특성상 delay 이후에 콜백함수가 실행되는 것이 정상 동작이었으나, 케이스에 따라 콜백을 먼저 실행하고, delay 가 있어야 하는 경우도 있을 것

 

3. Intersection Observer API (체신 문법 적용^^)

  • Intersection Observer API 적용
  • 교차 관찰자 API
  • 브라우저에서 제공하는 API
  • 크롬 51버전부터 사용가능
  • 위에서 확인했듯이, 스크롤 이벤트는 일단 스크롤이 발생하면 트리거됨
  • 우리가 원하는 것은 스크롤이 아래에 도달했을 때만 트리거 시키고 싶음

코드 구현

  • new IntersectionObserver() 생성자 표현식을 사용하여, observer 객체를 생성하여 사용
  1. 생성
    • 교차될때 실행할 콜백함수를 제공해야 한다 → fetchData 함수
    • options 로 root / rootMargin / threshold 값을 커스텀 할 수 있음
  2. 설정
    • options 값 커스텀
      • root : 대상이 되는 객체의 가시성을 확인 할 때 사용하는 뷰포트 요소, 기본값은 브라우저 뷰포트

      • rootMargin : root 가 가진 여백, 교차성을 개산하기 전에 적용됨

      • threshold : observer의 콜백함수가 실행될 대상 요소의 가시성 퍼센테이지, 10%만 보여도 수행하는가?, 100% 다보였을때 실행하는가

 

3. 관찰대상 지정하기

  • 요소를 observer.observe 메소드에 등록해주면 된다
let target = document.querySelector('#listItem'); 
observer.observe(target);

4. 콜백함수 구현하기

  • 콜백함수 기본 args
let callback = (entries, observer) => {
  entries.forEach(entry => {
    // Each entry describes an intersection change for one observed
    // target element:
    //   entry.boundingClientRect
    //   entry.intersectionRatio
    //   entry.intersectionRect
    //   entry.isIntersecting
    //   entry.rootBounds
    //   entry.target
    //   entry.time
  });
};
  • IntersectonObserverEntry 리스트를 첫번째 인자로 받고, observer 자체를 두번째 인자로 받는다
  • React 용 useIntersect hook
const { useRef, useCallback, useEffect } = require('react');

const useIntersect = (onIntersectFn, options) => {
	const ref = useRef(null); // 1
	const callback = useCallback(
		(entries, observer) => {
			entries.forEach((entry) => {
				if (entry.isIntersecting) onIntersectFn(entry, observer);
			});
		},
		[onIntersectFn]
	);

	useEffect(() => {
		if (!ref.current) return;
		const observer = new IntersectionObserver(callback, options); // 2
		observer.observe(ref.current); // 3
		return () => observer.disconnect();
	}, [ref, options, callback]);

	return ref;
};

export default useIntersect;
  1. 관찰 타겟 ref 생성
  2. observer 생성
  3. observer.observe 에 관찰 타겟 ref 연결
  4. ref unmount 시 observer disconnect 시키기 (삭제)
  5. callback 함수 선언
    • entry 객체 내부 key

    • entry 가 isIntersecting true 인터섹트 트리거 되면,
    • onIntersectFn 함수를 실행
    • onIntersectFn은 useIntersect 훅 선언시 전달 → fetchData 함수
  • useIntersect 훅 사용부분
    • 핵심 내용은 fetchData 함수를 실행해야함
    • 타겟에 대한 관찰을 멈출지는 상황에 따라 판단
const ref = useIntersect(async (entry, observer) => {
	// observer.unobserve(entry.target);
	// if(hasNextPage && !isFetching) {
	// 	console.log('fetchNextPage');
	// }
	console.log('fetchNextPage');
});

 

4. 폴리필

  • safari 에서 지원하고 있지 않음 폴리필 필요

https://github.com/GoogleChromeLabs/intersection-observer

https://github.com/w3c/IntersectionObserver

$ npm i intersection-observer
import 'intersection-observer'

const io = new IntersectionObserver(콜백함수, 옵션)

const 관찰대상 = document.querySelectorAll(관찰대상)

// IE에서 사용하기
Array.prototype.slice.call(관찰대상).forEach(elim => {
  io.observe(elim)
})

 

참고