[Vue] intersection observer로 스크롤 탐지하기

박기범·2022년 3월 14일
5
post-thumbnail

다양한 웹 페이지를 보다보면 스크롤에 따라서 이벤트가 발생하는 것을 본 적이 있을것이다.

참고로, 위의 웹 페이지(썸네일)는 Vue와 intersection observer로 직접 스크롤 탐지를 구현해낸 결과이다. (입사 과제로 자기소개 페이지를 작성했는데, 사실 디자인은 별 볼일 없다.)

물론 코어 JS (바닐라 JS)로 스크롤 이벤트에 이벤트리스너를 부착하여 매 스크롤을 탐지하면 간단하게 해결 가능하다.

하지만, 다 알고있지 않은가?

브라우저에서 스크롤 한번마다 매번 이벤트를 발생시키고, 그 이벤트를 지속적으로 리스닝하면 무슨일이 일어날지.

그래서 나는 다른 방법을 찾고싶었다.

그 해결책이 바로 intersection observer이다.

Intersection Observer

Intersection observer MDN 공식문서

당연히 모든 스크롤마다 이벤트리스너가 부착되고 그 스크롤을 탐지한다면 해당 페이지에 심각한 부하가 걸릴것이다. 거기다 하필 그 페이지가 렌더링이 느리다면, 정말 첩첩산중이 아닐 수 없다.

이럴때 필요한 것이 intersection observer(이하 iob)이다.

iob는 사용자가 감시하고자 하는 페이지의 요소가 특정 요소(브라우저의 viewport)와 교차되는 정도를 관찰하고, 설정해둔 비율 이상의 교차가 일어났을 때 실행되어야 하는 콜백 함수를 등록할 수 있다.

이를 그림을 통해 간단하게 살펴보면 다음과 같다.
그림으로 이해가 되었으면 좋겠다... 저장하고 보니 이미지가 너무 큰데..?

위와 같은 iob를 사용하기 위해서는 js에서 공식적으로 제공하는 객체를 이용할 수 있다.

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

let observer = new IntersectionObserver(callback, options);

위는 mdn에도 공개되어있는 예제 코드이다. 이 때 root 옵션은 null로 지정해두면 기본적으로 브라우저 viewport를 이용하게 된다.

rotMargin은 css margin과 비슷한 속성이며, root 속성의 여백을 의미한다.

마지막으로 threshold인데, 이부분이 핵심이다. iob가 observe하는 target이 root 옵션에서 지정한 요소(혹은 브라우저 viewport)와 얼마나 교차했을때 콜백 함수를 트리거하는지 결정하는 옵션이다. 1.0은 100%를 의미한다. (0.5라면 50%를 의미할것이다.)

let target = document.querySelector('#listItem');
observer.observe(target);

// the callback we setup for the observer will be executed now for the first time
// it waits until we assign a target to our observer (even if the target is currently not visible)

그리고 이 코드처럼 특정 target을 observe 할 수 있다. 이렇게 되면 등록이 완료된다. 아주 간편하다!

iob in Vue

이제는 Vue이다. SPA 컨셉에 맞춰 등장한 Vue는 당연하게도 컴포넌트화 및 코드 재사용을 기깔나게 지원한다. 이를 통해 개발자는 화면을 여러개의 컴포넌트로 나누어 편하게 유지보수 할 수 있다.

아이러니하게도, 문제점은 여기서 등장한다. 여러 파일로 나누어져 있는 코드에서, 개발자는 어디에서 옵저버를 등록할 것인가? 컴포넌트마다 일일이 옵저버 코드를 기술하여 옵저버를 등록할 것인가?

물론 그렇게 한다면 문제를 해결할 수는 있겠지만, 같은 코드를 지속적으로 반복할 가능성이 존재한다.

그래서 지금부터 iob를 vue component화 해서 관리 및 사용한 경험을 공유하고자 한다.

//triggerObserver.vue

<template>
  <div ref="triggerDiv">_</div>
</template>

<script>
export default {
  data() {
    return {
      observer: null,
      option: {
        root: null,
        threshold: 1,
      },
    };
  },
  methods: {
    handleIntersect: function (target) {
      if (target.isIntersecting) this.$emit(`triggerFadeIn`, this.OID);
    },
  },
  mounted() {
    this.observer = new IntersectionObserver((entries) => {
      this.handleIntersect(entries[0]);
    }, this.option);
    this.observer.observe(this.$refs.triggerDiv);
  },
};
</script>

<style scoped>
div {
  opacity: 0;
}
</style>

위는 본 게시글 첫 부분에 첨부한 내 자기소개 페이지의 일부 코드이다. 필자는 iob의 반복적인 이용을 위해 triggerObserver라는 이름의 vue 컴포넌트를 만들고, 이를 다른 컴포넌트에서 재사용했다.

우선 iob의 observer가 observe할 의미없는 요소를 하나 생성한다. 위의 코드는 div 태그를 하나 만들었다. 그리고 observe할 root 요소는 null로 지정하여 브라우저 viewport를 이용할 수 있도록 설정했다. 마지막으로 threshold는 1로 지정하여 해당 div 태그가 브라우저에 전부 표시되었을 때 trigger를 발동할 수 있도록 했다.

이 컴포넌트는 다른 웹페이지를 구성하는 컴포넌트들에서 자식 컴포넌트처럼 재활용 된다. 그리고 이 컴포넌트는 observe만을 위해서 존재하기에, 애니메이션 발생과 관련한 구체적인 코드를 작성하지 않고, 해당 구현을 부모 컴포넌트에 위임하기로 결정했다. 따라서 선택한 방법은 triggerObserver 컴포넌트가 사용되는 부모 컴포넌트로 이벤트를 emit 하는 방법이다.

handleIntersect 메소드를 보면, 특정 target이 옵션에 지정된 대로 root 요소와 교차되었을 때 수행하는 행동이 정의되어있다. 이 코드에서는 부모 컴포넌트로 triggerFadeIn 이벤트를 emit 하게 되어있다.
같이 넘겨주는 this.OID 파라미터는 결론적으로 쓸모 없어진 코드이다.. 왜 안지웠지..

하나 중요한것은 iob에 observe할 target을 넘겨주는 코드이다.
필자는 이 코드에서 파라미터를 this.$refs.triggerDiv 형태로 넘겼다. 즉, Vue의 ref를 이용한 것이다.

이게 왜 중요하냐고?

보통 iob를 이용할때는 document.querySelector를 이용해서 직접 dom 요소를 고르고 target으로 넘겨줬을 것이다. 그런데 문제는, 저 triggerObserver 컴포넌트는 재사용을 위해 만들어졌다는 것이다. 만약 querySelector를 이용하여 해당 div 태그를 선택하고 target으로 넘겨준다면, triggerObserver 컴포넌트가 아무리 많이 재사용이 되어도 document에 존재하는 첫번째 triggerObserver 컴포넌트의 div 요소만 선택되어 iob에 등록될것이다. 어떤 일이 일어날지 상상이 가는가?

결과를 설명하기 전에 먼저 부모 컴포넌트의 코드를 살펴보겠다.

<template>
  <transition v-on:enter="enter">
    <div v-if="show" ref="dreamWrapper" class="dreamWrapper">
      <h2>MY DREAM</h2>
      <section>
        ... (중략) ...
        <DreamObserver v-on:triggerFadeIn="fadeIn"></DreamObserver>
        ... (중략) ...
      </section>
    </div>
  </transition>
</template>

<script>
import TriggerObserver from "./observers/TriggerObserver.vue";
export default {
  name: "MyDream",
  components: {
    DreamObserver: TriggerObserver,
  },
  data() {
    return {
      show: false,
    };
  },
  methods: {
    enter: function (el) {
      el.style.opacity = 0;
    },
    fadeIn: function () {
      this.$refs.dreamWrapper.style = "transition: opacity 1s";
    },
  },
  mounted() {
    this.show = true;
  },
};
</script>

부모 컴포넌트에서는 triggerObserver 컴포넌트에서 emit하는 triggerFadeIn 이벤트를 리스닝하고, 그에 따라 특정 스타일 요소를 js로 집어넣어서 fade in 애니메이션을 부여한다.

이제 다시 돌아가서, observer의 target을 등록할때 document.querySelector를 사용하지 않는 이유를 논한다. 만약 이 함수를 사용하여 target을 등록한다면, document에서 가장 먼저 위치하는 triggetObserer 컴포넌트의 div만 지속적으로 select 될것이다. 그렇게 되면 각 부모 컴포넌트에 등록된 triggerObserver 컴포넌트는 독립적으로 존재하지만, 해당 컴포넌트의 iob가 observing 하는 target은 항상 document에서 처음으로 존재하는 triggerObserver 컴포넌트가된다. 즉, 한번 이벤트가 trigger 되면 모든 fade-in 애니메이션이 한번에 작동하게 되는것이다. 이때문에 vue의 ref를 이용하여 특정한 컴포넌트의 div만 추적할 수 있도록 하는것이 좋다.

이게 전부..?

물론 아니다. 현재의 형태는 triggerObserver 컴포넌트를 부모 컴포넌트의 특정 위치에 끼워넣는 형태이다. 그리고 iob는 끼워져있는 triggerObserver 컴포넌트를 감지한다. 이는 부모 컴포넌트의 dom 요소 배치에 영향을 줄 가능성이 생긴다.

따라서 필자는 vue의 slot을 활용하여 이 점을 개선해보고자 한다.
slot을 활용하면 특정 자식 컴포넌트 내에 전달하는 내용을 html 구조로써 명시적으로 기술할 수 있다.

다음의 예를 보자.

<!-- 부모 컴포넌트 -->
<div>
  <h1>나는 부모 컴포넌트의 제목입니다</h1>
  <my-component>
    <p>이것은 원본 컨텐츠 입니다.</p>
    <p>이것은 원본 중 추가 컨텐츠 입니다</p>
  </my-component>
</div>


<!-- 자식 컴포넌트 -->
<div>
  <h2>나는 자식 컴포넌트의 제목입니다</h2>
  <slot>
    제공된 컨텐츠가 없는 경우에만 보실 수 있습니다.
  </slot>
</div>

이 예시의 랜더링 결과는 다음과 같다.

<div>
  <h1>나는 부모 컴포넌트의 제목입니다</h1>
  <div>
    <h2>나는 자식 컴포넌트의 제목 입니다</h2>
    <p>이것은 원본 컨텐츠 입니다.</p>
    <p>이것은 원본 중 추가 컨텐츠 입니다</p>
  </div>
</div>

놀랍다!

이제 이 슬롯을 이용해 위의 triggetObserver 컴포넌트의 이용 방식을 개선하면 다음과 같을 것이다.


<template>
  <DreamObserver v-on:triggerFadeIn="fadeIn">
    <transition v-on:enter="enter">
      <div v-if="show" ref="dreamWrapper" class="dreamWrapper">
        <h2>MY DREAM</h2>
        <section>
          ... (중략) ...
        </section>
      </div>
    </transition>
  </DreamObserver>
</template>

이런식으로 사용한다면, 실제 화면에 보여지는 컴포넌트의 교차 비율을 기준으로 이벤트를 trigger할 수 있게 된다.

마무리

이렇게 Vue 컴포넌트화 된 observer 객체를 이용하여 스크롤을 탐지하고, 원하는 타이밍에 이벤트를 trigger 하는 방법을 알아보았다.
다음 게시글로 본 기능을 slot을 이용해 실제 개선하고 구현하는 내용을 담아볼까 한다.

참고자료

profile
원리를 좋아하는 개발자

1개의 댓글

comment-user-thumbnail
2022년 3월 14일

template 영역 안에 쓸수있는 transition 이라는 개념도 있었군요! 잘 봤습니다 :)

답글 달기