한 요소에 이벤트가 발생하면, 해당 요소에 할당된 핸들러가 동작하고, 이어서 부모 요소의 핸들러가 동작한다. 가장 최상단의 조상 요소를 만날 때까지 올라가며 각 요소에 할당된 핸들러가 동작한다.
모든 이벤트가 버블링되는 건 아니다.
focus, blur
와 같이 버블링되지 않는 이벤트도 있다.
부모 요소의 핸들러는 이벤트가 정확히 어디서 발생했는지에 대한 자세한 정보를 얻을 수 있다.
이벤트가 발생한 가장 안쪽의 요소를 targe 요소라고 한다. event.target
을 사용해 접근할 수 있다.
event.target
은 실제 이벤트가 시작된 요소이다. 버블링이 진행되어도 변하지 않는다. this(event.currentTarget)
는 현재 요소이다. 현재 실행 중인 핸들러가 할당된 요소를 참조한다. 이벤트 버블링은 target 이벤트에서 시작해 html
요소를 거쳐 document
객체를 만날 때까지 각 노드에서 모두 발생한다. 몇몇 이벤트는 window
객체까지 거슬러 올라간다. 이때도 모든 핸들러가 호출된다.
이때 핸들러에게 이벤트를 완전히 처리하고 난 후 버블링을 중단하도록 명령할 수 있다. 이벤트 객체의 메서드인 event.stopPropagation()
를 사용하면 된다.
다만 한 요소의 특정 이벤트를 처리하는 핸들러가 여러 개라면, 핸들러 중 하나가 버블링을 멈추더라도 나머지 핸들러는 여전히 동작한다. event.stopPropagation()은 위쪽으로 일어나는 버블링은 막아주지만, 병렬로 붙어 있는 다른 핸들러들의 동작은 막지 못한다.
버블링도 멈추고, 요소에 할당된 다른 핸들러의 동작도 막으려면 event.stopImmediatePropagation()
을 사용해야 한다.
**
웬만하면 버블링을 막지 않는 게 좋다. stopPropagation을 사용한 영역은 분석시스템의 코드가 동작하지 않는 dead zone이 되어 버리기 때문이다.
예를 들어,
어떤 버튼을 클릭하면 하단으로 메뉴 영역이 등장하고 사라지는 UI 영역을 만들고 싶다면. 그리고 등장한 메뉴 영역 이외의 window 공간을 클릭하면 메뉴 영역이 다시 사라지고, 메뉴 영역 내부를 클릭하면 관련한 페이지로 이동하도록 하는 UI를 만들고 싶을 때를 생각해보자.
const btnEl = document.querySelector(".btn")
const menuEl = document.querySelector(".btn > .menu")
btnEl
를 클릭하면 show 클래스를 붙였다가 떼주는 이벤트 핸들러를 걸어주면 된다. 그리고 window
를 클릭하면 메뉴 영역을 사라지게 할 것이다.
btnEl.addEventListener('click', function() {
if (btnEl.classList.contains('show')) hideMenu()
else showMenu()
})
window.addEventListener('click', function() {
hideMenu()
})
위와 같이 코드를 작성하면 문제가 발생한다.
버튼 요소인 btnEl
를 클릭하면 click 이벤트가 window까지 버블링이 된다. 즉, 버튼을 눌러 메뉴 영역을 보여주자 마자 window 이벤트에 click 이벤트가 전파되어 닫는 명령으로 덮이게 된다.
이 경우에는 btnEl
를 클릭했을 때 click 이벤트가 조상 요소로 전파되는 걸 막아야 한다. 이런 경우에 event.stopPropagation()
를 사용해야 한다.
btnEl.addEventListener('click', function(event) {
event.stopPropagation()
if (btnEl.classList.contains('show')) hideMenu()
else showMenu()
})
window.addEventListener('click', function() {
hideMenu()
})
위와 같이 코드를 작성하고 나면,
버튼을 클릭했을 때 메뉴 영역이 열리거나 닫히고, 그 외 부분을 클릭하면 메뉴 영역이 다시 닫히도록 동작시킬 수 있게 된다.
하지만 아직 문제가 남아 있다.
메뉴 영역에서의 click 이벤트는 다른 동작이 되어야 한다. 근데
메뉴 영역을 click하면 이벤트 버블링이 발생해서 btnEl
로 click 이벤트가 전파된다. 그러면 버튼을 눌러 메뉴 영역을 보여주더라도 메뉴 영역을 클릭하면 또 닫히게 된다. 이를 막으려면
메뉴 영역에서 버블링을 막아줘야 한다.
btnEl.addEventListener('click', function(event) {
event.stopPropagation()
if (btnEl.classList.contains('show')) hideMenu()
else showMenu()
})
menuEl.addEventListener('click', function(event) {
event.stopPropagation()
})
window.addEventListener('click', function() {
hideMenu()
})
이런 식으로 각 이벤트를 특정 요소로만 한정지어서 동작을 구분지을 때
버블링 개념을 이해하고 있어야 한다.
캡처링은 실제 코드에서는 자주 쓰이지 않지만 알아두어야 한다.
부모 요소에서 자식 요소로 이벤트를 전파하고 싶을 때 사용할 수 있다.
표준 DOM 이벤트에는 3가지 이벤트 흐름이 존재한다.
on<event>
프로퍼티나 HTML 속성, addEventListener(event, handler)
를 이용해 할당된 핸들러는 캡처링에 대해 전혀 알 수 없다. 이 핸들러들은 타깃 단계와 버블링 단계에서만 동작한다.
캡처링 단계에서 이벤트를 잡아내려면 addEventListener의 capture 옵션을 true로 설정해야 한다.
elem.addEventListener(..., {capture: true})
// 아니면, 아래 같이 {capture: true} 대신, true를 써줘도 됩니다.
elem.addEventListener(..., true)
예를 들어
<form>FORM
<div>DIV
<p>P</p>
</div>
</form>
<script>
for(let elem of document.querySelectorAll('*')) {
elem.addEventListener("click", e => alert(`캡쳐링: ${elem.tagName}`), true);
elem.addEventListener("click", e => alert(`버블링: ${elem.tagName}`));
}
</script>
위와 같이 document 전체 요소에 각각 캡처링과 버블링을 걸어 놓고
p 요소를 클릭했을 때 아래와 같이 흐름이 발생한다.
- HTML → BODY → FORM → DIV (캡처링 단계, 첫 번째 리스너)
- P (타깃 단계, 캡쳐링과 버블링 둘 다에 리스너를 설정했기 때문에 두 번 호출됩니다.)
- DIV → FORM → BODY → HTML (버블링 단계, 두 번째 리스너)
event.eventPhase
프로퍼티를 이용하면 현재 발생 중인 이벤트 흐름의 단계를 알 수 있다. (캡처링=1, 타깃=2, 버블링=3)
이벤트 버블링과 캡처링은 이벤트 위임(event delegation)의 토대가 된다. 이벤트 위임은 강력한 이벤트 핸들링 패턴이다.
이벤트 위임은 비슷한 방식으로 여러 요소를 다뤄야 할 때 사용된다. 이벤트 위임을 사용하면 요소마다 핸들러를 할당하지 않고 요소의 공통 조상에 이벤트 핸들러를 단 하나만 할당해도(event.target으로 실제로 어디서 이벤트가 발생하기 시작했는지 알 수 있으므로) 여러 요소를 한꺼번에 다룰 수 있다.
예를 들어
아래와 같은 구조를 가지는 9개의 table이 있는 상황에서, 9개 각각 요소별로 비슷한 이벤트를 부여하고 싶다면.
<table>
<tr>
<th colspan="3">제목</th>
</tr>
<tr>
<td class="nw"><strong>하위 제목</strong><br>요소1<br>요소2<br>요소3</td>
<td class="n">...</td>
<td class="ne">...</td>
</tr>
<tr>...2 more lines of this kind...</tr>
<tr>...2 more lines of this kind...</tr>
</table>
각각 요소에 이벤트 핸들러를 할당하는 게 아니라 table 요소 하나에만 이벤트 핸들러를 할당하면 된다.
event.target으로 이벤트가 발생하기 시작한 곳을 찾는다.
td에만 이벤트를 할당할 것이므로 아니라면 return하고
맞다면 hightlight 함수를 실행해준다.
let selectedTd;
table.onclick = function(event) {
let target = event.target; // 클릭이 어디서 발생했을까요?
if (target.tagName != 'TD') return; // TD에서 발생한 게 아니라면 아무 작업도 하지 않습니다,
highlight(target); // 강조 함
};
function highlight(td) {
if (selectedTd) { // 이미 강조되어있는 칸이 있다면 원상태로 바꿔줌
selectedTd.classList.remove('highlight');
}
selectedTd = td;
selectedTd.classList.add('highlight'); // 새로운 td를 강조 함
}
하지만 위와 같이 작성하면 td 하위 요소에서 클릭 이벤트가 동작할 때의 버블링으로 문제가 될 수 있다.
그래서 아래와 같이 event.target.closest(selector)
를 사용해 selector와 일치하는 가장 근접한 조상 요소를 반환하도록 하면 된다. 그리고 그게 table에 포함되어야 있는지도 확인해서 영역을 구체화해주면 된다.
table.onclick = function(event) {
let td = event.target.closest('td'); // (1)
if (!td) return; // (2)
if (!table.contains(td)) return; // (3)
highlight(td); // (4)
};
event.target
을 사용해서 이벤트 위임을 사용하지만, 더 확장해서 쓸 수도 있다.
예를 들어, event.target
으로 요소를 감지한 뒤에 dataset
속성을 사용해서 선언적 방식으로 행동을 추가해줄 수 있다.
<button data-toggle-id="subscribe-mail">
구독 폼 보여주기
</button>
<form id="subscribe-mail" hidden>
메일 주소: <input type="email">
</form>
<script>
document.addEventListener('click', function(event) {
let id = event.target.dataset.toggleId;
if (!id) return;
let elem = document.getElementById(id);
elem.hidden = !elem.hidden;
});
</script>
이벤트 위임을 사용하면
수많은 요소에 각각 핸들러를 일일이 붙일 필요가 없어진다. 그러므로 코드 구조가 유연해진다.
이벤트 위임의 알고리즘을 요약하면 다음과 같다.
- 컨테이너에 하나의 핸들러를 할당한다.
- 핸들러의 event.target을 사용해 이벤트가 발생한 요소가 어디인지 알아낸다.
- 원하는 요소에서 이벤트가 발생했다고 확인되면 이벤트를 핸들링한다.
이벤트 위임의 장점
- 많은 핸들러를 할당하지 않아도 되기 때문에 초기화가 단순해지고 메모리가 절약된다. (이벤트 핸들러는 사용하지 않는다면 삭제해줘야 하지만 일일이 삭제하기 어렵다. 이벤트 위임을 알면 이벤트 핸들러를 관리하기 쉬워진다.)
- 요소를 추가하거나 제거할 때 해당 요소에 할당된 핸들러를 추가하거나 제거할 필요가 없기 때문에 코드가 짧아진다.
- innerHTML이나 유사한 기능을 하는 스크립트로 요소 덩어리를 더하거나 뺄 수 있기 때문에 DOM 수정이 쉬워진다.
이벤트 위임의 단점
- 이벤트 위임을 사용하려면 이벤트가 반드시 버블링 되어야 한다. 하지만 몇몇 이벤트는 버블링 되지 않는다. 그리고 낮은 레벨에 할당한 핸들러엔 event.stopPropagation()를 쓸 수 없다.
- 컨테이너 수준에 할당된 핸들러가 응답할 필요가 있는 이벤트이든 아니든 상관없이 모든 하위 컨테이너에서 발생하는 이벤트에 응답해야 하므로 CPU 작업 부하가 늘어날 수 있다. 물론 이런 부하는 무시할만한 수준이므로 실제로는 잘 고려하지 않는다.
버블링과 캡처링 - 모던 JavaScript 튜토리얼
이벤트 위임 - 모던 JavaScript 튜토리얼
왜 이벤트 위임(delegation)을 해야 하는가? - TOAST UI