최근에 바닐라 자바스크립트로 기능을 구현하던 도중에, 하위 요소들에 같은 이벤트 리스너를 바인딩해야 되는 일이 있었다. 이전에 리액트를 사용했을 때는 이 부분에 대한 큰 고민없이 이벤트를 발생시키는 요소에 직접 이벤트 리스너를 바인딩 하는 것이 당연하다고 생각했다. 그런데 하위 요소마다 같은 이벤트 리스너를 바이딩했을 때, 하위 요소의 수가 적다면 그 차이는 미비하겠지만 하위 요소가 많다면 이벤트 리스너가 하위 요소의 수만큼 중복되기 때문에 메모리 효율적인 측면에서 바람직하지 않다고 생각했다.
위와 같은 상황에서는 하위 요소들을 묶어줄 수 있는 상위요소에 이벤트 리스너를 한번만 바인딩해주고, 하위 요소들에게 이벤트가 발생했을 때 상위 요소에 바인딩된 이벤트 리스너를 호출하는 것이 효율적일 것이다. 그리고 이것을 상위 요소의 이벤트가 하위 요소에 전달된다고 해서 이벤트 위임(Event Delegation)
이라고 한다.
<body>
<section class="button-container">
<button class="red-btn">
<img src="./red-button.png" alt="red-btn" />
<span>1</span>
</button>
<button class="red-btn">
<img src="./red-button.png" alt="red-btn" />
<span>2</span>
</button>
<button class="red-btn">
<img src="./red-button.png" alt="red-btn" />
<span>3</span>
</button>
</section>
</body>
이벤트 위임
에 대해 알아보기 위해 간단한 코드를 작성했다.
const handleEvent = e => {
console.log(e.target);
};
document.querySelectorAll('.red-btn').forEach($btn => $btn.addEventListener('click', handleEvent));
querySelectorAll
로 모든 button
을 가져와 각각의 버튼에 이벤트 리스너를 바인딩해줬다. 기능은 정상적으로 동작하지만 같은 함수를 반복하여 이벤트 리스너로 바인딩하기 때문에 메모리 사용 측면에서 비효율적이다.
그리고 만약 하위 요소가 추가된다면 하위 요소를 추가할 때마다 새롭게 추가되는 요소에 이벤트 리스너를 바인딩해야 한다. 추가되지 않고 초기에는 하위 요소가 존재하지 않다가 동적으로 추가 되는 경우도 마찬가지다. 그렇기 때문에 하위 요소에 일일이 추가하는 것보다 상위 요소에 이벤트 리스너를 바인딩하여 이벤트 위임
을 해주는 것이 바람직해 보인다.
그런데 사실 지금 button
도 이벤트 위임을 하고 있다.
사진에 표시한 위치와 순서대로 버튼을 클릭하면 콘솔에는 이렇게 나온다.
button
의 하위요소인 <img>
와 <span>
에는 이벤트 리스너를 직접 바인딩하지 않았지만 상위 요소인 button
의 이벤트 리스너가 호출된 것이다.
이제 button
의 내부를 클릭하면 어느 곳을 클릭하더라도 button
에 바인딩한 이벤트 리스너가 호출된다.
.red-btn > img,
.red-btn > span {
pointer-events: none;
}
그런데 만약 함수 호출 뿐만 아니라 target도 button
으로 가져오고 싶다면 target을 지정하고 싶지 않은 하위 요소에 css
의 pointer-events: none
속성을 적용하는 것이다. 해당 속성을 적용하면 <img>
와 <span>
요소를 클릭해도 button
이 이벤트의 target
이 된다.
이제 button
의 하위요소를 클릭해도 이벤트 리스너가 동작한다. 근데 button
에 이벤트 위임을 하기 위해 button-container
에 이벤트 리스너를 바인딩한다면 button
의 외부 영역에도 이벤트 리스너가 바인딩 되어 있다. 그래서 1과 2를 순차적으로 클릭하여 콘솔에 나온 요소는 같지 않다.
그래서 .button-container
에도 pointer-events: none
속성을 주려고 하면, 이제 콘솔에 아무런 값도 나오지 않는다. pointer-events: none
속성은 하위 요소에도 적용되기 때문에 .button-container
와 모든 하위요소들이 이벤트의 target
대상이 되지 않는다.
그래서 이 문제를 해결하기 위해 위에서 작성한 handleEvent
함수에 이벤트의 target
이 해당 button
인지 판별해주는 로직을 추가해줄 필요가 있었다.
const handleEvent = e => {
if (!e.target.classList.contains('red-btn')) return;
console.log(e.target);
};
button
의 하위 요소에는 pointer-events: none
속성을 적용하고, button
이 아닌 이벤트 리스너를 바인딩 해준 상위 요소의 영역은 자바스크립트로 판별하여 이벤트 위임
을 구현하였다.
그런데 프론트엔드는 항상 크로스 브라우징
의 문제를 안고 있다. pointer-events
속성은 현재 대부분의 브라우저에서 지원하지만, 일부 구형 브라우저에서는 지원하지 않는다.
이외에도 발생할 수 있는 문제가 더 존재하는데, html
의 구조가 지금보다 더 복잡해질 수 있다. button
의 하위 요소의 하위 요소가 존재하고 그 하위 요소는 별도의 event
를 지정해줘야 한다면 (그런 마크업 구조가 유용한지는 현재는 차치하고) pointer-events
속성은 하위 요소들에게 상속되기 때문에 별도의 event
를 지정할 수 없다.
이런 경우를 제외하고는 css
를 사용하는 것이 간단하고 좋지만, 만약 위와 같은 상황에는 자바스크립트를 통해서 문제를 해결할 수 있다.
const handleEvent = ({ target }) => {
let $element = target;
while (!$element.classList.contains('red-btn')) {
$element = $element.parentElement;
if ($element.matches('body')) {
return;
}
}
console.log($element);
};
위에서 구현한 handleEvent
함수에서는 .red-btn
버튼 요소를 찾을때까지 계속해서 $element
에 부모 요소를 재할당해준다. 만약, 해당 요소 외부 영역을 클릭하면 해당 요소의 부모로 해당 요소를 찾을 수 없으므로 재할당한 값이 body
가 되면 return 해주는 것으로 위 문제를 해결했다. 자바스크립트가 반드시 필요한 경우가 아니라면 css를 통해 구현하는 것이 좋다.
const handleEvent = ({ target }) => {
if(target.matches('.button-container')) return;
if(target.matches('.red-btn *')) return;
console.log(target);
};
button
의 하위 요소가 복잡하지 않다면 자바스크립트를 사용하지 않고 css 선택자로 button
의 부모요소와 하위요소에 이벤트를 return시켜 함수를 호출시키지 않을 수 있다.