카테고리 없음

IntersectionObserver를 활용한 읽고있는 글의 부분의 목차를 자동으로 하이라이팅 하기

곽빵 2022. 11. 30. 17:23

개요

자작 블로그 개발 하던중 글의 목차를 사이드 쪽에 간단히 생성해서 현재 읽고 있는 글의 부분이 어느 목차에 해당되는지 유저에게 인식 시키게 하기 위한 UI를 개발중에 생긴 문제점과 해결한 과정을 기록

개발중인 블로그

내가 원하는 움직임

 

TIL ] JPA 영속성 컨텍스트

엔티티와 JPA 영속성 컨텍스트

velog.io

위 사이트를 들어가 보면 velog의 포스트인데 이런 움직임을 원하고 있다.

이를 구현하기 위해 IntersectionObserver라는 친구를 쓸려고 한다.

IntersectionObserver 란?

IntersectionObserver를 사용할 때 지정한 부모요소 또는 document의 viewport내에 타겟 요쇼가 교차되는가의 변화를 비동기적으로 관찰할 수 있는 javascript 내부 기능이다.

 

제일 유명한 사용 사례는 역시 무한 스크롤링으로 컨텐츠를 어느 지점의 스크롤이 도달했을때 컨텐츠를 더 불러오는 방식을 쉽게 구현할 수 있다. IntersectionObserver이 없는 시절에는 이 무한 스크롤을 scroll event를 이용해 구현하곤 했는데 이 스크롤 이벤트 자체가 스크롤을 할 때 마다 트리거되며 그에 따라 한번 불러야 할 이벤트를 2번 3번이상 부르게 되는 문제점이 생기고 이를 해결하기 위해 *쓰로틀링이나 *디바운싱을 걸어줘야 한다. 하지만 이러한 점을 고려하지 않고 구현할 수 있는 기능이 IntersectionObserver가 된다.

 

*쓰로틀링(Throttling)

함수가 호출된 후 일정 시간이 지나기 전에 다시 호출되지 않도록 하는 것.

즉, 연속으로 호출되는 함수들 중에 처음 호출되는 함수만 실행되도록 하는 것

 

*디바운싱(Debouncing)

연속으로 호출되는 함수들 중에 마지막에 호출되는 함수만 실행되도록 하는 것

IntersectionObserver 생성과 옵션

const IODefaultOptions = {
  root: null,
  rootMargin: '-50% 0px -50% 0px',
  threshold: 0,
};

// doWhenIntersect 조건이 충족할 떄 실행할 콜백함수
observer = new IntersectionObserver(doWhenIntersect, options);

root

타겟 요소가 이 root에서 지정한 요소에 들어왔는지(가시성)을 확인할 때 사용하는 옵션이며, 기본값으로 브라우저의 뷰포트 이며, null 이거나 값을 지정하지 않을 때 기본값으로 설정된다. (이 값에 값을 지정하며 해당 요소는 무조건 타겟 요소의 부모가 되어야 하며 뷰포트와 같은 역할을 할 수 있게끔 스크롤 되지 않는 대상?이 되어야 한다.)

 

rootMargin

root 가 가진 여백이며 범위를 늘이거나 좁힐 때 사용할 수 있다.

root: null, rootMargin: '-50% 0px' 이면 화면의 뷰포트로 정중앙을 지정하는 것이고

root: null, rootMargin: '10px' 이면 화면의 뷰포트보다 가로 세로 20px 넓어진 뷰포트가 되는것이다.

 

threshold

observer의 콜백이 실행될 대상 요소의 가시성 퍼센티지를 나타내는 단일 숫자 혹은 숫자 배열

만일 50%만큼 요소가 보여졌을 때를 탐지하고 싶다면, 값을 0.5로 설정하면 된다. 혹은 25% 단위로 요소의 가시성이 변경될 때마다 콜백이 실행되게 하고 싶다면 [0, 0.25, 0.5, 0.75, 1] 과 같은 배열을 설정. 기본값은 0이며(이는 요소가 1픽셀이라도 보이자 마자 콜백이 실행됨을 의미) 1.0은 요소의 모든 픽셀이 화면에 노출되기 전에는 콜백을 실행시키지 않음을 의미한다.

 

IntersectionObserver 를 사용해보자

리액트도 뷰도 어느쪽이든 쓸 수 있게 만들고 싶었기 때문에 훅으로써 만들어 보았다(뷰에서는 composable로 보면될듯하다.)

useIntersectionObserver.ts

const IODefaultOptions = {
  root: null,
  rootMargin: '-50% 0px -50% 0px',
  threshold: 0,
};

type DoWhenIntersectFunction = (entries: IntersectionObserverEntry[], observer: any) => void;

export const useIntersectionObserver = (options = IODefaultOptions) => {
  let observer: IntersectionObserver;

  const addIntersectHandler = (
    targets: Element[] | NodeList,
    doWhenIntersect: DoWhenIntersectFunction,
  ) => {
    observer = new IntersectionObserver(doWhenIntersect, options);
    targets.forEach((el) => {
      observer.observe(el);
    });
  };

  const removeIntersectHandler = () => {
    observer.disconnect();
  };

  return {
    addIntersectHandler,
    removeIntersectHandler,
  };
};
  • useIntersectionObserver는 옵션을 매개변수로써 받고 인자를 넘기지 않을시 IODefaultOptions가 셋팅된다.
  • addIntersectionHandler는 타겟과 콜백함수를 넘겨서 감시를 시작하게 하는 함수
  • removeIntersectHandler는 감시를 종료시키는 함수

useIntersectionObserver.ts 사용 예시 코드

const intersectionObserver = useIntersectionObserver({
  root: null,
  rootMargin: '0px 0px -96% 0px',
  threshold: 0.3,
});
useEffect(() => {
  const targetElements = navInfo.map((info) => {
    return document.getElementById(info.text);
  });
  intersectionObserver.addIntersectHandler(targetElements, (entries, observer) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting && entry.target?.id) {
        setActiveId(entry.target?.id);
      }
    });
  });
  return () => {
    intersectionObserver.removeIntersectHandler();
  };
}, [intersectionObserver, navInfo]);
  • 옵션으로 브라우저 뷰포트를 이용하고 제일 윗부분으로만 뷰포트를 제한했다.
  • threshold: 0.3은 30% 교차되면 콜백 함수를 실행시키게끔 한다.
  • navInfo에는 타겟으로할 요소들이 들어있다. (이번에 타겟팅되는 요소들은 여러개)
  • activeId로 활성화 할 목차를 판별하고 있고 타겟 요소가 교차될 때 교차된 요소의 id가 activeId가 된다.
  • 클린업 함수로써 감시를 종료시키는 removeIntersectHandler를 실행시킨다.

IntersectionObserver 를 사용하면서 생긴 문제점

위의 옵션을 이용해 root를 디폴트값으로 두고 rootMargin을 "0px 0px -96% 0px"로 둬서 제일 상단의 뷰포트로 범위를 좁혀서 여기에 제목이 들어올 때 해당 제목의 목차를 활성화 시키는 방법으로 진행했지만, 스크롤을 일부러 빠르게 할 경우에는 intersecting 되지 않는(타겟요소가 뷰포트에 들어왔는데 들어온적이 없는것 처럼 되는) 문제가 발생했습니다.

 

다시 한번 구글링을 해보니 

Intersection Observer는 감시대상의 DOM요소의 위치를 체크하는 비동기 함수를 반복적으로 실행한다. 이것은 브라우저의 렌더링 사이클하고 관련되어 있으며 아주 빠르게 실행되지만(화면의 프레임이 60fps니깐 초당 60번) 이것들의 체크보다 빠르게 스크롤을 하면 IOAPI는 일부의 가시성의 변화를 검출하지 않는 경우가 생기고 만다. 

https://stackoverflow.com/questions/61951380/intersection-observer-fails-sometimes-when-i-scroll-fast

 

Intersection Observer fails sometimes when i scroll fast

I have a little issue with the Intersection Observer API, It works good for me, but. When I scroll fast (very fast) on my webpage, the Intersection Observer API fails sometimes to detect the preten...

stackoverflow.com

원인파악

처음에는 IntersectionObserver의 사양상 빠른 스크롤에 의해 intersecting 안되겠구나..라고 생각했지만, 나의 경우에는 조금만 빠르게 해도 제대로 intersecting이 감지가 안되었다. 그 원인을 이래저래 찾던중 리액트의 재렌더링이 너무 많이 일어나서 조금 빠른 스크롤에도 버벅이게 되는게 원인이었다...위의 코드는 vue 프로젝트를 할 때 사용한 훅을 그대로 갖고 온거라 리액트로 할 때는 렌더링 부분을 신경써줬어야 했는데 그 점이 많이 부족했었다. 이렇게 보면 vue는 정말 쓰기 편한 프레임워크구라 하는 생각이 들었다.

해결

useIntercetionObserver.ts

import { useCallback } from 'react';

const IODefaultOptions = {
  root: null,
  rootMargin: '-50% 0px -50% 0px',
  threshold: 0,
};

type DoWhenIntersectFunction = (entries: IntersectionObserverEntry[], observer: any) => void;

let observer: IntersectionObserver;

export const useIntersectionObserver = () => {
  const addIntersectHandler = useCallback(
    (
      targets: Element[] | NodeList,
      options = IODefaultOptions,
      doWhenIntersect: DoWhenIntersectFunction,
    ) => {
      observer = new IntersectionObserver(doWhenIntersect, options);
      targets.forEach((el) => {
        observer.observe(el);
      });
    },
    [],
  );

  const removeIntersectHandler = useCallback(() => {
    observer.disconnect();
  }, []);

  return {
    addIntersectHandler,
    removeIntersectHandler,
  };
};
  • useIntersectionObserver를 사용하는 컴포넌트의 재렌더링이 일어나도 addIntersectHandler와 removeIntersectHandler가 재실행 되지 않게끔 매개변수로 받던 option을 addIntersectHandler의 매개변수로서 넘겨 의존성을 없애준다.
  • observer 변수 자체를 훅의 밖으로 선언해 의존성을 없앤다
  • add, remove에 useCallback을 씌우는데 위의 작업으로 인해 의존성이 없어졌으므로 훅 자체가 재렌더링 되도 이 두 함수는 재실행 안될것이다.
const Post: FC<Props> = ({ post }) => {
  const [activeId, setActiveId] = useState('');
  const navInfo = useMemo(() => {
    return post.contents
     ...생략
  }, [post]);

  const { addIntersectHandler, removeIntersectHandler } = useIntersectionObserver();
  useEffect(() => {
    const targetElements = navInfo.map((info) => {
      return document.getElementById(info.text);
    });
    addIntersectHandler(
      targetElements,
      {
        root: null,
        rootMargin: '0px 0px -95% 0px',
        threshold: 0,
      },
      (entries, observer) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting && entry.target?.id) {
            setActiveId(entry.target?.id);
          }
        });
      },
    );
    return () => {
      removeIntersectHandler();
    };
  }, [addIntersectHandler, removeIntersectHandler, navInfo]);
  return (...생략)  
}
  • setActiveId에 의해 state가 변경될 때 컴포넌트 전체 재렌더링이 일어나는데 그에 따라 재실행이 안되게끔 navInfo를 useMemo로 메모이제이션을 해준다.

이제 화면에 들어가서 포스트에서 천천히 스크롤을 내려보는데 제일 상단에 있는 제목에 도달했을 때는 문제없이 타겟 목차에 하이라이팅이 들어갔지만, 그 뒤에는 내가 등록한 IntersectionObserver가 없어진거 마냥 교차가 되어도 더 이상 콜백함수 자체가 실행이 안되었다. 계속 삽질해서 알아낸 결과는 글 자체는 마크다운 컴포넌트라는 부분에서 글을 렌더링하고 있는데 이 친구가 이 부모 컴포넌트(useIntersect 훅을 사용하고 있는 컴포넌트)가 재렌더링 될 때 자식 컴포넌트도 재렌더링 되기 때문에 내가 지정했던 타겟 요소(HTML Element)가 재렌더링 됨으로써 IntersectionObserver에 등록되어 있는 요소하고 화면에 표시 되고 있는 요소가 전혀 다른 요소로 인식되어서 교차 이벤트가 발생이 안되었던 것이었다..그래서 자식 컴포넌트에도 props가 바뀌지 않으면 재렌더링 안되게끔 React.memo로 메모이제이션을 해 주었다.

const MarkDownView: FC<Props> = React.memo(function MarkdownView({ markdown }) {
  return (
    <ReactMarkdown
...생략

해결한 commit

https://github.com/gmldnjs26/heewon-blog/commit/6131f688243d5e6115c3c0fe6517a8a81d5fcfd9

 

fix(front): 스크롤 퍼포먼스 개선 · gmldnjs26/heewon-blog@6131f68

Show file tree Showing 3 changed files with 78 additions and 66 deletions.

github.com