Intersection Observer란? 그리고 Infinite scroll 구현하기

byseop·2021년 7월 27일
2

Intersection Observer 란

Intersection Observer API 는 타겟 요소와 상위 요소 또는 최상위 document 의 viewport 사이의 intersection 내의 변화를 비동기적으로 관찰하는 방법입니다.

MDN 에서 설명하는 Intersection Observer의 설명은 위와 같습니다.
그렇다면 엘리먼트 특정 Element 사이의 intersection 변화를 알 수 있는 다른 방법은 무엇이 있고 Intersection Observer와 무엇이 다르고 어떻게 활용할 수 있을까요?

Intersection Observer vs ScrollEvent

널리 알려지고 흔히 사용되는 방법은 ScrollEvent 를 이용하는 방법입니다.
ScrollEvent 의 단점인 이벤트가 빈번하게 발생하는 모습

위와 같이 ScrollEvent는 빈번하게 발생될 수 있기 때문에, 이벤트 핸들러는 DOM 수정과 같은 계산이 많이 필요한 연산을 실행하지 않아야 합니다. 대신에 requestAnimationFrame, setTimeout, customEvent 등을 사용해 이벤트를 스로틀(throttle) 하는것이 좋습니다.

반면 Intersection Observer는 타겟 엘리먼트가 다른 엘리먼트의 뷰포트에 들어가거나 나갈때 또는 요청한 부분만큼(threshold) 두 엘리먼트의 교차부분이 변경될 때 마다 실행될 콜백 함수를 실행되도록 합니다. 즉, 브라우저는 원하는 대로 교차 영역 관리를 최적화 할 수 있습니다.

Intersection Observer 컨셉

Intersection Observer를 사용하면 다음과 같은 상황에서 callback 함수를 사용 할 수 있습니다.

  1. 타겟 엘리먼트가 또 다른 특정 엘리먼트 혹은 rootintersection 될 때.
  2. observer가 타겟 엘리먼트를 처음 관측할 때.

타겟 엘리먼트가 특정 엘리먼트 혹은 root 사이에 원하는 만큼 교차할때, 미리 정의해둔 callback 함수가 실행됩니다. 여기서 원하는 만큼 교차하는 정도를 intersection ratio 라고 하고, 0.0 ~ 1.0 사이의 숫자로 표현합니다.


간단한 예제를 확인해보겠습니다.

let options = {
  root: document.querySelector("#scrollArea"),
  rootMargin: "10px 0px 3px 5px",
  threshold: 1
};

let observer = new IntersectionObserver(callback, options);

options

  • root: 타겟 엘리먼트의 가시성을 확인할 뷰포트를 의미합니다. 해당 값을 입력하지 않거나 null로 정의할 경우 기본값으로 브라우저 root가 됩니다.
  • rootMargin: root 엘리먼트가 가지는 margin을 의미합니다. 이 값은 CSS margin 속성과 유사하게 'top right bottom left' 순서로 배열합니다.
  • threshold: 타겟 엘리먼트와 root 엘리먼트 사이에 교차 정도를 의미합니다. 기본값은 0 이고 이 때는 타겟 엘리먼트가 1px이라도 교차되면 callback 함수를 실행합니다. 이 값이 1 이라면 타겟 엘리먼트 전체가 root 엘리먼트와 교차될 때 callback 함수가 실행됩니다.
    만약 여러 포인트에서 callback 함수의 실행을 원한다면 [0.25, 0.5, 0.75, 1] 같이 원하는 값의 배열로 표시할 수 있습니다.

callback

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
  });
};

callback 함수는 위와 같이 표시 할 수 있습니다. 파라미터의 entries에는 다양한 메서드와 프로퍼티들이 존재하지만 우리가 집중해야 할 값은 entry.isIntersecting 입니다. 이 값은 타겟 엘리먼트와 root 엘리먼트가 교차하고있는지 아닌지를 boolean 값으로 표시합니다.

let callback = ([entry]) => {
  if (entry.isIntersecting) {
    onIntersect();
  }
};

위와 같이 교차 했는지를 확인하여 callback을 실행 할 수 있습니다.
이 외의 다양한 entry 의 값은 IntersectionObserverEntry 에서 확인 할 수 있습니다.

그렇다면 실제로 어디서 활용 할 수 있을까?

위 컨셉에서 미리 설명 한 callback 이 실행되는 부분

타겟 엘리먼트가 또 다른 특정 엘리먼트 혹은 rootintersection 될 때.

을 활용하여 특정지점에서 필요한 애니메이션을 실행한다거나, 무한스크롤을 구현할 수 있습니다.

Intersection Observer 활용해보기

먼저 아래 예제들은 예제 코드 에 모두 저장 되어있습니다.
Intersection Observer를 이용하여 무한 스크롤을 구현해 보겠습니다.

import { useState, useEffect, useCallback } from 'react';
import axios from 'axios';

type UserType = {
  avatar: string;
  email: string;
  first_name: string;
  id: number;
  last_name: number;
};

type UsersType = {
  data: UserType[];
  page: number;
  total_pages: number;
};

export default function Example() {
  const [pageInfo, setPageInfo] = useState({
    page: 1,
    totalPage: 1
  });
  const [users, setUsers] = useState<UsersType>();
  // (1)
  const [target, setTarget] = useState<Element | null>(null);

  // (2)
  const handleIntersect = useCallback(
    ([entry]: IntersectionObserverEntry[]) => {
      if (entry.isIntersecting) {
        setPageInfo((prev) => {
          if (prev.totalPage > prev.page) {
            return {
              ...prev,
              page: prev.page + 1
            };
          }
          return prev;
        });
      }
    },
    []
  );

  useEffect(() => {
    const instance = axios.get<UsersType>(
      `https://reqres.in/api/users?page=${pageInfo.page}`
    );
    instance.then((response) => {
      if (response.status === 200) {
        setUsers((prev) => {
          if (prev && prev.data?.length > 0) {
            return {
              ...response.data,
              data: [...prev.data, ...response.data.data]
            };
          }
          return response.data;
        });

        setPageInfo((prev) => ({
          ...prev,
          totalPage: response.data.total_pages
        }));
      }
    });
  }, [pageInfo.page]);

  // (3)
  useEffect(() => {
    const observer = new IntersectionObserver(handleIntersect, {
      threshold: 0,
      root: null
    });

    target && observer.observe(target);

    return () => {
      observer.disconnect();
    };
  }, [handleIntersect, target]);

  return (
    <div>
      <ul>
        {users?.data?.map((user, i) => (
          <li
            key={user.id}
            ref={users.data.length - 1 === i ? setTarget : null}
          >
            {user.first_name}
          </li>
        ))}
      </ul>
    </div>
  );
}

3 단계로 나눠서 볼 수 있습니다. 각 단계는 주석으로 표시해두었습니다.

  1. 타겟 엘리먼트 설정하기
...

const [target, setTarget] = useState<Element | null>(null);

...
...

return (
  <div>
    <ul>
      {users?.data?.map((user, i) => (
        <li
          key={user.id}
          ref={users.data.length - 1 === i ? setTarget : null}
        >
          {user.first_name}
        </li>
      ))}
    </ul>
  </div>
);

엘리먼트 ref 를 이용하여 타겟 엘리먼트를 설정합니다. 일반적인 리스트에서 가장 아래쪽 엘리먼트를 타겟 엘리먼트로 설정합니다.

  1. callback 함수 정의하기
const handleIntersect = useCallback(([entry]: IntersectionObserverEntry[]) => {
  if (entry.isIntersecting) {
    setPageInfo(prev => {
      if (prev.totalPage > prev.page) {
        return {
          ...prev,
          page: prev.page + 1
        };
      }
      return prev;
    });
  }
}, []);

entry.isIntersecting 을 활용하여 원하는만큼 엘리먼트가 교차했는지 확인한 뒤에 page 정보를 업데이트합니다.

  1. Intersection Observer 생성하기
useEffect(
  () => {
    const observer = new IntersectionObserver(handleIntersect, {
      threshold: 0,
      root: null
    });

    target && observer.observe(target);

    return () => {
      observer.disconnect();
    };
  },
  [handleIntersect, target]
);

useEffect 를 이용하여 Intersection Observer 를 생성합니다. optionsrootnull로 정의하면 화면 전체가 뷰포트가 됩니다. threshold 값을 0으로 정의하면 타겟 엘리먼트 전체가 뷰포트에 교차해야 callback이 실행됩니다.

구현된 무한스크롤

커스텀 훅

더 나아가 커스텀 훅으로 발전시켜 보겠습니다.

import { useEffect, useState, useCallback } from "react";

export default function useInfiniteScroll(
  onIntersect: () => void,
  options?: IntersectionObserverInit
) {
  const [target, setTarget] = useState<Element | null>(null);

  const handleIntersect = useCallback(
    ([entry]: IntersectionObserverEntry[]) => {
      if (entry.isIntersecting) {
        onIntersect();
      }
    },
    [onIntersect]
  );

  useEffect(
    () => {
      const observer = new IntersectionObserver(handleIntersect, options);
      target && observer.observe(target);
      return () => {
        observer.disconnect();
      };
    },
    [handleIntersect, target, options]
  );

  return [setTarget];
}

이렇게 훅을 만들고...

import { useState, useEffect, useCallback } from 'react';
import axios from 'axios';
import useInfiniteScroll from '../hooks/useInfiniteScroll';

type UserType = {
  avatar: string;
  email: string;
  first_name: string;
  id: number;
  last_name: number;
};

type UsersType = {
  data: UserType[];
  page: number;
  total_pages: number;
};

export default function Example() {
  const [pageInfo, setPageInfo] = useState({
    page: 1,
    totalPage: 1
  });
  const [users, setUsers] = useState<UsersType>();

  const handleIntersect = useCallback(() => {
    setPageInfo((prev) => {
      if (prev.totalPage > prev.page) {
        return {
          ...prev,
          page: prev.page + 1
        };
      }
      return prev;
    });
  }, []);

  // (1)
  const [setTarget] = useInfiniteScroll(handleIntersect, {
    threshold: 0
  });

  useEffect(() => {
    const instance = axios.get<UsersType>(
      `https://reqres.in/api/users?page=${pageInfo.page}`
    );
    instance.then((response) => {
      if (response.status === 200) {
        setUsers((prev) => {
          if (prev && prev.data?.length > 0) {
            return {
              ...response.data,
              data: [...prev.data, ...response.data.data]
            };
          }
          return response.data;
        });

        setPageInfo((prev) => ({
          ...prev,
          totalPage: response.data.total_pages
        }));
      }
    });
  }, [pageInfo.page]);

  return (
    <div>
      <ul>
        {users?.data?.map((user, i) => (
          <li
            key={user.id}
            ref={users.data.length - 1 === i ? setTarget : null}
          >
            {user.first_name}
          </li>
        ))}
      </ul>
    </div>
  );
}

이렇게 handleIntersectconfig를 받아서 코드를 재활용해주는 훅을 만들어 보았습니다.
주석 (1)에서 처럼 타겟 엘리먼트 설정 부분만 따로 빼주어서 ref 에 넣어주었습니다.

마치며

이렇게 Intersection Observer를 이용하면 ScrollEvent에 비해 브라우저에 부하가 더 적게 최적화 할 수 있고 추가로 커스텀 훅 까지 만들어 보았습니다.

하지만 ScrollEvent도 위에 설명한 스로틀을 잘 적용하면 좀 더 사용자에게 좋은 경험을 만들 수 있을것 같습니다.

Reference

0개의 댓글