개념
이벤트 캡처링 / 버블링
이벤트 버블링은 JS를 잠깐 배웠을 때 접했던 용어다. 대략적으로 이해하고 넘어갔었고 리액트 강의를 수강할 때는 이를 크게 의식할 일이 없었다. 대략적인 개념을 일단 짚고 넘어가 보자.
웹 페이지의 엘리먼트들은 위와 같이 태그 안에 태그가 위치하는 계층식으로 이루어져 있다. 그렇기 때문에 HTML 요소에 이벤트가 발생하면 연쇄적인 이벤트 전파가 일어난다. 그 순서는 다음과 같다.
- 이벤트 캡처링: 이벤트가 최상위 노드에서 시작하여 이벤트 타겟(이벤트가 발생한 요소)까지 내려가는 과정이다. 캡처링 단계에서는 이벤트가 도달하기 전에 상위 요소에서 먼저 감지된다. 그림에서는 windonw -> ... -> tr까지 이벤트가 순차적으로 전파되는 것이다.
- 타깃 단계: 이벤트가 실제 타깃에 전달되는 단계이다. 그림에서는 이벤트가 발생지인 td까지 도달하는 것이다.
- 버블링 단계: 이벤트가 상위 요소로 전파되는 단계이다. 그림에서는 이벤트 타겟 td부터 window까지 이벤트가 전파된다.
이벤트 핸들러 등록과 흐름
대부분의 이벤트는 기본적으로 버블링을 사용하여 처리된다.
<div id="parent">
<button id="child">Click me!</button>
</div>
// 버블링 단계에서 이벤트 처리 (기본값)
document.getElementById('child').addEventListener('click', function() {
alert('버블링 단계에서 처리됨');
}, false);
// 캡처링 단계에서 이벤트 처리
document.getElementById('parent').addEventListener('click', function() {
alert('캡처링 단계에서 처리됨');
}, true);
기본적으로 addEventListener()를 통해 이벤트 핸들러를 등록할 때는 이벤트 버블링 단계에서 등록한 핸들러가 처리된다. 3번째 매개변수 useCapture를 true로 설정해 주면 캡처링 단계에서 핸들러가 처리되게끔 해줄 수도 있다.
이벤트 전파 제어
상위 노드까지 이벤트가 전파되지 않게 하거나 형제 이벤트 핸들러 실행을 막는 것이 필요할 수 있다. 그때는 event.stopPropagation(), event.stopImmediatePropagation() 함수를 사용할 수 있다.
document.getElementById('child').addEventListener('click', function(event) {
alert('Button clicked!');
event.stopPropagation(); // 이벤트가 부모 요소로 전파되는 것을 중지
});
document.getElementById('parent').addEventListener('click', function() {
alert('Parent clicked!');
});
이렇게 코드가 작성되면 버튼을 클릭했을 때 "Parent clicked!"라는 메시지는 표현되지 않는다.
document.getElementById('button').addEventListener('click', function(event) {
alert('First handler!');
event.stopImmediatePropagation();
});
document.getElementById('button').addEventListener('click', function() {
alert('Second handler!');
});
이 코드는 버튼이 클릭되었을 때 이후에 등록된 다른 핸들러의 실행과 이벤트 전파를 중지한다.
실제 사례
교육 기간 동안 강의를 통한 예제만 접하다가 이제 실제로 개발 업무에 참여하게 되었다. 버튼, 라디오, 토스트 등의 공용 컴포넌트 개발의 일부분을 맡게 되었다. 강의를 통해 머리에 집어넣은 건 많지만 앞의 내용은 금방 잊어버리고 계속해서 다른 기술과 개념들도 접해서 직접 개발하려니 진도가 잘 안 나가고 있는 상황이다.
어쨌든 지금 이야기할 것은 토스트 컴포넌트다. 처음에는 단순히 일정 시간 노출된 뒤에 사라지게끔 코드를 작성하였다. 하지만 이후 "토스트가 노출되어 있는 동안 다른 곳을 클릭하면 토스트가 바로 사라지게 해 주세요"라는 추가 요구사항을 받게 되었다.
처음 든 생각은 useEffect를 사용해서 토스트가 렌더링 될 때 document에 클릭 이벤트를 추가해서 토스트 노출 여부에 관한 상태를 변경하면 되겠다고 생각했다.
const WrToastContent = ({ setOpen, message, duration, location, boxClass, sizeClass }: WrToastContentProps) => {
const divRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (divRef.current && !divRef.current.contains(event.target as Node)) {
setOpen(false)
}
}
const timer = setTimeout(() => {
setOpen(false)
}, duration)
// 문제의 부분 !
document.addEventListener("click", handleClickOutside)
return () => {
document.removeEventListener("clcik", handleClickOutside)
clearTimeout(timer)
}
}, [duration, setOpen])
const initY = location === "top" ? -30 : 30
return (
<motion.div ref={divRef} className={boxClass} key='toast' initial={{ y: initY, opacity: 0 }} animate={{ y: 0, opacity: 0.7 }} exit={{ y: initY, opacity: 0 }}>
<p className={sizeClass}>{message}</p>
</motion.div>
)
}
토스트 코드의 일부분이다. open이라는 state에 따라 토스트가 생성되거나 사라진다. useEffect()에서 일어나는 작업은 대략 이렇다.
- 토스트 컴포넌트가 렌더링 될 때 document에 'click' 이벤트를 추가
- 해당 이벤트 핸들러에서는 setOpen을 false로 바꿔 토스트를 지우는 것(DOM에서 삭제)을 의도하고 있음
- 따로 클릭을 하지 않으면 setTimeout()에서 등록된 콜백이 실행되면서 토스트가 사라지게 됨
하지만 결과적으로 해당 코드는 예상대로 동작하지 않았다. 테스트 페이지에서 버튼을 클릭하여 open state를 true로 변환하는 식으로 테스트를 하였지만 토스트가 생성되지 않았다(정확히는 생성되었다가 바로 삭제됨). 하지만 문제의 부분을 주석 처리하면 다시 토스트가 정상적으로 생성된다. 원인에 대해서 혼자 고민해보기도 하고 동료들에게 질의하기도 했다. 결론적으로 해당 문제는 이벤트 버블링이 주요 원인이었다.
해결 방법
해결 방법은 정말 간단하다. 바로 addEventListener()에서 "click"을 "mousedown"으로 바꿔주면 문제는 해결된다. 참고로 click 이벤트는 마우스를 누르고 땔 때 발생하고, mousedown 이벤트는 마우스를 누르는 순간 발생한다. click으로 이벤트를 설정했을 때 문제가 발생하는 경위는 다음과 같다.
- 테스트 페이지에서 토스트를 생성하기 위한 버튼을 클릭하게 되면 해당 이벤트의 캡처링과 버블링이 시작된다.
- 이때 토스트가 렌더링 되면서 useEffect()의 함수가 실행된다. 그 과정에서 document click 이벤트 리스너가 등록된다.
- 그리고 1.에서 발생한 클릭 이벤트 버블링에 의해 2.에서 등록된 이벤트 리스너에 걸리게 된다.
즉 테스트 페이지의 버튼을 클릭하는 순간 토스트가 열리고 useEffect 내의 document 이벤트 리스너가 이를 인식해 버리는 것이다. 만약 mousedown으로 이벤트를 설정하면 어떤 차이가 있을까?
- 테스트 페이지에서 토스트를 생성하기 위한 버튼을 누르는 순간 mousedown 이벤트 발생. 그리고 마우스에서 손을 땐 후에 click 이벤트 발생
- 이때 토스트가 렌더링 되면서 useEffect()의 함수가 실행된다. 그 과정에서 document mousedown 이벤트 리스너가 등록된다.
- 이미 1.에서 발생한 mousedown 이벤트는 버블링까지 끝났기 때문에 등록한 document 이벤트 리스너가 의도치 않게 동작하지 않는다.
이런 결론을 내렸는데 사실 명쾌하지는 않다. 이벤트 버블링 도중에 리스너가 등록되고 있기 때문이다. 그렇기 때문에 명확한 파악이 어렵다. 혹시나 능력자 분께서 이 글을 보고 보충해 준다면 큰 도움이 될 것 같다(그럴 리 없겠지만...). 나중에라도 잠재적인 문제가 발견돼서 수정하게 된다면 그때 다시 내용을 수정해 보겠다.
'스터디 > 프론트엔드' 카테고리의 다른 글
CSS 참고 키워드 - 반응형 웹사이트 (1) | 2024.09.25 |
---|---|
CSS 참고 키워드 - CSS 요소 (0) | 2024.09.24 |
[Internet] 브라우저의 동작 방식 (0) | 2024.08.27 |
[Internet] DNS (0) | 2024.08.26 |
프론트엔드 첫 글 (0) | 2024.08.25 |
댓글