Web API Javascript IntersectionObserver 사용해 보기

여러 Web API 중 변경사항을 감지하는 Observer들이 존재합니다.
이 중 매우 유용하게 쓰일 수 있는 IntersectionObserver, MutationObserver 등이 있는데 이 중에서 IntersectionObserver를 사용해보고자 합니다.

IntersectionObserver 란?

IntersectionObserver는 Web API 중 하나로 Viewport나 정해진 영역과 다른 영역이 겹치는지, 얼마나 겹치는지 감지하는데 이용하는 API입니다.
대부분 Modern browser에서 간단하게 사용가능합니다.

 

해당 API의 존재를 알기 전에는 주로 addEventListener('scroll', callback); 을 이용해 스크롤 위치와 요소의 상대위치 및 크기를 고려해 직접 계산해야 하는 고생을 해야 했는데, 해당 API를 사용하면 더 쉽고 최적화된 성능으로 개발이 가능합니다.

IntersectionObserver 사용해 보기

IntersectionObserver는 Viewport와 등록된 요소가 겹치는지 감지하는 Observer입니다.
즉, 등록된 Element가 노출이 될 때, 노출이 해제될 때 callback으로 등록된 함수가 실행된다고 생각하시면 됩니다.

 

다양한 활용이 가능하지만 가장 많이 사용되는 방식인 scroll에 따른 상호작용을 해보겠습니다.

<div class="wrap">
    <div class="div">Element</div>
    <div class="div">Element2</div>
</div>
<style>
    .wrap {
        padding: 400px 0;
    }
    .wrap > div {
        width: 200px;
        height: 80vh;
        border: 1px solid #000;
        margin-bottom: 20px;
        padding: 20px;
    }
    .div:nth-of-type(1) {
        background-color: #fafafa;
    }
    .div:nth-of-type(2) {
        background-color: #eaeaea;
    }
</style>
<script>
const options = {};
const observer = new IntersectionObserver(console.log, options);
// └ callback에 console.log를 넣어 감지된 요소를 console에 보여주는 observer 생성
document.querySelectorAll('.wrap > div').forEach(element => observer.observe(element));
// └ .wrap > div 에 해당하는 element들을 감지하도록 등록
</script>

위와 같이 세팅하고 테스트해보면 화면에서 스크롤 시 div가 사라지고 나타날 때마다 IntersectionObserverEntry 객체가 배열에 담겨 console에 찍히는 것을 확인할 수 있습니다.
또 위에서는 options에 빈 객체를 입력했지만 여러 option으로 Observer를 설정할 수 있습니다.


IntersectionObserverEntryoptions에 대해서는 아래에서 자세히 보겠습니다.

IntersectionObserverEntry 알아보기

  1. boundingClientRect
    Element.getBoundingClientRect()를 통해 얻을 수 있는 DOMRectReadOnly객체를 가져옵니다.
  2. intersectionRect
    DOMRectReadOnly 객체를 가져오나 겹치는 영역에 대해서만 값을 표시합니다.
    (intersection 밖의 영역이면 모든 값이 0으로 표시되고 잘린다면 보이는 부분의 Height나 Width만 나타내줌)
    아래 이미지를 보시면 더 쉽게 이해 가능합니다.
  3. intersectionRatio
    intersection에 겹치는 영역의 비율입니다.
    조금이라도 겹치지 않으면 0, Viewport에 완전히 들어간다면 1로 0 ~ 1의 정수값을 가집니다.
  4. isIntersecting
    intersectionthreshold 이상의 영역이 겹치는지 여부입니다.
    observer를 생성할 때 option으로 threshold값을 명시해줄 수 있습니다. threshold의 기본 값은 0.0으로 0.0일 때는 1px이라도 영역이 겹치면 true가 됩니다. 만약 threshold값이 여러 개일 경우 최솟값을 기준으로 값을 보여줍니다.
  5. rootBounds
    감지영역의 DOMRectReadOnly를 가져옵니다.
    기본적으로 Viewport이나 observer를 생성할 때 입력한 options에 따라 달라질 수 있습니다.
  6. target
    관찰한 Element입니다.
    style을 조정하거나 텍스트를 수정하는 등으로 이용할 수 있습니다.
  7. time
    time origin으로부터 해당 감지가 발생한 시점까지의 시간차를 ms단위로 나타낸 값입니다.
    time origin는 일반적으로 페이지가 호출된 시점으로 생각하면 됩니다. (Window 객체가 생성된 시점)

Options 알아보기

  1. root
    root로 지정한 Element를 Viewport로 사용합니다.
    이때 document는 cross browsing 문제로 사용을 지양하는 것이 좋습니다. (Safari, Android Firefox 에서 사용 불가)
    기본값은 null로 기본 Viewport를 사용합니다.
  2. rootMargin
    입력된 값으로 Viewport를 확장합니다.
    css margin입력하는 것처럼 입력 가능하고 px이나 %를 사용해야 합니다.
    기본값은 '0px 0px 0px 0px'입니다.
  3. threshold
    0.0 이상, 1.0 이하의 숫자 단일 값이나 해당 값으로 이루어진 배열로 감지하는 Element의 원본크기와 Viewport에 보이는 영역 크기 비가 해당 값일 때 callback을 실행합니다.
    즉, 해당 값보다 작아질 때 커질 때 각각 감지하게 되며 값이 여러 개일 경우 (배열 안에 나열) 각각의 값에 도달할 때마다 callback이 실행됩니다.
    기본값은 [0.0]입니다.

IntersectionObserver 응용하기

위의 예시는 Scroll event로도 쉽게 구현이 가능한 예시였습니다.
하지만 Scroll event로는 영역이 얼마나 겹치는지 자세하게 계산이 필요한 경우 점점 복잡한 코드를 요구합니다.


아래 예제들을 통해 IntersectionObserver가 얼마나 간단하게 구현 가능한지 확인해 보겠습니다.

<div class="wrap">
    <section class="section1">
        <h2>title1</h2>
        <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit</p>
        <p>sed do eiusmod tempor incididunt ut labore et dolore magna aliqua</p>
        <p>Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris</p>
        <p>nisi ut aliquip ex ea commodo consequat</p>
    </section>
    <section class="section2">
        <h2>title2</h2>
        <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit</p>
        <p>sed do eiusmod tempor incididunt ut labore et dolore magna aliqua</p>
        <p>Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris</p>
        <p>nisi ut aliquip ex ea commodo consequat</p>
    </section>
    <section class="section3">
        <h2>title3</h2>
        <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit</p>
        <p>sed do eiusmod tempor incididunt ut labore et dolore magna aliqua</p>
        <p>Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris</p>
        <p>nisi ut aliquip ex ea commodo consequat</p>
    </section>
</div>
<style>
    body {
      margin: 0;
      }
    .wrap > section {
        width: 100%;
        height: 80vh;
        border: 1px solid #000;
        padding: 20px;
        box-sizing: border-box;
    }
    .wrap > section > * {
        position: relative;
        opacity: 0;
        top: 10px;
        transition: all 500ms ease;
    }
    .wrap > section.on > * {
        opacity: 1;
        top: 0;
    }
</style>
<script>
const observer = new IntersectionObserver(entries => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            entry.target.classList.add('on');
            // 영역 겹침이 50% 이상 발생하면 -> class 'on' 추가
        } else {
            entry.target.classList.remove('on');
            // 영역 겹침이 50% 미만으로 줄어들면 -> class 'on' 삭제
        }
    });
}, {threshold: [0.5]});
document.querySelectorAll('.wrap > section').forEach(element => observer.observe(element));
</script>

각 세션의 50% 영역이 화면에 노출될 때 하위 요소들이 fade-in 하면서 아래에서 위로 떠올라 자연스럽게 노출되는 것을 확인할 수 있습니다.


조금 더 복잡하게 조건을 주게 되면

<div id="pos"><!-- 화면 내 세션이 몇 번 세션인지 --></div>
<div class="wrap">
    <section class="section1" data-tab="1">
        <h2>title1</h2>
        <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit</p>
        <p>sed do eiusmod tempor incididunt ut labore et dolore magna aliqua</p>
        <p>Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris</p>
        <p>nisi ut aliquip ex ea commodo consequat</p>
    </section>
    <section class="section2" data-tab="2">
        <h2>title2</h2>
        <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit</p>
        <p>sed do eiusmod tempor incididunt ut labore et dolore magna aliqua</p>
        <p>Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris</p>
        <p>nisi ut aliquip ex ea commodo consequat</p>
    </section>
    <section class="section3" data-tab="3">
        <h2>title3</h2>
        <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit</p>
        <p>sed do eiusmod tempor incididunt ut labore et dolore magna aliqua</p>
        <p>Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris</p>
        <p>nisi ut aliquip ex ea commodo consequat</p>
    </section>
</div>
<style>
    body {
        margin: 0;
    }
    .wrap {
        padding: 60px 0 0;
    }
    .wrap > section {
        width: 100%;
        height: 80vh;
        border: 1px solid #000;
        padding: 20px;
        box-sizing: border-box;
    }
    .wrap > section > * {
        position: relative;
        opacity: 0;
        top: 10px;
        transition: all 500ms ease;
    }
    .wrap > section.on > * {
        opacity: 1;
        top: 0;
    }
    #pos {
        position: fixed;
        top: 0;
        z-index: 1;
        width: 100%;
        height: 60px;
        padding: 20px;
        background-color: #d9d9d9;
        border-bottom: 1px solid #222;
        box-sizing: border-box;
    }
</style>
<script>
const observer = new IntersectionObserver(entries => {
    entries.forEach(entry => {
        if (entry.intersectionRatio >= 0.5) {
            entry.target.classList.add('on');
            // 영역 겹침이 50% 이상 발생하면 -> class 'on' 추가
        }
        if (entry.intersectionRatio <= 0.3) {
            entry.target.classList.remove('on');
            // 영역 겹침이 30% 이하로 줄어들면 -> class 'on' 삭제
        }
        if (entry.intersectionRatio >= 0.7) {
            document.getElementById('pos').innerText = `This is session ${entry.target.dataset.tab}`;
            // 영역 겹침이 70% 이상 발생하면 -> #pos의 텍스트 변경
        }
    });
}, {threshold: [0.3, 0.5, 0.7], rootMargin: '-60px 0px 0px'});
document.querySelectorAll('.wrap > section').forEach(element => observer.observe(element));
</script>

세션의 intersectionRatio가 0.5에 도달 시 자연스럽게 콘텐츠를 노출, 0.3미만으로 떨어질 시 컨텐츠 숨기기
현재 세션을 알려주는 부분을 만든 뒤 0.7에 도달 시 갱신시켜 주기
이런 식으로 좀 더 세세하게 변화를 적용시켜줄 수 있습니다.

 

좀 더 극단적으로 threshold를 늘려주면

<div class="wrap">
    <section class="section1" data-tab="1">
        <h2>title1  [<span class="ratio"></span>]</h2>
    </section>
    <section class="section2" data-tab="2">
        <h2>title2  [<span class="ratio"></span>]</h2>
    </section>
    <section class="section3" data-tab="3">
        <h2>title3  [<span class="ratio"></span>]</h2>
    </section>
</div>
<style>
    body {margin: 0;}
    .wrap > section {
        width: 100%;
        height: 80vh;
        border: 1px solid #000;
        padding: 20px;
        box-sizing: border-box;
    }
</style>
<script>
const threshold = [];
for (let i = 0; i < 1; i += 0.01) {
    threshold.push(i);
}
threshold.push(1);
// threshold를 0 에서 1까지 0.01 단위로 threshold 설정
// 정수로 1을 push 해준 이유는 float 0.01을 100번 더했을 때 1이 아니기 때문
const observer = new IntersectionObserver(entries => {
    entries.forEach(({target, intersectionRatio}) => {
        target.getElementsByClassName('ratio')[0].innerText = `${Math.round(intersectionRatio * 100)}%`;
        // 각 세션의 화면 노출 비율을 %단위로 표시
    });
}, {threshold});
document.querySelectorAll('.wrap > section').forEach(element => observer.observe(element));
</script>

위와 같은 사용도 가능합니다.


Web API 중 하나인 IntersectionObserver를 사용해 보았습니다.
Scroll event를 사용하는 것보다 성능면에서도 우수하고 코드도 직관적이며 짧습니다.
여러모로 반응형 인터페이스를 구성하는데 매우 효과적인 API입니다.

 

글에 오류가 있거나 추가할 내용이 있다면 언제라도 댓글을 남겨주세요!

 

다음에는 다른 Observer인 MutationObserver를 소개하는 글을 작성해 보겠습니다.

참고

mdn web docs - Intersection Observer API

HTML Object overflow 감지

HTML 내 요소에서 자식요소가 부모요소보다 클 때 css 상에서 overflow 속성을 정의해 처리를 할 수 있습니다.
하지만 이를 javascript에서 동적으로 처리를 하고 싶다면 다른 방법을 이용해야 합니다.
이때 2가지 방법을 이용할 수 있습니다.

1. scrollWidth 이용

위 이미지를 참고하면 clientWidthscrollWidth를 비교하여 scrollWidthclientWidth보다 크다면 overflow가 발생했음을 알 수 있습니다.


아래의 예제를 따라 해보면 문제없이 결과를 얻을 수 있습니다.

<div class="wrap">
    <!-- short는 200px 미만으로 overflow가 발생하지 않는다. -->
    <div id="short">Lorem ipsum dolor sit amet</div>
    <div id="long">consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</div>
</div>
<style>
    .wrap > div {
        width: 200px;
        white-space: nowrap;
    }
</style>
const short = document.getElementById('short');
const long = document.getElementById('long');
// └ getElementById를 이용해 Element 객체 가져오기

const isOverflow = element => {
    const {clientWidth, scrollWidth} = element;
    // └ 구조분해 할당으로 clientWidth, scrollWidth 값 가져오기
    return clientWidth < scrollWidth;
}

console.log(`is overflow? ${isOverflow(short)}`); 
// 출력: is overflow? false
console.log(`is overflow? ${isOverflow(long)}`);  
// 출력: is overflow? true

좀 더 응용해 보면

const short = document.getElementById('short');
const long = document.getElementById('long');

const getDiff = element => {
    const {clientWidth, scrollWidth} = element;
    const diff = scrollWidth - clientWidth;
    return diff ? `overflow ${diff}px` : 'not overflow';
}

console.log(getDiff(short)); 
// 출력: not overflow
console.log(getDiff(long));  
// 출력: overflow 439px

이렇게 얼마나 콘텐츠가 넘치는지 체크할 수 있습니다.

2. cloneNode 이용

두 번째 방법은 Node객체의 instance methods 중 하나인 cloneNode를 이용한 방법입니다.

Element객체는 Node객체를 상속받은 객체로 Node객체의 instance methods를 이용 가능합니다.
cloneNode는 한 Node객체를 그대로 복사해오는 method로 parameter로 boolean을 받아 자식요소들도 복제할지 선택할 수 있습니다.

 

복제해온 Node의 style에 길이를 max-content로 지정해 길이를 구하고 제거하는 방식으로 overflow를 감지할 수 있습니다.

<div class="wrap">
    <!-- short는 200px 미만으로 overflow가 발생하지 않는다. -->
    <div id="short">Lorem ipsum dolor sit amet</div>
    <div id="long">consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</div>
</div>
<style>
    .wrap > div {
        width: 200px;
        white-space: nowrap;
    }
</style>

바로 1번 방식의 응용 예제를 따라 해보면

const short = document.getElementById('short');
const long = document.getElementById('long');
// └ getElementById를 이용해 Element 객체 가져오기

const getCloneWidth = element => {
    const clone = element.cloneNode(true);
    // └ parameter로 true를 전달해 자식요소를 함께 복사
    clone.style.width = 'max-content';
    // └ 콘텐츠가 최대로 커질 수 있는 너비로 변경
    element.parentNode.appendChild(clone);
    // └ 원본 요소의 부모요소에 append해 상속받는 style도 적용하기
    const cloneWidth = clone.clientWidth;
    // └ 복제된 요소의 길이 구하기
    element.parentNode.removeChild(clone);
    // └ 복제된 요소 제거
    return cloneWidth;
}


const getDiff = element => {
    const width = element.clientWidth;
    const cloneWidth = getCloneWidth(element);
    const diff = cloneWidth - width;
    return diff ? `overflow ${diff}px` : 'not overflow';
}

console.log(getDiff(short)); 
// 출력: overflow -15px
console.log(getDiff(long));  
// 출력: overflow 439px

long의 결과는 1번 방식과 같은 결과를 보이나 short의 경우가 -15px로 나타남을 볼 수 있습니다.

 

차이는 콘텐츠의 최대 너비를 구하는 과정에서 발생하는데 scrollWidth는 요소가 아무리 작아도 clientWidth보다 작아지지 않습니다.

그러나 cloneNode방식을 이용했을 때는 복제된 Node의 너비 자체를 콘텐츠의 크기로 변경한 것으로 getCloneWidth에서 반환된 크기는 콘텐츠의 크기가 됩니다.

 

즉, Lorem ipsum dolor sit amet의 길이가 185px일 때 scrollWidth는 200px이 되는것이고 getCloneWidth의 반환 값은 185px이 되는 것입니다.


추가로 콘텐츠 크기를 구할 수 있는 cloneNode 방식을 Element에 바인딩할 수 있습니다.

Object.defineProperty(Element.prototype, 'contentWidth', {get() {
    const clone = this.cloneNode(true);
    clone.style.width = 'max-content';
    this.parentNode.appendChild(clone);
    const cloneWidth = clone.clientWidth;
    this.parentNode.removeChild(clone);
    return cloneWidth;
}});

위와 같이 작업해두면 아래처럼 간단하게 이용 가능합니다.

const element = document.getElementById('someId');
console.log(element.contentWidth);
const elements = document.querySelectorAll('someSelector');
elements.forEach(element => console.log(element.contentWidth));

마무리로 두가지 방법을 요약하자면

감지방식 scrollWidth cloneNode
콘텐츠 크기 < clientWidth clientWidth 콘텐츠 크기
성능 빠름(scrollWidth값을 읽기만 하면 됨) 느림(DOMcloneNode를 그려야함)
추천 상황 일반적으로 사용 정확한 콘텐츠 크기를 비교해야할 때

로 볼 수 있습니다.

위 방법들을 이용해 Javascript에서 HTML Element에 Overflow가 발생했는지 간단히 알아볼 수 있습니다.

+ Recent posts