[TIL] IntersectionObserver에 대해 알아보자

Ben·2022년 8월 26일
0

Today I Learned

목록 보기
47/57
📌 모든 코드는 바닐라 자바스크립트를 기준으로 작성하였습니다.

IntersectionObserver란?

target 요소와 상위, 또는 document viewport 내의 변화를 비동기적으로 관찰할 수 있는 API이다.

등장 배경

무한 스크롤 또는 lazy loading을 구현하려고 할 때, 기존에는 scroll event를 이용하여 구현하였다.

window.addEventListener('scroll', callback);

그러나 scroll 이벤트가 발생할 때마다 콜백 함수가 실행되어, 많은 성능 저하가 일어났다. scroll을 한번 할 때, scroll이 완전히 멈출 때까지 이벤트가 발생하기 때문에, debouncing이나 requestAnimationFrame를 이용하여 따로 최적화 하는 방식을 사용했었다.

또한, 요소의 크기를 구하기 위하여 getBoundingClientRect() 메서드를 호출 시 reflow가 많이 발생하는 문제가 있다.

그러나 scroll을 이용하여 UI를 구성하는 경우가 적지 않기 때문에, 성능상에 이점을 취할 수 있으면서, 신뢰할 수 있는 api의 필요성이 대두되었는데 그것이 바로 Intersection Observer이다.

IntersectionObserver의 원리

IntersectionObserver는 루트 요소와 타겟 요소를 관찰한다. 다음과 같이 작성할 수 있다.

const io = new IntersectionObserver(callback, options)
  • callback은 관찰 대상의 가시성에 변화가 감지되면 실행되는 콜백함수이다.
  • options는 관찰자를 초기화할 때 사용할 수 있는 여러 옵션들이다.
const options = {
  root: document.querySelector('.App'),
  rootMargin: '1rem',
  threshold: 0.5
}
  • root: 관찰하려는 대상의 가시성의 변화를 감시할 때 사용하는 root 요소
  • rootMargin: root가 가진 margin
  • threshold: 요소가 얼마나 보여진 뒤에 콜백 함수를 실행할 것인지 결정. 0이면 1px이라도 보이면 callback 함수가 실행되고, 1일 경우 요소가 100% 화면에 노출된 이후에 callback 함수가 실행된다.

일반적으로 다음의 형식으로 사용한다.

const io = new IntersectionObserver((entries, observer) => {}, options)

IntersectionObserverIntersectionObserverCallback type을 가지며, 해당 콜백함수에 entries, observer 두 인자를 넘겨준다. entries는 IntersectionObserverEntry[]인데, 각 entry에는 관찰 대상이 화면과 교차되고 있는 상태인지 알려주는 boolean 값이므로, 가장 보편적으로 사용되는 값이다.

const io = new IntersectionObserver((entries, observer) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        // do something
      }
    });
  });

IntersectionObserver를 어디에 사용할 수 있을까?

일반적으로 사진의 lazy-loading 처럼 렌더링 최적화나, 무한 스크롤을 구현하기 위해 사용된다.

IntersectionObserver로 Lazy-loading을 구현하기

일단 lazy-loading을 IntersectionObserver로 구현해보자.

다음과 같은 App 컴포넌트 안에, 박스 컴포넌트 50개가 있다고 생각해보자.

// Box.js
function Box({ $target }) {
  this.element = document.createElement('div');
  this.element.className = 'box';
  $target.appendChild(this.element);
}

export default Box;

// App.js
import Box from './Box.js';

function App({ $target }) {
  // box 100개 등록
  const boxes = Array(100).fill(new Box({ $target }));
}

export default App;
* {
  box-sizing: border-box;
}

.box {
  width: 200px;
  height: 200px;
  margin: 1rem;
  background-color: lime;
}

.lazy {
  background-color: royalblue;
}

스크롤을 내릴 때 50%이상 보일 경우 색깔이 변하는 로직을 IntersectionObserver를 이용하여 구현해 보기로 한다. 박스 배열에 존재하는 모든 박스를 관찰하고, 교차할 때 동작을 정의해주면 된다.

import Box from './Box.js';

function App({ $target }) {
  // box 100개 등록

  for (let i = 0; i < 100; i += 1) {
    new Box({ $target });
  }

  const io = new IntersectionObserver(
    (entries, observer) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          // 이미 노출되었기 때문에 더이상 관찰하지 않는다.
          // unobserve에 entry.target
          observer.unobserve(entry.target);
          entry.target.classList.add('lazy');
        }
      });
    },
    { threshold: 0.5 },
  );

  document.querySelectorAll('.box').forEach((box) => {
    io.observe(box);
  });
}

export default App;

잘 동작함을 확인할 수 있다.

이와 같은 원리로 서버에서 내려오는 사진의 lazy loading을 구현할 수 있다.

lazy loading을 구현하기 위해서는 placeholder가 필요하다. 또한, 높이 값이 주어져 있어야 한다.

Step

  1. IntersectionObserver로 모든 아이템들을 구독한다.

  2. 해당되는 아이템이 viewport와 교차한다면, img 태그에 src를 삽입한다.

  3. 삽입 후 placeholder는 보이지 않게 처리한다.

  4. 기본 html 마크업을 한다.

<li class="item">
  <img data-src=""/>
  <div class="placeholder"></div>
</li>
  1. Intersection Observer 로직을 구성한다.
// intersection observer
  const io = new IntersectionObserver(
    (entries, observer) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          // entry.target을 unobserve에 넣으면 not element error가 발생함
          const { target } = entry;
          // target 구독 해제
          observer.unobserve(target);
          const $img = target.querySelector('img');
          const { src } = $img.dataset;
          if (!src) return;
          // img tag에 src 삽입
          $img.setAttribute('src', src);
          const $placeholder = target.querySelector('.placeholder');
          if (!$placeholder) return;
          // plarceholder 제거
          $placeholder.classList.add('fade-out');
        }
      });
    },
    {
      threshold: 0.5,
    },
  );

  this.element.querySelectorAll('.item').forEach((item) => io.observe(item));
  1. 스타일 입혀주기
* {
  box-sizing: border-box;
}

.search-result {
  display: grid;
  grid-template-columns: repeat(4, minmax(250px, 1fr));
  gap: 1rem;

  list-style: none;
}

.item {
  position: relative;
  height: 300px;
}

.item > img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.item .placeholder {
  position: absolute;
  width: 100%;
  height: 100%;
  background-color: #eee;
  z-index: 2;
}

.fade-in {
  display: block;
}

.fade-out {
  display: none;
}
  1. 컴포넌트 만들기
// App.js
import SearchResult from './SearchResult.js';

function App({ $target }) {
  this.state = [];
  this.element = $target;

  this.setState = (nextState) => {
    this.state = nextState;
    this.render();
  };

  this.render = () => {
    this.element.innerHTML = ``;
    new SearchResult({ $target, initialState: this.state });
  };

  this.setState(
    Array(100).fill(
      'https://www.sisa-news.com/data/photos/20200936/art_159912317533_32480a.jpg',
    ),
  );
}

export default App;

// SearchResult.js
import Image from './Image.js';

// initialState: string[]
function SearchResult({ $target, initialState }) {
  this.element = document.createElement('ul');
  this.element.className = 'search-result';
  $target.appendChild(this.element);

  this.state = initialState;

  this.setState = (nextState) => {
    this.state = nextState;
    this.render();
  };

  this.render = () => {
    // 초기화
    this.element.innerHTML = ``;
    this.state.map((imgSrc) => {
      return new Image({ $target: this.element, initialState: imgSrc });
    });
  };

  this.render();

  // intersection observer
  const io = new IntersectionObserver(
    (entries, observer) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          const { target } = entry;
          observer.unobserve(target);
          const $img = target.querySelector('img');
          const { src } = $img.dataset;
          if (!src) return;
          $img.setAttribute('src', src);
          const $placeholder = target.querySelector('.placeholder');
          if (!$placeholder) return;
          $placeholder.classList.add('fade-out');
        }
      });
    },
    {
      threshold: 0.5,
    },
  );

  this.element.querySelectorAll('.item').forEach((item) => io.observe(item));
}

export default SearchResult;

// Image.js
function Image({ $target, initialState }) {
  this.element = document.createElement('li');
  this.element.className = 'item';
  $target.appendChild(this.element);

  this.state = initialState;
  this.setState = (nextState) => {
    this.state = nextState;
    this.render();
  };

  this.render = () => {
    this.element.innerHTML = `
      <img data-src="${this.state}" />
      <div class="placeholder"></div>
    `;
  };

  this.render();
}

export default Image;
  • 50% 미만으로 보였을 때

  • 50% 이상 보였을 때

요약

  • IntersectionObserver를 이용하여 lazy loading을 구현할 수 있다.
  • lazyloading을 구현하기 위해서는 고정된 영역의 placeholder가 필요하다.

IntersectionObserver로 무한 스크롤 구현하기

무한 스크롤 역시 Intersection Observer로 구현할 수 있으나, 방법은 약간 다르다.

가장 마지막 요소 또는 바닥 요소를 관찰한 뒤 해당 요소가 viewport에 드러나게 될 경우, callback 함수를 실행시켜 주면 된다.

이때, IntersectionObserver로 요소들을 추가해줄 때 중요한 점은, 기존에는 this.setState를 이용하여 데이터가 업데이트 될 때 렌더링을 하는 방식을 사용했는데, 그러다 보니 스크롤을 내리면 위쪽 요소들이 초기화되어 다시 loaded 되기 전으로 돌아가는 문제가 발생했다.

이 문제를 해결하기 위하여 데이터와 완전히 동기화 시키는 렌더링 방식을 포기하고, fetchNextData라는 함수를 만들고, 해당 함수에서 데이터를 바탕으로 element 배열을 만든 후, 해당 배열을 this.element.append(...array) 방식을 이용하여 넣어주는 방식을 사용하였다.

그리고 이 때 lazy loading을 위해 observe를 어떻게 하면 되지 고민했는데, 일단 렌더링 하고 보니 굳이 lazy-loading을 하지 않아도 원하는대로 잘 떠서, 따로 observe 처리는 하지 않았다.

import Image from './Image.js';

// initialState: string[]
function SearchResult({ $target, initialState }) {
  this.element = document.createElement('ul');
  this.element.className = 'search-result';
  $target.appendChild(this.element);

  this.state = initialState;

  this.setState = (nextState) => {
    this.state = nextState;
    this.render();
  };

  this.render = () => {
    // 초기화
    this.element.innerHTML = ``;
    this.state.map((imgSrc) => {
      return new Image({ $target: this.element, initialState: imgSrc });
    });
  };

  this.render();

  const lazyLoadCallback = (entries, observer) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        const { target } = entry;
        observer.unobserve(target);
        const $img = target.querySelector('img');
        const { src } = $img.dataset;
        if (!src) return;
        $img.setAttribute('src', src);
        const $placeholder = target.querySelector('.placeholder');
        if (!$placeholder) return;
        $placeholder.classList.add('fade-out');
      }
    });
  };

  const fetchNextData = () => {
    return Array(25)
      .fill(
        'https://www.sisa-news.com/data/photos/20200936/art_159912317533_32480a.jpg',
      )
      .map((url) => {
        const item = document.createElement('li');
        item.className = 'item';
        item.innerHTML = `
          <img src="${url}" />
          <div class="placeholder" style="display: none;"></div>
        `;
        return item;
      });
  };

  const infiniteScrollCallback = (entries, observer) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        const { target } = entry;
        observer.unobserve(target);
        const newElements = fetchNextData();
        this.element.append(...newElements);
        observer.observe(newElements[newElements.length - 1]);
      }
    });
  };

  // intersection observer
  const lazyIo = new IntersectionObserver(lazyLoadCallback, {
    threshold: 0.5,
  });
  const infiniteScrollIo = new IntersectionObserver(infiniteScrollCallback);
  const $items = this.element.querySelectorAll('.item');

  $items.forEach((item) => lazyIo.observe(item));
  infiniteScrollIo.observe($items[$items.length - 1]);
}

export default SearchResult;

코드가 잘은 동작하지만, 추후 리팩토링의 여지가 많다. 바닐라 자바스크립트로 앱을 구성하면, 로직과 UI의 분리가 어려운게 참 아쉽다.

느낀 점

  • 그동안 리액트에서만 작업을 하고, 무한 스크롤이나, 스크롤 관련 이벤트들을 처리해보지 않았는데, 볼 때는 생각보다 간단한데? 라고 느낀 것들이, 실제로 구현할 때는 너무나 고려해야 할 것이 많았다.
  • 특히 바닐라 자바스크립트에서 원하는 대로 구현하는 것이 어려웠다. 그러나 프론트엔드 개발자로서 바닐라 자바스크립트를 자유자재로 다룰 수 있는 것이 굉장히 중요하다고 생각하여, 과제 테스트를 준비하고 있을 때 틈을 내서 글을 작성하게 되었다.
  • 추후 리액트 환경에서도 무한 스크롤과 lazy-loading을 구현해볼 생각이다.
profile
New Blog -> https://portfolio-mrbartrns.vercel.app

0개의 댓글