본 포스팅은 여기에 올라온 게시글을 바탕으로 작성되었습니다.
파트와 카테고리 동일한 순서로 모든 내용을 소개하는 것이 아닌, 몰랐거나 새로운 내용 위주로 다시 정리하여 개인공부 목적으로 작성합니다.
중간중간 개인 판단 하에 필요하다고 생각될 시, 기존 내용에 추가로 보충되는 내용이 있을 수 있습니다.
상당수의 이벤트는 발생 즉시 브라우저에 의해 특정 동작을 자동으로 수행하게 된다.
위와 같은 동작은 브라우저 내에서 자체적으로 구현되어 지원되는 동작이다. 그러나 어떤 경우엔 브라우저 기본동작 대신 자바스크립트를 사용해 직접 동작을 구현해야 하는 경우가 있다.
브라우저의 기본 동작을 막을 수 있는 방법은 두 가지가 있다.
event
객체를 사용해 event.preventDefault()
메서드 호출addEventListener
가 아닌 on<event>
를 사용해 할당되었다면 false
반환을 통해 기본 동작 방지 가능아래의 예시에서는 링크를 클릭하더라도 URL로 이동하지 않는다.
<a href="/" onclick="return false">link</a>
<a herf="/" onclick="event.preventDefault()">link</a>
이벤트 핸들러에서 반환된 값은 대개 무시된다. 그러나 위에서 살펴본 바와 같이 on<event>
를 사용해 할당한 핸들러에서 false
를 반환하는 것은 예외 사항이다. 이를 제외한 값들은 return
되더라도 무시된다.
기본 동작을 막은 채로 메뉴를 구현해보자. 각 항목은 button
태그가 아니라 링크를 만들 때 쓰이는 a
태그를 이용해 작성할 것이다. 이는 다음을 위해서이다.
a
태그를 제외한 다른 태그는 해당 기능 제공하지 않음<a href='...'>
링크로 이동<ul id='menu' class='menu'>
<li><a href="/html">HTML</a></li>
<li><a href="/javascript">Javascript</a></li>
<li><a href="/css">CSS</a></li>
</ul>
이때 a
태그의 기본 동작이 아닌, 자바스크립트로 클릭 이벤트를 의도적으로 처리하기로 했으므로 관련 작업을 구현해주자. 다음과 같이 return false
를 통해 기본 동작을 취소시킬 수 있다.
menu.onclick = function (event) {
if (event.target.nodeName !== 'A') return;
let href = event.target.getAttribute('href');
console.log(href);
// 서버에서 데이터를 읽어 오거나
// 새로운 UI를 만드는 등의 작업을 처리
return false; // 브라우저의 기본 동작 취소
};
이벤트 위임 방식으로 기본 동작을 취소하는 이벤트 핸들러를 등록해주었다. 만약 return false
를 생략했다면 핸들러가 실행되었더라도 a
태그가 기본 동작을 수행해 페이지 이동이 발생했을 것이다.
어떤 이벤트들은 순차적으로 발생한다. 이런 이벤트는 첫 번째 이벤트를 막게 되면 두 번째 이벤트가 일어나지 않는다.
<input>
필드의mousedown
이벤트는focus
이벤트를 유발한다.mousedown
이벤트를 막게 되면focus
이벤트 역시 발생하지 않는다. 이와 같이 어떤 이벤트가 발생했을 때 다른 이벤트가 기본 동작으로 발생하는 타입을 후속 이벤트라고 한다.<input value='focus 동작' onfocus='this.value=""' /> <input value='focus 동작 X' onmousedown='return false' onfocus='this.value=""' />
앞서 addEventListener
메서드는 세 번째 인수로 options
객체를 전달받을 수 있었다. 해당 객체에 passive : true
옵션을 지정하여 전달하게 되면, 브라우저에게 preventDefault()
를 호출하지 않겠다고 알리는 역할을 한다.
기능은 쉽게 이해할 수 있지만 이러한 옵션은 왜 필요할까? 모바일 기기에는 사용자가 스크린에 손가락을 대고 움직일 때 발생하는 touchmove
와 같은 이벤트가 있다. 이런 이벤트는 기본적으로 스크롤링을 발생시킨다. 그러나 핸들러의 preventDefault()
를 사용하게 되면 스크롤링까지 막을 수 있다.
브라우저는 스크롤링을 발생시키는 이벤트를 감지했을 때 먼저 모든 핸들러를 처리하는데, 이때 preventDefault
가 어디에서도 호출되지 않았다고 판단되었을 때 그제서야 스크롤링을 진행한다. 이 과정에서 불필요한 지연이 생기고, 화면이 덜덜 떨리는 현상이 발생할 수 있다.
passive: true
옵션은 핸들러가 스크롤링을 취소하지 않을 것이라는 정보를 브라우저에게 전달하는 역할을 수행한다. 이 정보를 바탕으로 브라우저는 화면을 최대한 자연스럽게 스크롤링 할 수 있게 하고 이벤트는 적절하게 처리되는 것이다. 크롬과 파이어폭스 등의 몇몇 브라우저는 touchstart
와 touchmove
와 같은 모바일 기기에서 사용되는 이벤트의 기본 passive
값을 true
로 지정하고 있다.
기본 동작을 막은 경우는 event.defaultPrevented
값이 true
이고 그렇지 않은 경우엔 false
이다. 이를 이용한 흥미로운 유스 케이스가 있다.
버블링과 캡처링 파트에서 event.stopPropagetion()
메서드를 살펴볼 때 버블링을 막는 것은 추천하지 않는다고 언급한 바 있다. 몇몇 경우엔 event.defaultPrevented
프로퍼티를 사용해서 stopPropagation()
메서드의 동작을 흉내낼 수 있다.
브라우저에서 마우스 오른쪽 버튼을 누르면 contextmenu
라는 이벤트가 발생한다. 이 이벤트는 컨텍스트 메뉴에 대한 UI를 출력한다 (브라우저마다 조금씩 내용과 디자인이 다를 수 있다). 이때 기본 동작을 막는 방식으로 다른 창을 띄워줄 수도 있다.
<button oncontextmenu="alert('hello'); return false"> click </button>
버튼 자체에서만 컨텍스트 메뉴를 띄우는 대신에, 문서 레벨에서도 자체 컨텍스트 메뉴를 뜨게 할 수 있다.
<p>문서 레벨 컨텍스트 메뉴</p>
<button id='elem'>버튼 레벨 컨텍스트 메뉴</button>
<script>
elem.oncontextmenu = function (event) {
event.preventDefault();
console.log('버튼 컨텍스트 메뉴');
};
document.oncontextmenu = function (event) {
event.preventDefault();
console.log('문서 컨텍스트 메뉴');
};
</script>
그러나 위와 같이 구현하게 되면 elem
요소를 클릭했을 때 버튼 레벨의 컨텍스트 메뉴와 문서 레벨의 컨텍스트 메뉴 2개가 뜨게 되는 문제가 발생한다. 이는 이벤트 버블링으로 인해 조상 요소까지 이벤트가 전파되었기 때문이다. 이를 해결하기 위해서는 간단한 방법으로는 event.stopPropagation()
메서드를 elem
요소 이벤트 핸들러에 추가하는 것이다.
하지만 이는 의도한 대로 잘 동작하지만 추후 확장성을 고려했을땐 매우 좋지 않은 접근 방식이다. 외부에서는 더는 마우스 우클릭에 대한 정보를 버튼 레벨에서 얻어갈 수 없기 때문이다. 따라서 event.stopPropagation()
대신에 앞서 살펴본 event.defaultPrevented
프로퍼티를 이용해서 해결해보자.
event.defaultPrevented
를 사용해 document
레벨에서 기본 동작이 막혀있는지 아닌지를 확인하면 문제 해결이 가능하다. 만약 기본 동작이 막혀있는 상태인데 이벤트를 다시 핸들링하려 하는 경우엔 이에 반응하지 않도록 처리할 수 있다.
<p>문서 레벨 컨텍스트 메뉴</p>
<button id='elem'>버튼 레벨 컨텍스트 메뉴</button>
<script>
elem.oncontextmenu = function (event) {
// 이 시점에서 event.defaultPrevented는 true 된다.
event.preventDefault();
console.log('버튼 컨텍스트 메뉴');
};
document.oncontextmenu = function (event) {
if (event.defaultPrevented) return;
event.preventDefault();
console.log('문서 컨텍스트 메뉴');
};
</script>
위의 예시를 통해 확인할 수 있듯이 event.stopPropagation()
메서드와 event.preventDefault()
메서드는 서로 다른 메서드이다. 의미적으로 비슷해 보일 수 있지만 전혀 다른 기능을 수행하는 연관성이 없는 메서드이다.
기본 동작을 막는 것 역시 남용하는 것은 좋지않다. 이는 기본 HTML 요소가 가지고 있는 기능을 막고 추가적인 기능이나 동작을 부여할 때 쓰이는데, 사실 HTML 태그 본래 의미에 어긋나게 사용될 수 있다. 가급적이면 HTML 태그 본래 의미에 충실하게 사용하는 것이 좋은 코드일 뿐더러 접근성 측면에서도 권장된다. 나아가 HTML을 semantic
한 구조로 유지하는데도 도움이 된다.
자바스크립트를 사용하면 핸들러 할당 뿐만 아니라 이벤트를 직접 만들 수도 있다. 이렇게 직접 만든 커스텀 이벤트는 그래픽 컴포넌트를 생성할 때 주로 사용한다.
자바스크립트 기반 메뉴가 있다고 가정해보자. 개발자는 메뉴의 루트 요소에 open/select
와 같은 이벤트를 달아 상황에 맞게 이벤트가 실행되도록 할 수 있다. 이렇게 루트 요소에 이벤트 핸들러를 달아 놓는다면 바깥 코드에서도 이벤트 리스닝을 통해 메뉴에서 어떤 일이 일어났는지 판단이 가능하다.
자바스크립트 코드를 사용하면 새로운 커스텀 이벤트뿐만 아니라 목적에 따라 click
, mousedown
같은 내장 이벤트도 입맛에 맞게 변경이 가능하다. 이러한 커스텀한 내장 이벤트는 주로 테스팅을 자동화할 때 유용하다.
내장 이벤트 클래스는 DOM
요소 클래스 같이 계층 구조를 형성한다. 내장 이벤트 클래스 계층에 꼭대기엔 Event
클래스가 자리잡고 있다. Event
객체는 다음과 같이 생성할 수 있다.
let event = new Event(type[, options]);
type
: 이벤트 타입을 나타내는 문자열로 click
같은 내장 이벤트, my-event
같은 커스텀 이벤트가 올 수도 있다.options
: 두 개의 선택 프로퍼티가 있는 객체bubbles: true/false
: true
인 경우 이벤트 버블링 허용cancelable: true/false
: true
인 경우 브라우저 기본 동작이 실행되지 않음
React
에서 사용하는 이벤트 객체도 엄연히 보면 커스텀 이벤트이다. 공식문서에서는 이를 합성 이벤트(SyntheticEvent
)라고 표현하고 있다. 용어의 차이가 중요한 것은 아니고, 핵심은 브라우저의 이벤트 객체와는 약간의 차이를 가지고 있다는 점이다.
리액트에서 이벤트 핸들러는 모든 브라우저에서 이벤트를 동일하게 처리하기 위한 이벤트 래퍼SyntheticEvent
객체를 전달받는데, 사실상 거의 모든 부분이 브라우저 고유 이벤트와 동일하나 모든 브라우저에서 동일하게 동작하기 위한 처리가 추가적으로 구현되어 있다. 해당 이벤트의 자세한 이벤트 목록은 공식문서를 참고하자.
이벤트 객체를 생성한 다음엔 event.dispatchEvent(event)
메서드를 호출해 요소에 있는 이벤트를 반드시 실행시켜 주어야 한다. 이렇게 이벤트를 실행시켜야 핸들러가 일반 브라우저 이벤트처럼 이벤트에 반응하게 된다.
<button id='elem' onclick='alert("click")'>
자동으로 클릭되는 버튼
</button>
<script>
let event = new Event('click');
// 이 시점에서 클릭을 하지 않더라도
// elem 요소에 click 이벤트가 발생함
elem.dispatchEvent(event);
</script>
이처럼 이벤트가 dispatchEvent
를 통해 발생했는지, 아니면 정말 유저와의 상호작용을 통해 발생했는지 구분할 수 있는 프로퍼티 또한 event
객체가 제공한다. event.isTrusted
가 true
이면 사용자와의 상호작용을 통해 발생한 이벤트 임을 의미하고, false
라면 dispatchEvent
를 통해 생성되었음을 말한다.
hello
라는 이름을 가진 커스텀 이벤트를 만들고 이를 버블링 시켜 document
객체에서 이벤트를 관리하도록 해보자. 이벤트 버블링을 위해서는 bubbles
를 꼭 true
로 설정해야 한다.
<h1 id='elem'>Hello from the script!</h1>
<script>
document.addEventListener('hello', function(event) {
console.log('hello from " + event.target.tagName);
});
let event = new Event('hello', { bubbles: true });
elem.dispatchEvent(event);
</script>
on<event>
는 내장 이벤트에만 해당하는 HTML 속성이기 때문에 커스텀 이벤트에서는 통용되지 않는다. 따라서 커스텀 이벤트는 반드시 addEventListener
를 사용해 핸들링 되어야 한다.bubbles: true
가 명시적으로 설정되지 않았다면 이벤트 버블링이 일어나지 않는다.명세서의 UI 이벤트섹션에는 다양한 UI 이벤트 클래스가 명시되어 있다.
UIEvent
FocusEvent
MouseEvent
WheelEvent
KeyboardEvent
이런 내장 이벤트들은 new Event
로 만들지 않고, 반드시 관련 내장 클래스를 사용해야 한다. 마우스 클릭 이벤트의 경우엔 new MouseEvent('click')
을 통해 이벤트 객체를 생성해야 한다. 그래야 해당 이벤트 전용 표준 프로퍼티를 정상적으로 사용할 수 있다.
앞서 이벤트마다 사용하는 프로퍼티는 다를 수 있다고 했다. 가령 마우스 포인터 관련 이벤트라면 현재 위치 좌표에 접근할 수 있는 프로퍼티를 지원하지만, 폼 제출 이벤트는 이러한 프로퍼티가 없다. 따라서 전용 표준 프로퍼티를 정상적으로 가져오기 위해서는 알맞은 내장 클래스를 사용해 이벤트 객체 생성이 필요하다.
let mouseEvent = new MouseEvent('click', {
bubbles: true,
cancelable: true,
clientX: 100,
clientY: 100,
});
let event = new Event('click', {
bubbles: true,
cancelable: true,
// 좌표 관련 프로퍼티는 무시된다.
clientX: 100,
clientY: 100,
});
물론 객체의 성질을 이용해 new Event
로 이벤트 객체를 하나 생성하고 event.clientX = 100
과 같은 방식으로 직접 프로퍼티 값을 설정해 주면 이러한 제약을 회피할 수 있다. 그렇지만 브라우저에서 만들어지는 UI 이벤트는 적확한 이벤트 타입이 따로 있고, 이를 통해 접근하는 것을 추천한다.
지금까진 new Event
생성자로 커스텀 이벤트 객체를 생성했다. 그렇지만 제대로 된 커스텀 이벤트를 만들려면 new CustomEvent
를 사용해야 한다. CustomEvent
는 Event
와 거의 유사하지만 한 가지 다른 점이 있다.
CustomEvent
의 두 번째 인수에는 객체가 들어갈 수 있는데, 개발자는 이 객체에 detail
이라는 프로퍼티를 추가해 커스텀 이벤트 관련 정보를 명시하고 정보를 이벤트에 전달할 수 있다.
<h1 id='elem'>Hello World!</h1>
<script>
elem.addEventListener('hello', function(event) {
console.log(event.detail.name);
});
elem.dispatchEvent(new CustomEvent('hello', {
detail: { name: 'KG' }
}));
</script>
detail
프로퍼티에는 어떠한 데이터라도 들어갈 수 있다. 사실 new Event
로 일반 이벤트를 생성한 다음 추가 정보가 담긴 프로퍼티를 이벤트 객체에 추가해주면 되기 때문에 detail
프로퍼티 없이도 충분히 이벤트 객체에 원하는 정보를 추가할 수 있는 방법은 있다. 그렇지만 detail
프로퍼티를 사용하는 주된 이유는 다른 이벤트 프로퍼티와의 충돌을 피하기 위해서이다. 또한 CustomEvent
를 사용해 커스텀 이벤트 객체를 생성하면 코드 자체만으로도 해당 이벤트가 커스텀 이벤트라는 것을 설명해주는 효과가 있다.
브라우저 이벤트 대다수는 기본 동작과 함께 실행된다. 링크 클릭 시 특정 URL로 이동한다거나, 전송 버튼 클릭 시 서버에 폼을 전송하는 동작은 기본 동작의 대표적인 예시이다.
우리가 직접 만든 커스텀 이벤트에는 당연히 기본 동작이 정의되어 있지 않다. 하지만 커스텀 이벤트를 만들고 디스패치하는 코드에 원하는 동작을 구현하면, 커스텀 이벤트에도 기본 동작을 설정해 줄 수 있다.
이벤트 기본 동작은 event.preventDefault()
를 호출해 취소할 수 있다는 것을 앞서 살펴보았다. 이때 이벤트 기본 동작이 취소되면 elem.dispatchEvent(event)
호출 시 결과값으로 false
가 반환된다. 해당 이벤트를 디스패치 하는 코드에선 따라서 이 값을 통해 기본동작이 취소되어야 한다는 점을 미리 간파할 수 있다.
토끼 숨기기 예시를 통해 위 내용을 직접 구현해보자. id
가 rabbit
인 요소와 hide
이벤트를 실행시는 함수 hide()
가 있다. 이때 함수 hide()
는 다른 코드들이 이벤트 실행 여부를 알 수 있게 해준다.
<pre id="rabbit">
|\ /|
\|_|/
/. .\
=\_Y_/=
{>o<}
</pre>
<button onclick="hide()">숨기기</button>
<script>
function hide() {
let event = new CustomEvent('hide', {
cancelable: true
});
if (!rabbit.dispatchEvent(event)) {
alert('기본 동작이 취소되었음');
} else {
rabbit.hidden = true;
}
}
rabbit.addEventListener('hide', function(event) {
if (confirm("preventDefault를 호출하겠습니까?")) {
event.preventDefault();
}
});
</script>
new CustomEvent
생성자를 통해 커스텀 이벤트 객체를 생성할 때 cancelable: true
를 지정해주는 것에 유의하자. 해당 값을 전달하지 않으면 event.preventDefault()
가 정상적으로 처리되지 않는다.
confirm
창에서 확인을 누르면 기본 동작이 차단되는 event.preventDefault()
메서드가 호출되고, 이때 elem.dispatchEvent(event)
은 기본 동작이 차단되었음을 간파하고 false
를 리턴하여 원래 동작을 취하지 않도록 구현할 수 있다.
이벤트는 대게 큐에서 처리된다. 따라서 브라우저가 onclick
이벤트를 처리하고 있는데, 마우스를 움직여서 새로운 이벤트를 발생시키면 이 이벤트에 상응하는 mousemove
핸들러는 onclick
이벤트 처리가 끝난 후에 호출될 것이다.
그런데 이벤트 안 dispatchEvent
처럼 이벤트 안에 다른 이벤트가 있는 경우엔 위와 같은 규칙이 적용되지 않는다. 이벤트 안에 있는 이벤트는 즉시 처리된다는 점에 주의하자. 새로운 이벤트 핸들러가 호출되고 난 후에는 현재 이벤트 핸들링이 계속 재개된다.
예시를 통해 menu-open
이벤트가 onclick
이벤트 처리 도중에 트리거가 되는 것을 확인해보자. menu-open
이벤트 처리는 onclick
이벤트 안에서 발생하고, 따라서 onclick
핸들러가 끝날 때까지 기다리지 않고 바로 처리된다.
<button id='menu'>menu</button>
<script>
menu.onclick = function() {
alert(1);
menu.dispatchEvent(new CustomEvent('menu-open', {
bubbles: true,
}));
alert(2);
};
document.addEventListener('menu-open', () => alert('중첩이벤트'));
</script>
버튼을 클릭하면 1 > 중첩이벤트 > 2
순서로 출력됨을 알 수 있다.
이 예시에서 주목해야 할 점은 중첩 이벤트 menu-open
이 document
에 할당된 핸들러에서 처리된다는 점이다. 중첩 이벤트의 전파와 핸들링이 외부 코드인 onclick
의 처리가 다시 시작되기 전에 끝난 것을 알 수 있다.
이런 일은 중첩 이벤트가 dispatchEvent
일 때뿐만 아니라 이벤트 핸들러 안에서 다른 이벤트를 트리거 하는 메서드를 호출할 때 동일하게 발생한다. 즉 이벤트 안의 이벤트는 동기적인 처리가 보장된다.
그런데 때에 따라 중첩 이벤트가 동기적으로 처리되는 것을 원하지 않을 수도 있다. 예를 들면 menu-open
이벤트나 다른 이벤트 처리 여부와 상관 없이 항상 onclick
이벤트를 먼저 처리하고 싶은 경우가 생길 수 있다.
이런 경우에는 단순하게 onclick
핸들러의 모든 동작이 끝나고 나서 마지막 부분에 dispatchEvent
등의 이벤트 트리거 호출을 넣는 것도 하나의 방법이 될 수 있다. 또는 자바스크립트의 이벤트 루프의 성질을 이용해 스케쥴링 함수 setTimeout
을 지연시간 0으로 호출하는 것도 좋은 방법이다.
menu.onclick = function (event) {
alert(1);
setTimeout(() => menu.dispatchEvent(new CustomEvent('menu-open', {
bubbles: true,
})));
alert(2);
};
document.addEventListener('menu-open', () => alert('중첩이벤트'));
되도록이면 내장 이벤트와 같은 이름을 가진 브라우저 이벤트를 만들지 않는 것이 좋다. 복잡한 로직의 경우 예측하지 못한 충돌이 발생할 수 있으며 대부분의 설계 관점에서도 아주 좋지 않은 영향을 끼치는 경우가 많기 때문이다.
그렇지만 이런 경우에는 브라우저 이벤트를 만드는게 불가피할 수 있다.