[React] useIntersectionObserver(+once) custom hook

SangBooom·2022년 10월 19일
1

이 글은 뷰포트 내에 Element의 교차점을 관찰하여 애니메이션 동작 여부를 결정할 때 사용하기 적합한 IntersectionObserver custom hook을 간단하게 설명하고 제공하는 목적에 포커스가 되어있습니다.

Intersection Observer?

우리는 IntersectionObserver를 아래와 같은 상황에 자주 사용한다.

  • 뷰포트 내에 Element의 교차점을 관찰하고 싶을 때
  • 페이지 스크롤시 이미지를 지연로딩 하고 싶을 때
  • 무한 스크롤을 통해 새로운 콘텐츠를 불러올 때
  • 광고의 수익을 계산하기 위해 광고의 가시성을 참고할 때

위와같이 요소가 뷰포트에 포함되는지 아닌지, 사용자 화면에 지금 보이는 요소인지 아닌지를 구별하는 기능을 기본적으로 제공한다.
이러한 기능들은 MDN에 나와있듯이 비동기적으로 실행되기 때문에 scroll 같은 이벤트 기반 요소 관찰에서 발생하는 렌더링 성능이나 이벤트 연속 호출같은 문제 없이 사용할 수 있는 장점을 가지고 있다.

개요

어떤 장점이 있고 이런 api가 등장하게 된 배경은 무엇인지 간단하게만 알아보자.

scroll event

보통 위와같은 상황을 구현하기 위한 방법으로 scroll 이벤트를 먼저 떠올린다. 특정 엘리먼트나 document에 스크롤 이벤트를 등록하고, 특정 지점을 관찰하여 엘리먼트가 위치에 도달하면 실행할 콜백함수를 등록한다.

문제점

scroll 이벤트는 단시간에 많은 호출을 야기할 수 있고 동기적으로 실행되기 때문에 메인스레드 영향을 준다. 또한 한 페이지 내에 여러개의 scroll event 가 등록되어 있다면 사용자가 스크롤할 때마다 이를 감지하는 이벤트가 끊임없이 호출된다.

간단한 해소방법

  1. 디바운싱이나 쓰로틀링을 건다.
  2. 이벤트 리스너의 옵션인 passive: true 를 통해 컴포지터 스레드가 메인 스레드의 프로세스를 기다리지 않고 자신의 작업인 paint와 composite를 즉시 수행하여 이벤트를 수신하는 즉시 핸들러를 실행하게 하여 화면에 바로 반영 및 합성 시킬 수 있게 함으로써 성능을 향상시켜 어느정도 해소할 수는 있다.
    (이 방법은 preventDefault() 호춣을 통해 기본동작인 스크롤을 막을 가능성이 없을 때 사용해야 성능이 개선된다)

getBoundingClientRect

특정 지점을 관찰하기 위해서는 getBoundingClientRect() 함수가 유용하게 쓰인다.

문제점

하지만 이 함수는 리플로우를 야기한다.

Intersection Observer API의 등장

Intersection Observer API를 사용하면 위와 같은 문제들을 해결할 수 있다.
비동기적으로 실행하기 떄문에 메인 스레드에는 영향을 주지 않으면서 변경사항을 관찰할 수 있다.
또한 IntersectionObserverEntry의 속성을 활용하면 getBoundingClientRect()를 호출한 것과 같은 결과를 알 수 있기 때문에
따로 getBoundingClientRect() 함수를 호출할 필요가 없어 리플로우 현상을 방지할 수 있다.

자 그럼 간단한 설명은 여기까지 하고 뷰포트 내에 Element의 교차점을 관찰하여 애니메이션 동작 여부를 결정할 때 사용하기 적합한 IntersectionObserver를 custom hook으로 만들어 보자.

useIntersectionObserver hook

// 구현부

import { useEffect, useState } from 'react';

interface Props extends IntersectionObserverInit {
  onIntersect: IntersectionObserverCallback;
  triggerOnce?: boolean;
}

const DEFAULT_THRESHOLD = 1.0;

export const useIntersectionObserver = ({ root, rootMargin = '0px', threshold = DEFAULT_THRESHOLD, onIntersect, triggerOnce = false }: Props) => {
  const [target, setTarget] = useState<HTMLElement | null | undefined>(null);

  useEffect(() => {
    if (!target) return;

    let observer: IntersectionObserver;
    if (triggerOnce) observer = new IntersectionObserver(onIntersectOnce(onIntersect, triggerOnce, target), { root, rootMargin, threshold });
    else observer = new IntersectionObserver(onIntersect, { root, rootMargin, threshold });

    observer.observe(target);

    return () => {
      if (target) observer.unobserve(target);
    };
  }, [root, rootMargin, onIntersect, threshold, target, triggerOnce]);

  return [setTarget];
};

const onIntersectOnce = (callback: IntersectionObserverCallback, triggerOnce: boolean, target: HTMLElement) => {
  return (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
    callback(entries, observer);

    if (target && triggerOnce && !!entries?.some((entry) => entry.isIntersecting)) {
      observer.unobserve(target);
    }
  };
};
// 호출부

...
  const [animating, setAnimating] = useState(false);

  const [setTargetRef] = useIntersectionObserver({
    threshold: 0.2,
    triggerOnce: true,
    onIntersect: useCallback(([{ isIntersecting }]) => {
      if (isIntersecting) setAnimating(true);
    }, []),
  });

...

 <Text ref={setTargetRef} startAnimation={animating} >
    애니메이션이 적용되는 컨텐츠
 </Text>

보통 널리 사용하는 IntersectionObserver hook과 다른점이 있다면 크게 두가지이다.

  • 뷰포트에 요소(Element)가 감지됐을 때 한번만 트리거 되도록 하는 기능을 props로 전달하여 사용할 수 있다.
  • callback ref의 역할로 useState의 setState에게 역할을 위임하여 요소 감지를 시킨다.
    그 결과로 hook 내에선 참조값의 변경사항을 return 값으로 반환하여 호출부에서 감지할 수 있다.

다른 방법

https://www.npmjs.com/package/react-intersection-observer 패키지를 사용하는것도 추천한다.
번들사이즈도 작고 유용한 기능들이 다수 탑재되어 있어서 쉽게 사용 가능하다.

여담으로 오늘의집 사이트를 번들스캐너로 패키지 분석해봤는데 해당 패키지를 사용하고 있다.
저명한 기업이 사용하고 있다는것은 유용한 라이브러리로 검증이 됐다는 의미이지 않을까..

profile
끊임없이 떨어지는 물방울이 바위를 뚫는다

0개의 댓글