Intersection Observer

·2023년 4월 15일
1

삽질LOG

목록 보기
6/13

삽질의 시작

스크롤 중간에 탭 영역을 만나면 상단에 탭이 고정되고 현재 보이는 영역에 맞춰서 탭이 활성화되는 그것 말이다. 인터넷 쇼핑 좀 해봤다~하면 모두 아는 그것. 전 회사에도 했던 기능을 또 만들게 됐다. 그래서 처음엔 저번 회사와 똑같이 구현했다.

구현 방법

  1. scroll에 현재 스크롤 위치를 반환하는 이벤트 리스너를 추가해 hook을 만든다.
  2. 만든 훅으로 현재 스크롤 위치와 탭의 offsetTop을 비교하며 상단에 있는지 없는지 판단한다.
  3. 상단이면 고정 아니면 원위치 시킨다.
  4. 현재 스크롤과 각 영역별 offsetTop을 비교해서 탭을 활성화 시킨다.

이렇게 아주 순식간에 만들었다. 동작은 아주아주 잘하지만..throttle이나 debounce를 걸어놨다고 해도 너무나도 많이 호출되는 hook때문에 성능 고민이 생겼고 무한 스크롤 구현 코드를 보니 intersection observer api를 사용하고 있었다. 문서를 보니 실제로도 스크롤 이벤트가 성능을 악화시킬 수 있어 대체제로 만들어진 API다.

오..나는 이거 처음보는데?? 내가 그래도 3년차 개발자인데 이걸 처음본다고?? 당장 공부해서 저 기능에 써먹어..! 라는 생각이 들었다.
그때는 몰랐지 이게 갑자기 나의 고생길이 될 줄은. 일주일을 삽질을 했다.

피곤한 덤덤 사랑행
피곤한 덤덤 사랑행

Intersection Observer

공식문서

제일 윗줄을 읽어보면 해당 API의 기능이 뭔지 아주 명확하게 나온다.

루트 요소와 타겟 요소의 교차점을 관찰한다. 그리고 타겟 요소가 루트 요소와 교차하는지 아닌지를 구별하는 기능을 제공 뷰포트에 보이면 콜백을 실행시켜준다. 스크롤 이벤트와 다르게 비동기적으로 실행된다.

사용법 및 스펙

일단 Intersection Observer 인스턴스를 생성해보자.

let options = {
	root: document.querySelector('#scrollArea'),
	rootMargin: '0px',
  threshold: 1.0
}

// options에 따라 인스턴스 생성
let observer = new IntersectionObserver(callback, options);

// 타겟 요소 관찰 시작
let target = document.querySelector('#listItem');
observer.observe(target);

new 키워드를 통해 인스턴스를 생성한다. callback , options 2개의 파라미터를 받는다.

  • callback: 가시성의 변화가 생겼을 때 호출
  • options: 콜백이 호출되는 상황을 정의

Options

우선 options 부터 살펴보도록 하겠습니다.

root
타겟 요소의 가시성을 확인할 때 사용되는 루트 요소
이것은 타겟 요소보다 상위 요소, 즉 요소의 조상 요소이어야한다. 설정하지 않거나 root 값을 null 로 주었을 때 기본 값으로 브라우저 뷰포트가 설정된다.

rootMargin
margin 을 주어 루트 요소의 범위를 확장할 수 있다. 즉, 확장된 영역 안에 타겟 요소가 들어가면 가시성에 변화가 생긴다. CSS 의 margin 과 유사하게 top, right, bottom, left 의 margin 정도롤 각각 설정할 수 있습니다. 기본 값은 0이며 따로 설정 시 단위를 꼭 입력해야한다.

threshold
콜백이 실행될 타겟 요소의 가시성 퍼센티지를 나타내는 단일 숫자 및 숫자 배열이 들어갈 수 있다. 즉, 요소의 top, bottom 이 노출된 순간만 콜백을 실행할 수 있는 것이 아니라 어느정도 타겟 요소가 보여졌는 지에 따라서도 콜백을 호출 가능하다. 예를 들어 요소가 50%만큼 보여졌을 때 탐지하고 싶다면 단일 숫자 값 0.5 를 설정하면 된다. 혹은 25% 단위로 가시성이 변경될 때마다 콜백이 실행되게 하고 싶다면 [0, 0.25, 0.5, 0.75, 1] 을 설정하면 됩니다.

// 타겟 요소가 50% 가시성이 확인되었을 때
let observer1 = new IntersectionObserver(callback, {
	threshold: 0.5
});

// 타겟 요소가 25% 단위로 가시성이 확인되었을 때
let observer1 = new IntersectionObserver(callback, {
	threshold: [0, 0.25, 0.5, 0.75, 1]
});

Callback

타겟 요소의 관찰이 시작되거나, 가시성에 변화가 감지되면(threshold 와 만나면) 등록된 callback 이 실행된다.

let callback = (entries, observer) => {
  entries.forEach(entry => {
    // 각 entry는 가시성 변화가 감지될 때마다 발생하고 그 context를 나타냅니다.
    // target element:
    //   entry.boundingClientRect
    //   entry.intersectionRatio
    //   entry.intersectionRect
    //   entry.isIntersecting
    //   entry.rootBounds
    //   entry.target
    //   entry.time
  });
};

이 콜백은 메인스레드에서 처리되고 파라미터로 entries 와 observer 를 받게 된다.

Entries
entries 는 IntersectionObserverEntry 인스턴스를 담은 배열이다. 일반적으로 callback 에 파라미터로 전달이 되고 후술할 Intersection Observer.takeRecords() 를 통해 반환받을 수도 있다.

  • IntersectionObserverEntry 는 루트요소와 타겟요소의 교차(threshold 와 만났을 때)의 상황을 묘사한다. 포함된 프로퍼티들은 모두 읽기전용(read only) 이다.

  • IntersectionObserverEntry.boundingClientRect : 타겟 요소의 사각형 정보(DOMRectReadOnly)를 반환한다. getBoundingClientRect() 호출과는 다르게 reflow 를 발생시키진 않는다.

  • IntersectionObserverEntry.intersectionRect : 타겟 요소의 가시성이 감지된 부분의 정보(DOMRectReadOnly)를 반환한다.

  • IntersectionObserverEntry.intersectionRatio : 타겟 요소의 intersectionRect 이 boundingClientRect 와 어느정도로 교차(겹치는 지) 비율(0.0 ~ 1.0)을 반환한다. 바꿔 말하면 타겟 요소가 루트 요소와 얼마나 교차하는지의 정도와 같다.

타겟 요소와 루트 요소가 전혀 교차하지 않았어도 타겟 요소의 관찰이 시작되면 콜백도 바로 호출된다. 이는 Intersection Observer 의 기본 설정이다. 이를 예외처리 하기 위해서 intersectionRatio 가 사용된다.

let callback = (entries, observer) => {
  entries.forEach(entry => {
	// 타겟 요소가 루트 요소와 교차하는 점이 없으면 콜백을 호출했으되, 조기에 탈출한다.
	if (entry.intersectionRatio <= 0) return

	// 혹은 isIntersecting을 사용할 수 있습니다.
	if (!entry.isIntersecting) return

	// ... 콜백 로직
  });
};
  • IntersectionObserverEntry.isIntersecting : 해당 entry 에 타겟 요소가 루트 요소와 교차하는 지 여부를 Boolean 값으로 반환
  • IntersectionObserverEntry.rootBounds : 루트 요소의 사각형 정보(DOMRectReadOnly)를 반환. 이 정보는 rootMargin 옵션에 따라 영향을 받음
  • IntersectionObserverEntry.target : 타겟 요소를 반환
  • IntersectionObserverEntry.time : 문서(Document)가 만들어진 표준 시간(time origin)을 기준으로 타겟 요소와 루트 요소의 교차가 발생한 시간(DOMHighResTimeStamp)을 반환

Methods

  • IntersectionObserver.observe(targetElement) : 타겟 요소에 대한 관찰을 시작

  • IntersectionObserver.unobserve(targetElement) : 타겟 요소에 대한 관찰을 중지. 관찰의 목적이 이루어져 굳이 계속 관찰을 할 필요가 없는 경우 사용

  • IntersectionObserver.disconnect() : 인스턴스의 타겟 요소들에 대한 모든 관찰을 중지

  • IntersectionObserver.takerecords(targetElement) : IntersectionObserverEntry 인스턴스들의 배열을 반환

해당 기능을 선택한 이유

MDN Intersection Observer API 페이지에서는 대표적인 용례를 4개정도 적어놨다.

  • 페이지가 스크롤 되는 도중에 발생하는 이미지나 다른 컨텐츠의 레이지 로딩
  • 스크롤 시에, 더 많은 컨텐츠가 로드 및 렌더링되어 사용자가 페이지를 이동하지 않아도 되게 하는 무한스크롤을 구현
  • 광고 수익을 계산하기 위한 용도로 광고의 가시성 보고
  • 사용자에게 결과가 표시되는 여부에 따라 작업이나 애니메이션을 수행할 지 여부를 결정

나는 여기서 제일 마지막을 보고 이 기능을 써도 되겠다는 결론을 내렸다. 이제 본격적인 삽질을..시작해보자.

삽질 과정

나는

  1. 중간탭이 header와 닿았느지 알아야한다.
  2. 스크롤이 올라오다가 중간탭 위치가 되면 중간탭 고정이 풀려야한다.
  3. 현재 상품상세/리뷰/Q&A 영역이 상단에 고정된 중간탭과 닿았는지 알아야한다.

3번은 크게 어렵지 않았다. 뷰포트 위치를 헤더와 중간탭 아래 일부로 잡고 isIntersecting으로 callback 호출을 정하면 되니까. (근데 내가 해당 옵션을 useCallback에 옵션으로 줘가지고 옵션 설정이 안되서 작동 안되는 줄 알고 삽질했다. 껄껄껄껄... 괄호를 잘 보자..라는 교훈을 얻었다. 이거때문에 옵션 이래저래 바꿔본다고 이틀 쓴거 같다.)

1번은 3번과 동일한 방식으로 해결할 수 있긴하다. 헤더 바로 아래에 뷰포트를 주고 여기에 위치하는지 보면 그대로 중간탭을 고정시킬 수 있다. 하지만 이렇게 쉽게 풀렸다면 이 이야기가 삽질LOG가 되지 않았을거다..ㅎㅎ
2번은 뷰포트 사이즈 설정으로 되는게 아니라 entry.isIntersecting 만으론 해결할 수 없었다. 저걸로 해결해볼라고 했는데 일단 난 실패했다. (물론 내가 방법을 못 찾아서 그럴 수 있음 주의) 그래서 각종 entry를 다 써봤담.

결론적으론 IntersectionObserverEntry.boundingClientReact 를 썼다. getBoundingClientReact() 와 동일하게 동작하는데 reflow는 발생하지 않는다!! bottom을 기준으로 bottom이 10보다 작아지면 상단 고정이 되게 했다. 0으로 해도 되지만 오차를 어느정도 감안해서 나는 10으로 잡았다.

삽질 소감

지금보면 왜이렇게 삽질을 했지 싶은데 그때는 엄청난 고난과 역경이었다. 머릿속에 저 생각밖에 없어서 꿈도 꿀 정도였다. 허헣..그래도 내 능력이 하나 더 발전했다는 생각이 들어서 다행이다.
마지막엔 커스텀 훅으로 만들어서 무한스크롤 구현할 때도 쓰고 위에 중간탭 기능 구현할 때도 썼다. 무한스크롤도 페이지마다 중복 코드로 쓰이고 있었는데 이것도 커스텀 훅으로 바꿨다. 나중에 한번 리뷰해달라고 해서 리뷰 준비 중인데..떨린다. 리뷰가 필요할 만큼 대단한 기능은 아니지만 요청이 들어왔다는게 뿌듯하다. 맨날 블로그는 어떻게 끝내야할지 모르겠다. 여튼..끝..고생했다 나

-끝-

profile
이제는 병아리는 벗어나야하는 프론트개발자

0개의 댓글