본문 바로가기

WEB&서버

[WEB] MutationObserver API

https://developer.mozilla.org/ko/docs/Web/API/MutationObserver

 

MutationObserver - Web API | MDN

MutationObserver 는 개발자들에게 DOM 변경 감시를 제공합니다. DOM3 이벤트 기술 설명서에 정의된 Mutation Events 를 대체합니다.

developer.mozilla.org

 WEB API 중에는 'Observer'이라는 표현이 붙는 종류의 API들이 존재하는데, 이들은 등록된 특정 이벤트가 감지되면 콜백에 정의된 동작을 수행한다. MutationObserver은 이러한 API 중 하나로,  DOM 상에서 발생하는 특정 변경사항을 감시하는 역할을 수행한다.


사용법

const mutObserver = new MutationObserver(callback);

 MutationObserver은 이벤트가 발생했을 때 실행할 콜백 함수를 요구한다. 콜백 함수는 다음 구조를 가진다.

const callback = (mutations, observer) => {
// some codes...
};
  • mutations: MutationRecord [ ] 타입으로, 변경 사항에 대한 정보들을 담고 있다. 
  • observer: 현재 MutationObserver을 가리킨다.

MutationRecord

 MutationRecord는 각 노드에 발생한 변경사항을 담고 있다. 

MutationRecord.type

 변경 사항은 3가지 타입으로 나뉜다.

  • attributes: 엘리먼트의 속성(attribute)이 변경되는 경우. ex) class, style 등
  • characterData: 노드의 문자열이 변경되는 경우
  • childList: 노드 트리에 변경이 발생하는 경우 ex) 노드 삽입 / 삭제 등

 이러한 타입들은 콜백 함수 내부에서 타입에 따른 조건문 처리를 할 때 유용하다. 

MutationRecord.attributeName

  type === 'attributes' 일 때, 현재 변경된 속성의 이름을 반환한다.
예를 들어 class='apple'인 엘리먼트에 'ball'이라는 클래스를 추가해서 class='apple ball'이 되었다면, 이는 class 속성이 변경된 것이므로 attributeName로 'class'가 반환된다. 이러한 특성을 이용하면 각 속성의 변경에 따라 다른 동작을 수행하도록 구현할 수 있다.

MutationRecord. [addedNodes, removedNodes]

 type === 'childList' 일 때 현재 노드에 대해 추가되거나 제거된 노드 목록을 반환한다.

MutationRecord.target

 변화가 발생한 노드 자신을 반환한다.

이외 프로퍼티에는 다음 같은 것들이 있으니, 필요한 경우는 공식문서를 참고하자.

  • attributeNamespace
  • nextSibling
  • previousSibling
  • oldValue: 변하기 전 값을 반환한다.

mutationObserver.observe

 MutationObserver 객체는 생성할 때 콜백 함수만 요구한다. 콜백 함수는 어떻게 데이터를 정의할 뿐이므로, observer 객체가 감시할 대상범위는 아직 지정되어 있지 않다. observe 메서드는 이러한 요소들을 지정한다.

   mutObserver.observe(target, {
            childList: true,
            subtree: true,
            attributeFilter: ['data-ad']
   });
  • target: 감시할 노드에 해당한다. querySelector 등의 방법으로 얻은 노드를 전달하면 된다.
  • options: 감시할 범위를 지정한다.
    • childList: 해당 노드의 자식 노드들의 변동 사항을 감시한다.
    • attributes: 모든 속성을 감시한다. 
    • characterData: 데이터를 감시한다.
    • attributeFilter: 감시할 속성 목록을 구체적으로 지정한다. 일부 속성의 변동에만 관심이 있는 경우 사용한다.
    • subtree: 해당 노드가 구성하는 트리의 하위 요소들(자식 포함) 모두가 감시 대상이 된다.

 data attribute 등 일부 속성 값이 변하는 경우만 감시하고 싶은 경우 attributeFilter에 해당 속성을 등록한다. 위 코드에서는 전체 속성 대신 'data-ad' 속성에 대한 변동만 감시하기 위해 attributeFilter을 설정했다. 이외로 해당 노드의 하위 노드들의 변동사항(추가나 삭제)를 감시하기 위해 childList: true로 설정했다.

 옵션에서 지정할 수 있는 값 중 subtree을 제외한 요소는 MutationRecord.type에서 정의하고 있는 3개 타입에 대한 감시를 수행할 것인지 여부를 결정한다. 반면 subtree는 이러한 감시를 하위 노드들에 대해서도 수행할지 여부를 결정한다.

 실제로 사용해보니 subtree: true로 지정되지 않으면 해당 노드의 자식을 감시하지 않으므로 childList: true로 지정하더라도 이벤트가 발생하지 않는다. 따라서 childList에 대한 감시를 수행하기 위해서는 subtree: tree로 설정해야 하는 것 같다.

mutationObserver.disconnect

 감시를 중단한다.

mutationObserver.takeRecords

 노드의 변동 사항을 담고 있는 MutationRecord 배열을 큐로부터 받아오는 함수라고 한다. MutationRecord 배열은 콜백에 의해 처리되는데, 이벤트 루프 기반으로 동작하는 웹 브라우저의 특성 상 콜백 함수는 현재 진행되는 작업이 모두 끝난 후 별도의 큐에서 꺼내 실행된다. 이 경우 이벤트 발생에 즉시 반응한다는 보장이 불가능하며, 필연적으로 지연이 발생한다. 해당 상황에서 비동기적으로 동작하는 콜백에게 작업을 맡기는 대신에 동기적으로 처리하고자 할 때 사용할 수 있겠다. 

예전에 아래와 같은 논의가 있었으며, 지연을 줄이는 것이 핵심이라는 느낌으로 종결되었다.

https://github.com/w3c/IntersectionObserver/issues/133


사용 예시

 이 API를 보자마자 생각난 것은 광고 차단을 방지하는 방법이었다. 인터넷을 보다 보면 애드블럭을 사용해도 유난히 광고가 많은 사이트들이 존재한다. 특히 특정 사이트는 개발자 도구를 사용해도 광고가 다시 생성되는데, MutationObserver에서 childList의 변동을 감시하도록 코딩하면 이 동작을 쉽게 구현할 수 있을 것으로 보였다. 

See the Pen Mutation Observer Example by blaxsior (@blaxsior) on CodePen.

 

위 html에서 Add ad(Observed) 버튼을 통해 생성한 광고는 감시 대상에 포함되어 제거하더라도 제거되지 않는다. 반면 Add ad(Not Observed) 버튼을 통해 생성한 광고는 감시 대상이 아니므로 delete 버튼을 누를 때 제거된다.  사용된 자바스크립트 코드는 아래와 같다.

  /*observed ad 구성*/
        const observedAd = document.createElement('iframe');
        observedAd.classList.add('ads');
        observedAd.src = 'https://example.org';
        observedAd.setAttribute('data-ad', '');
        observedAd.title = 'Added';
        observedAd.width = 400;
        observedAd.height = 300;

        /*observer에 의한 감시 대상이 아닌 경우 */
        const noObservedAd = document.createElement('div');
        noObservedAd.classList.add('ads');
        noObservedAd.style.backgroundColor = 'gray';
        noObservedAd.innerText = 'Not Observed';
        noObservedAd.style.textAlign = 'center';


        /*버튼들 설정*/
        const delAdBtn = document.querySelector('#delete-ad');
        delAdBtn.addEventListener('click', (e) => {
            const ads = document.querySelectorAll('.ads');
            ads.forEach(it => it.remove());
        });

        const add1Btn = document.querySelector('#add-ad1');
        add1Btn.addEventListener('click', (e) => {
            const adlist = document.querySelector('.ad-list');
            adlist.appendChild(observedAd.cloneNode());
        })

        const add2Btn = document.querySelector('#add-ad2');
        add2Btn.addEventListener('click', (e) => {
            const adlist = document.querySelector('.ad-list');
            adlist.appendChild(noObservedAd.cloneNode(true));
        })

        /*옵저버 동작 정의*/
        const mutObserver = new MutationObserver((mutations, observer) => {
            for (const mutNod of mutations) {
                switch (mutNod.type) {
                    case "childList": // 자식 삭제되는 경우
                        const removedNodes = mutNod.removedNodes;
                        if (removedNodes.length > 0) {
                            const df = document.createDocumentFragment();
                            for (const node of removedNodes) {
                                if (node.nodeType === Node.ELEMENT_NODE) {
                                    switch (true) {
                                        case node.dataset['ad'] != undefined:
                                        case node.classList.contains('ad-list'):
                                            df.appendChild(node);
                                    }
                                }
                            }
                            mutNod.target.insertBefore(df, mutNod.target.firstChild);
                        }
                        break;
                    case "attributes": // 속성 값 변경 못하게 방지.
                        if (mutNod.attributeName === 'data-ad'
                            && !mutNod.target.dataset['ad']) {
                            mutNod.target.dataset['ad'] = '';
                        } else if (mutNod.attributeName === 'class'
                            && !mutNod.target.classList.contains('ad-list')) {
                            mutNod.target.classList.add('ad-list');
                        }
                }
            }
        });
        const target = document.querySelector('.container');
        mutObserver.observe(target, {
            childList: true,
            subtree: true,
            attributeFilter: ['data-ad','class']
        });
  •  for문을 통해 MutationRecord 배열을 순회한다. for문 내부에서는 레코드의 type 프로퍼티를 이용하여 childList에 대한 변경과 attributes에 대한 변경을 구분하였다.
  •  코드상에서 광고와 관련된 엘리먼트는 class='ad-list' 또는 data-ad 어트리뷰트를 통해 식별한다. 따라서 사용자가 해당 속성 값을 지우는 경우를 대비하여 "attributes" 측면에서 값을 덮어쓴다.
  • 또한 해당 엘리먼트들 자체를 제거하는 경우를 대비하여 "childList" 쪽에서 조건을 달아 ad-list 또는 data-ad를 가진 엘리먼트가 제거될 때 해당 노드의 부모에게 자동으로 추가되도록 코드를 작성했다.
  •  createDocumentFragment 함수의 경우 여러개의 노드가 동시에 제거되어 추가되는 과정에서 렌더링을 여러번 수행하지 않기 위해 이용했다.
  • observe 함수의 옵션에 childList, subtree 및 attributeFilter을 설정하여 감시 범위를 지정했다.

위 예시는 아래 codepen 또는 github에서 볼 수 있다.