이벤트 전파란 무엇이며, 어떻게 활용할 수 있는지 정리해 보았습니다.
이벤트 전파를 확인하기 위해 간단한 화면을 하나 구성합니다.
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EventPropagation</title>
<Style>
html, body, main, div, article {
display: flex;
align-items: center;
justify-content: center;
}
body, main, div, article {
width: 70%;
height: 70%;
}
html {
height: 100%;
background-color: #7fffd4;
}
body {
background-color: #52a666;
}
main {
background-color: #22863b;
}
div {
background-color: #106020
}
</Style>
<!-- index.js 연동 -->
<script src="index.js" defer></script>
</head>
<body>
<main>
<div>
</div>
</main>
</body>
</html>
// index.js
const htmlEl = document.querySelector('html');
const bodyEL = document.querySelector('body');
const mainEl = document.querySelector('main');
const divEl = document.querySelector('div');
htmlEl.addEventListener('click', function (e) {
console.log('1. html');
});
bodyEL.addEventListener('click', function (e) {
console.log('2. body');
});
mainEl.addEventListener('click', function (e) {
console.log('3. main');
});
divEl.addEventListener('click', function (e) {
console.log('4. div');
});
화면은 다음과 같습니다.
부모-자식 관계는 html > body > main > div 이고, 각 요소에 클릭시 태그명을 출력하도록 이벤트를 달아주었습니다. 이제 div 태그의 영역을 클릭하면 어떤 일이 발생할까요?
4. div
3. main
2. body
1. html
다음과 같이 div → main → body → html 순으로 등록한 이벤트가 발생하는 것을 확인할 수 있습니다.
div 요소를 클릭하면 '클릭 이벤트'는
1. html 요소로부터 시작해 div 요소의 모든 조상을 상위 요소부터 하위 요소 순으로 거쳐 내려갑니다. 이 때 캡쳐링 단계 이벤트가 등록되어있다면 해당 이벤트가 수행됩니다.
2. 실제 클릭이 일어난 div 태그에 도달하면 다시 html 요소까지 div 요소의 모든 조상을 하위 요소에서 상위 요소 순으로 거쳐 올라갑니다. 이 때 버블링 단계 이벤트가 등록되어있다면 해당 이벤트가 수행됩니다.
위에서 살펴본 예시에 div → main → body → html 순으로 이벤트가 발생한 이유는 addEventListener 함수로 인해 버블링 단계에 이벤트가 등록되었고, div태그로부터 html요소까지 이벤트가 전파되는 버블링 단계에서 각 이벤트가 수행되었기 때문입니다.
캡쳐링 단계에 이벤트를 등록하고 싶다면, addEventListener의 3번째 인자에 true를 전달하면 됩니다.
//index.js
const htmlEl = document.querySelector('html');
const bodyEL = document.querySelector('body');
const mainEl = document.querySelector('main');
const divEl = document.querySelector('div');
// addEventListener의 3번째 인자로 true 전달하여
// capturing 단계에 이벤트가 등록되도록 함
htmlEl.addEventListener('click', function (e) {
console.log('1. html');
}, true);
bodyEL.addEventListener('click', function (e) {
console.log('2. body');
}, true);
mainEl.addEventListener('click', function (e) {
console.log('3. main');
}, true);
divEl.addEventListener('click', function (e) {
console.log('4. div');
}, true);
index.js를 위와같이 수정한 후 div요소를 클릭하면 콘솔에 다음과 같이 출력됩니다.
1. html
2. body
3. main
4. div
캡쳐링 단계에 이벤트를 등록했기 때문에 html → body → main → div 순으로 이벤트가 발생하는 것을 확인할 수 있습니다.
이벤트 전파 개념은 리스트를 다룰 때 자주 사용됩니다. ul요소 혹은 ol요소 내부의 모든 il요소에 이벤트를 등록해야 하는 상황에서 ul요소에만 이벤트를 등록하면 이벤트 전파로 인해 모든 il요소에 이벤트를 등록한 효과를 낼 수 있습니다. 다음은 간단한 예시입니다. (css는 생략하겠습니다)
<!-- index.html -->
...
<body>
<ul class="container">
</ul>
</body>
...
// index.js
const ulEl = document.querySelector('ul');
let items = [
{"id": 1, "name": "apple"},
{"id": 2, "name": "banana"},
{"id": 3, "name": "orange"},
{"id": 4, "name": "kiwi"},
{"id": 5, "name": "melon"}
]
ulEl.innerHTML = items.map(item => `
<li>
<div class="item-id">${item.id}</div>
<div class="item-name">${item.name}</div>
</li>
`).join('');
과일의 id와 name 정보를 가지는 리스트를 화면에 띄우는 코드입니다.
각 리스트의 li요소에 클릭시 name을 알림창으로 띄우는 이벤트를 등록하려면 어떻게 하면 될까요? 위에서도 언급했지만, ul요소에만 이벤트를 추가해주면 됩니다.
ulEl.addEventListener('click', event => {
// 실제 클릭된 요소(event.target)의 가장 가까운 li 요소를 찾는다.
const liEl = event.target.closest('li');
// li 요소 하위의 item-name을 찾아서 알림을 띄운다.
if (!liEl || !liEl.querySelector('.item-name')) return;
alert(liEl.querySelector('.item-name').textContent);
});
한 번의 이벤트 등록으로 모든 리스트의 아이템에 이벤트를 등록했습니다.
앞서 언급한 예시처럼 간단한 상황만 있다면 얼마나 좋을까요? 하지만 실전에서 리스트의 아이템은 한 아이템에 여러 기능이 담겨있을 수 있습니다. 예를 들면 "찜하기" 버튼이 달려있는 상황입니다. (css는 생략하겠습니다)
const ulEl = document.querySelector('ul');
let items = [
{"id": 1, "name": "apple"},
{"id": 2, "name": "banana"},
{"id": 3, "name": "orange"},
{"id": 4, "name": "kiwi"},
{"id": 5, "name": "melon"}
]
ulEl.innerHTML = items.map(item => `
<li>
<div class="item-id">${item.id}</div>
<div class="item-name">${item.name}</div>
<span class="favorite">찜</span>
</li>
`).join('');
// 1. 알림창 이벤트
ulEl.addEventListener('click', event => {
// 실제 클릭된 요소(event.target)의 가장 가까운 li 요소를 찾는다.
const liEl = event.target.closest('li');
// li 요소 하위의 item-name을 찾아서 알림을 띄운다.
if (!liEl || !liEl.querySelector('.item-name')) return;
alert(liEl.querySelector('.item-name').textContent);
});
// 2. 찜 이벤트
ulEl.addEventListener('click', event => {
// .favorite 요소가 클릭된 경우가 아니면 종료
if(!event.target.classList.contains('favorite')) return;
// 찜 클릭시 active 클래스 추가, 이미 추가된 상태면 제거
// active 클래스 추가시 글자색 빨간색이 되도록 css 작성함
const favoriteEl = event.target;
if (favoriteEl.classList.contains('active'))
favoriteEl.classList.remove('active');
else
favoriteEl.classList.add('active');
});
의도하는 기능이 잘 작동하지만, 한 가지 문제점이 있습니다. "찜" 버튼을 클릭하면 찜 이벤트만 발생하면 좋겠는데 알림창 이벤트까지 같이 발생한다는 것이죠. 원하는 대로 동작하도록 수정하면 다음과 같습니다.
ulEl.addEventListener('click', event => {
// 2. 찜 이벤트
if(event.target.classList.contains('favorite')) {
// 찜 클릭시 active 클래스 추가, 이미 추가된 상태면 제거
// active 클래스 추가시 글자색 빨간색이 되도록 css 작성함
const favoriteEl = event.target;
if (favoriteEl.classList.contains('active'))
favoriteEl.classList.remove('active');
else
favoriteEl.classList.add('active');
}
// 1. 알림창 이벤트
else {
// 실제 클릭된 요소(event.target)의 가장 가까운 li 요소를 찾는다.
const liEl = event.target.closest('li');
// li 요소 하위의 item-name을 찾아서 알림을 띄운다.
if (!liEl || !liEl.querySelector('.item-name')) return;
alert(liEl.querySelector('.item-name').textContent);
}
});
처음부터 이렇게 짜면 되지 왜 굳이 이벤트 리스너를 2개 짜는 코드를 보여드렸냐면
function alertEventHandler (event) {
// ...
}
function favEventHandler (event) {
// ...
}
ulEl.addEventListener('click', alertEventHandler);
ulEl.addEventListener('click', favEventHandler);
이같이 보통 이벤트 마다 핸들러 함수를 따로 정의하여 사용하기 때문입니다. 여러분은 그럼에도 원하는 대로 코드를 잘 짜시겠지만 사실은 제가 프로젝트에 이 개념을 적용하다 실수했던 부분이기 때문에 언급해보았습니다.
위와 같이 클라이밍 매장의 정보를 리스트로 보여주는 페이지를 제작한 적 있습니다. 해당 매장의 카드를 클릭하면 상세페이지로 넘어가고, 좋아요 버튼을 클릭하면 좋아요 활성화 혹은 해제되는 기능을 구현하였습니다.
그러나 '이벤트 전파 활용하기 - 2'의 첫부분에서 언급한 실수와 같이 따로 이벤트 리스너를 만들어 등록하였고, 좋아요 버튼을 누르면 좋아요 활성화/해제와 동시에 상세페이지로 넘어가는 상황이 펼쳐져 해결하는데 애를 먹었습니다.
해당 경험을 한 후, 본 개념을 정리해야겠다는 결심을 하여 글을 쓰게 되었습니다.