LHJ

I'm a FE developer.

18.10.1 이벤트 버블링과 캡처링

02 Jun 2020 » js_lj

18.10.1 이벤트 버블링과 캡처링

HTML은 계층적이므로 이벤트를 꼭 한 곳에서만 처리해야 하는 건 아닙니다.
예를 들어 버튼을 클릭했을 때 물론 버튼 자체에서 이벤트를 처리할 수 있지만, 버튼의 부모에서 처리해도 되고 그 부모의 부모에서 처리해도 되는 식입니다.
여러 요소에서 이벤트를 처리할 수 있다면, 그 이벤트에 응답할 기회는 어떤 순서로 주어지는가 하는 의문이 생길 수 있습니다.

기본적으로 두 가지 방법이 있습니다.
하나는 가장 먼 조상부터 시작하는 방법으로 캡처링(capturing)이라 부릅니다.
예제 HTML에서 버튼은 <div id="content">에 들어있고, <div id="content"<body>에 들어있습니다.
따라서 <body>도 버튼에서 일어난 이벤트를 ‘캡처’할 수 있습니다.

다른 방법은 이벤트가 일어난 요소에서 시작해 거슬러 올라가는 방법입니다.
이런 방법을 버블링(bubbling)이라 부릅니다.

HTML5 이벤트 모델에서는 두 방법을 모두 지원하기 위해 먼저 해당 요소의 가장 먼 조상에서 시작해 해당 요소까지 내려온 다음, 다시 해당 요소에서 시작해 가장 먼 조상까지 거슬러 올라가는 방법을 택했습니다.

이벤트 핸들러에는 다른 핸들러가 어떻게 호출될지 영향을 주는 세 가지 방법이 있습니다.

preventDefault

가장 많이 쓰이는 것은 우리가 이미 본 preventDefault입니다.
이 메서드는 이벤트를 취소합니다.
취소된 이벤트는 계속 전달되기는 하지만, defaultPrevented 프로퍼티가 true로 바뀐 채 전달됩니다.
브라우저의 이벤트 핸들러는 defaultPrevented 프로퍼티가 true로 바뀐 이벤트를 무시하고 아무 일도 하지 않습니다.
프로그래머가 만든 이벤트 핸들러에서는 defaultPrevented 프로퍼티를 무시한 채 동작을 수행할 수 있고, 보통 그렇게 합니다.

stopPropagation

두 번째 방법은 stopPropagation입니다.
이 메서드는 이벤트를 현재 요소에서 끝내고 더는 전달되지 않게 막습니다.
즉, 해당 요소에 연결된 이벤트 핸들러는 동작하지만 다른 요소에 연결된 이벤트 핸들러는 동작하지 않습니다.

stopImmediatePropagation

마지막 방법은 가장 강력한 stopImmediatePropagation입니다.
이 메서드는 다른 이벤트 핸들러, 심지어 현재 요소에 연결된 이벤트 핸들러도 동작하지 않게 막습니다.

다음 예제를 보십시오.

예제

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Event Propagation</title>
</head>
<body>
    <div>
        <button>Click Me!</button>
    </div>
    <script>
        // 이벤트 핸들러를 만들어 반환합니다.
        function logEvent(handlerName, type, cancel, stop, stopImmediate) {
            // 실제 이벤트 핸들러입니다.
            return function (evt) {
                if (cancel) evt.preventDefault();
                if (stop) evt.stopPropagation();
                if (stopImmediate) evt.stopImmediatePropagation();
                console.log(`${type}: ${handlerName}` + (evt.defaultPrevented ? '(canceled)' : ''))
            }
        }
        function addEventLogger(elt, type, action) {
            const capture = type === 'capture';
            // addEventListener 세번째 매개변수는 이벤트 버블링, 또는 이벤트 캡처링을 
            // 사용할지 여부를 지정하는 부울값입니다.
            // 이 매개 변수는 선택적입니다.
            elt.addEventListener('click', logEvent(elt.tagName, type, action==='cancel', action==='stop', action==='stop!'), capture)
        }

        const body = document.querySelector('body');
        const div = document.querySelector('div');
        const button = document.querySelector('button');
        addEventLogger(body, 'capture');
        addEventLogger(body, 'bubble');
        addEventLogger(div, 'capture');
        addEventLogger(div, 'bubble');
        addEventLogger(button, 'capture');
        addEventLogger(button, 'bubble');
    </script>
</body>
</html>

버튼을 클릭하면 콘솔에 다음과 같은 내용이 출력됩니다.

capture: BODY
capture: DIV
capture: BUTTON
bubble: BUTTON
bubble: DIV
bubble: BODY

캡처링이 먼저 시작되고 그 후에 버블링이 이어지는 걸 확인할 수 있습니다.
이벤트가 실제 일어난 요소, 즉 버튼에서는 핸들러가 캡처링 다음 버블링이라는 일반적인 순서를 무시하고
추가된 순서대로 실행됩니다.
예를 들어 앞의 예제에서 버튼에 핸들러를 추가한 순서를 반대로 했다면 콘솔에도 반대로 기록됩니다.

이제 이벤트를 취소해 봅시다.
다음과 같이 예제를 수정해서 <div>의 캡처 단계에서 취소되도록 해 보십시오.

addEventLogger(body, 'capture');
addEventLogger(body, 'bubble');
addEventLogger(div, 'capture', 'cancel');
addEventLogger(div, 'bubble');
addEventLogger(button, 'capture');
addEventLogger(button, 'bubble');

이벤트 전달은 계속되지만, 이벤트가 취소됐다고 기록된 걸 볼 수 있습니다.

capture: BODY
capture: DIV (canceled)
capture: BUTTON (canceled)
bubble: BUTTON (canceled)
bubble: DIV (canceled)
bubble: BODY (canceled)

이제 버튼의 캡처 단계에서 이벤트 전달을 중지해 봅시다.

addEventLogger(body, 'capture');
addEventLogger(body, 'bubble');
addEventLogger(div, 'capture', 'cancel');
addEventLogger(div, 'bubble');
addEventLogger(button, 'capture', 'stop');
addEventLogger(button, 'bubble');

버튼 요소에서 이벤트 전달이 멈추는 걸 볼 수 있습니다.
캡처링까지 진행하고 멈추게 했지만, 버튼의 버블링 이벤트는 여전히 발생합니다.
하지만 <div><body> 요소는 이벤트 버블링을 받지 못합니다.

capture: BODY
capture: DIV (canceled)
capture: BUTTON (canceled)
bubble: BUTTON (canceled)

마지막으로, 버튼의 캡처 단계에서 즉시 멈추게 만들어 봅시다.

addEventLogger(body, 'capture');
addEventLogger(body, 'bubble');
addEventLogger(div, 'capture', 'cancel');
addEventLogger(div, 'bubble');
addEventLogger(button, 'capture', 'stop!');
addEventLogger(button, 'bubble');

이제 버튼의 캡처 단계에서 이벤트 전달이 완전히 멈췄고, 이후로는 아무 일도 일어나지 않습니다.

capture: BODY
capture: DIV (canceled)
capture: BUTTON (canceled)

NOTE_
addEventListener는 이벤트를 추가하는 구식 방법인 on 프로퍼티를 대체할 목적으로 만들어졌습니다.
예전에는, 예를 들어 요소 elt에 클릭 핸들러를 추가할 때 elt.onclick = function(evt) {/* handler */} 같은 문법을 썼습니다.
이런 문법의 가장 큰 단점은 이벤트에 핸들러 단 하나만 등록할 수 있다는 겁니다.

고급 이벤트 컨트롤이 자주 필요하지는 않지만, 이벤트 전달은 초보자들을 머리 아프게 만드는 주제 중 하나입니다.
이벤트 전달에 대해 확실히 알게 된다면 당신은 상당히 돋보일 겁니다.

NOTE_
제이쿼리 이벤트 리스너에서 명시적으로 false를 반환하는 것은 stopPropagation을 호출하는 것과 동등한 효과가 있습니다.
하지만 이것은 제이쿼리 단축문법일 뿐이며, DOM API에서는 동작하지 않습니다.