이벤트 드리븐 프로그래밍
브라우저는 처리해야 할 특정 사건이 발생하면 이를 감지하여 이벤트를 발생시킨다. 예를 들어, 클릭, 키보드 입력, 마우스 이동 등이 일어나면 브라우저는 이를 감지하여 특정한 타입의 이벤트를 발생시킨다.
이벤트가 발생했을 때 호출될 함수를 이벤트 핸들러라 하고, 이벤트가 발생했을 때 브라우저에게 이벤트 핸들러의 호출을 위임하는 것을 이벤트 핸들러 등록이라 한다.
브라우저는 사용자의 버튼 클릭을 감지해 클릭 이벤트를 발생시킬 수 있다.
<!DOCTYPE html>
<html>
<body>
<button>Click me!</button>
<script>
const $button = document.querySelector('button');
// 사용자가 버튼을 클릭하면 함수를 호출하도록 요청
$button.onclick = () => { alert('button click'); };
</script>
</body>
</html>
이처럼 이벤트와 그에 대응하는 이벤트 핸들러를 통해 사용자와 에플리케이션은 상호착용하며 이런 프로그래밍 방식을 이벤트 드리븐 프로그래밍이라 한다.
이벤트 타입
이벤트 타입은 이벤트의 종류를 나타내는 문자열이다.
아래의 MDN 문서에서 이벤트 타입들을 확인해 보자.
이벤트 핸들러 등록
이벤트 핸들러는 이벤트가 발생했을 때 브라우저에 호출을 위임한 함수다.
이벤트가 발생하면 브라우저에 의해 호출될 함수가 이벤트 핸들러이다.
이벤트 핸들러 어트리뷰트 방식
HTML 요소의 어트리뷰트 중에는 이벤트에 대응하는 이벤트 핸들러 어트리뷰트가 있다.
<!DOCTYPE html>
<html>
<body>
<button onclick="sayHi('Lee')">Click me!</button>
<script>
function sayHi(name) {
console.log(`Hi! ${name}.`);
}
</script>
</body>
</html>
이벤트 핸들러를 등록할 때 함수 참조를 등록해야 브라우저가 이벤트 핸들러를 호출할 수 있다. 만약 함수 참조가 아닌 함수 호출문을 등록하면 브라우저가 이벤트 핸들러를 호출할 수 없다.
하지만 위 예제에서는 함수 호출문을 할당했는데 이벤트 핸들러 어트리뷰트 값은 암묵적으로 생성될 이벤트 핸들러의 함수 몸체를 의미한다.
즉, onclick 어트리뷰트는 파싱되어 함수를 암묵적으로 생성하고 onclick 이벤트 핸들러 프로퍼티에 할당된다.
function onclick(event) {
sayHi('Lee');
}
이처럼 동작하는 이유는 이벤트 핸들러에 인수를 전달하기 위함이며, 만약 이벤트 핸들러 어트리뷰트 값으로 함수 참조를 할당해야 한다면 이벤트 핸들러에 인수를 전달하기 곤란하다.
<!-- 이벤트 핸들러에 인수를 전달하기 곤란하다. -->
<button onclick="sayHi">Click me!</button>
이벤트 핸들러 어트리뷰트 방식은 오래된 코드에서 간혹 이 방식을 사용한 것이 있기 때문에 알아 두면 좋지만, HTML과 JS는 관심사가 다르므로 분리하는 것이 좋다.
CBD(Component Based Development) 방식의 Angular/React/Svelte/Vue.js 같은 프레임워크/라이브러리에서는 이벤트 핸들러 어트리뷰트 방식으로 이벤트를 처리한다.
CBD 방식에서는 HTML, CSS, JS를 관심사가 다른 개별적인 요소가 아닌 뷰를 구성하기 위한 구성 요소로 보기 때문이다.
이벤트 핸들러 프로퍼티 방식
window 객체와 Document, HTMLElement 타입의 DOM 노드 객체는 이벤트에 대응하는 이벤트 핸들러 프로퍼티를 가지고 있다.
<!DOCTYPE html>
<html>
<body>
<button>Click me!</button>
<script>
const $button = document.querySelector('button');
// 이벤트 핸들러 프로퍼티에 이벤트 핸들러를 바인딩
$button.onclick = function () {
console.log('button click');
};
</script>
</body>
</html>
이벤트 핸들러를 등록하기 위해서는 이벤트를 발생시킬 객체인 이벤트 타깃과 이벤트의 종류인 이벤트 타입 그리고 이벤트 핸들러를 지정할 필요가 있다.
이벤트 핸들러 어트리뷰트 방식도 DOM 노드 객체의 이벤트 핸들러 프로퍼티로 변환되므로 두 방식은 같은 방식이다.
하지만 이벤트 핸들러 프로퍼티 방식은 HTML과 JS가 뒤섞이는 문제를 해결할 수 있다는 장점이 있지만, 하나의 이벤트 핸들러만 바인딩할 수 있다는 단점이 있다.
<!DOCTYPE html>
<html>
<body>
<button>Click me!</button>
<script>
const $button = document.querySelector('button');
// 이벤트 핸들러 프로퍼티 방식은 하나의 이벤트에 하나의 이벤트 핸들러만을 바인딩할 수 있다.
// 첫 번째로 바인딩된 이벤트 핸들러는 두 번째 바인딩된 이벤트 핸들러에 의해 재할당되어
// 실행되지 않는다.
$button.onclick = function () {
console.log('Button clicked 1');
};
// 두 번째로 바인딩된 이벤트 핸들러
$button.onclick = function () {
console.log('Button clicked 2');
};
</script>
</body>
</html>
addEventListner 메서드 방식
<!DOCTYPE html>
<html>
<body>
<button>Click me!</button>
<script>
const $button = document.querySelector('button');
// 이벤트 핸들러 프로퍼티 방식
// $button.onclick = function () {
// console.log('button click');
// };
// addEventListener 메서드 방식
$button.addEventListener('click', function () {
console.log('button click');
});
</script>
</body>
</html>
이벤트 핸들러 프로퍼티 방식은 이벤트 핸들러 프로퍼티에 이벤트 핸들러를 바인딩하지만 addEventListener 메서드에는 이벤트 핸들러를 인수로 전달한다.
만일 동일한 HTML 요소에 발생한 동일한 이벤트에 대해 이벤트 핸들러 프로퍼티 방식과 addEventListner 메서드 방식을 모두 사용하여 이벤트 핸들러를 등록하면 어떻게 동작할지 생각해보자.
<!DOCTYPE html>
<html>
<body>
<button>Click me!</button>
<script>
const $button = document.querySelector('button');
// 이벤트 핸들러 프로퍼티 방식
$button.onclick = function () {
console.log('[이벤트 핸들러 프로퍼티 방식]button click');
};
// addEventListener 메서드 방식
$button.addEventListener('click', function () {
console.log('[addEventListener 메서드 방식]button click');
});
</script>
</body>
</html>
단 addEventListener 메서드를 통해 참조가 동일한 이벤트 핸들러를 중복 등록하면 하나의 이벤트 핸들러만 등록된다.
<!DOCTYPE html>
<html>
<body>
<button>Click me!</button>
<script>
const $button = document.querySelector('button');
const handleClick = () => console.log('button click');
// 참조가 동일한 이벤트 핸들러를 중복 등록하면 하나의 핸들러만 등록된다.
$button.addEventListener('click', handleClick);
$button.addEventListener('click', handleClick);
</script>
</body>
</html>
이벤트 핸들러 제거
addEventListener 메서드로 등록한 이벤트 핸들러를 제거하려면 EventTarget.prototype.removeEventListener 메서드를 사용한다.
<!DOCTYPE html>
<html>
<body>
<button>Click me!</button>
<script>
const $button = document.querySelector('button');
const handleClick = () => console.log('button click');
// 이벤트 핸들러 등록
$button.addEventListener('click', handleClick);
// 이벤트 핸들러 제거
// addEventListener 메서드에 전달한 인수와 removeEventListener 메서드에
// 전달한 인수가 일치하지 않으면 이벤트 핸들러가 제거되지 않는다.
$button.removeEventListener('click', handleClick, true); // 실패
$button.removeEventListener('click', handleClick); // 성공
</script>
</body>
</html>
removeEventListener 메서드에 인수로 전달한 이벤트 핸들러는 addEventListener 메서드에 인수로 전달한 등록 이벤트 핸들러와 같은 함수여야 한다. 만약 무명 함수를 이벤트로 등록했다면 제거할 수 없다.
// 이벤트 핸들러 등록
$button.addEventListener('click', () => console.log('button click'));
// 등록한 이벤트 핸들러를 참조할 수 없으므로 제거할 수 없다.
단, 기명 이벤트 핸들러 내부에서 removeEventListener 메서드를 호출하여 이벤트 핸들러를 제거하는 것은 가능하다. 이러한 경우에는 이벤트 핸들러는 단 한 번만 호출된다.
// 기명 함수를 이벤트 핸들러로 등록
$button.addEventListener('click', function foo() {
console.log('button click');
// 이벤트 핸들러를 제거한다. 따라서 이벤트 핸들러는 단 한 번만 호출된다.
$button.removeEventListener('click', foo);
});
기명 함수를 이벤트 핸들러로 등록할 수 없다면 호출된 함수, 즉 함수 자신을 가리키는 argument.callee 를 사용할 수 있지만 코드 최적화를 방해하므로 되도록 사용하지 말자.
// 무명 함수를 이벤트 핸들러로 등록
$button.addEventListener('click', function () {
console.log('button click');
// 이벤트 핸들러를 제거한다. 따라서 이벤트 핸들러는 단 한 번만 호출된다.
// arguments.callee는 호출된 함수, 즉 함수 자신을 가리킨다.
$button.removeEventListener('click', arguments.callee);
});
이벤트 핸들러 프로프티 방식으로 등록한 이벤트 핸들러는 removeEventListener 메서드로 제거할 수 없으며 이벤트 핸들러를 제거하려면 이벤트 핸들러 프로퍼티에 null을 할당한다.
<!DOCTYPE html>
<html>
<body>
<button>Click me!</button>
<script>
const $button = document.querySelector('button');
const handleClick = () => console.log('button click');
// 이벤트 핸들러 프로퍼티 방식으로 이벤트 핸들러 등록
$button.onclick = handleClick;
// removeEventListener 메서드로 이벤트 핸들러를 제거할 수 없다.
$button.removeEventListener('click', handleClick);
// 이벤트 핸들러 프로퍼티에 null을 할당하여 이벤트 핸들러를 제거한다.
$button.onclick = null;
</script>
</body>
</html>
이벤트 객체
이벤트가 발생하면 이벤트에 관련한 다양한 덩보를 담고 있는 이벤트 객체가 동적으로 생성된다. 생성된 이벤트 객체는 이벤트 핸들러의 첫 번째 인수로 전달된다.
<!DOCTYPE html>
<html>
<body>
<p>클릭하세요. 클릭한 곳의 좌표가 표시됩니다.</p>
<em class="message"></em>
<script>
const $msg = document.querySelector('.message');
// 클릭 이벤트에 의해 생성된 이벤트 객체는 이벤트 핸들러의 첫 번째 인수로 전달된다.
function showCoords(e) {
$msg.textContent = `clientX: ${e.clientX}, clientY: ${e.clientY}`;
}
document.onclick = showCoords;
</script>
</body>
</html>
이벤트 핸들러 어트리뷰트 방식으로 이벤트 핸들러를 등록했다면 다음과 같이 event를 통해 이벤트 객체를 전달받을 수 있다.
<!DOCTYPE html>
<html>
<head>
<style>
html, body { height: 100%; }
</style>
</head>
<!-- 이벤트 핸들러 어트리뷰트 방식의 경우 event가 아닌 다른 이름으로는 이벤트 객체를
전달받지 못한다. -->
<body onclick="showCoords(event)">
<p>클릭하세요. 클릭한 곳의 좌표가 표시됩니다.</p>
<em class="message"></em>
<script>
const $msg = document.querySelector('.message');
// 클릭 이벤트에 의해 생성된 이벤트 객체는 이벤트 핸들러의 첫 번째 인수로 전달된다.
function showCoords(e) {
$msg.textContent = `clientX: ${e.clientX}, clientY: ${e.clientY}`;
}
</script>
</body>
</html>
이벤트 핸들러 어트리뷰트 방식의 경우 이벤트 객체를 전달받으려면 이벤트 핸들러의 첫 번째 매개변수 이름을 반드시 event이어야 한다.
function onclick(event) {
showCoords(event);
}
이벤트 객체의 상속 구조
이벤트가 발생하면 이벤트 타입에 따라 다양한 타입의 이벤트 객체가 생성된다.
위 그림의 Event, UIEvent, MouseEvent 등 모두는 생성자 함수이다.
<!DOCTYPE html>
<html>
<body>
<script>
// Event 생성자 함수를 호출하여 foo 이벤트 타입의 Event 객체를 생성한다.
let e = new Event('foo');
console.log(e);
// Event {isTrusted: false, type: "foo", target: null, ...}
console.log(e.type); // "foo"
console.log(e instanceof Event); // true
console.log(e instanceof Object); // true
// FocusEvent 생성자 함수를 호출하여 focus 이벤트 타입의 FocusEvent 객체를 생성한다.
e = new FocusEvent('focus');
console.log(e);
// FocusEvent {isTrusted: false, relatedTarget: null, view: null, ...}
// MouseEvent 생성자 함수를 호출하여 click 이벤트 타입의 MouseEvent 객체를 생성한다.
e = new MouseEvent('click');
console.log(e);
// MouseEvent {isTrusted: false, screenX: 0, screenY: 0, clientX: 0, ... }
// KeyboardEvent 생성자 함수를 호출하여 keyup 이벤트 타입의 KeyboardEvent 객체를
// 생성한다.
e = new KeyboardEvent('keyup');
console.log(e);
// KeyboardEvent {isTrusted: false, key: "", code: "", ctrlKey: false, ...}
// InputEvent 생성자 함수를 호출하여 change 이벤트 타입의 InputEvent 객체를 생성한다.
e = new InputEvent('change');
console.log(e);
// InputEvent {isTrusted: false, data: null, inputType: "", ...}
</script>
</body>
</html>
따라서 생성자 함수로 객체를 만드는 것이 가능하며 이벤트가 발생하면 이벤트 객체가 생성자 함수에 의해 생성되게 된다.
이벤트 객체 중 일부는 사용자의 행위에 의해 생성된 것이고 일부는 JS 코드에 의해 인위적으로 생성된 것이다. 즉, 이벤트 객체의 프로퍼티는 발생한 이벤트의 타입에 따라 달라진다.
<!DOCTYPE html>
<html>
<body>
<input type="text">
<input type="checkbox">
<button>Click me!</button>
<script>
const $input = document.querySelector('input[type=text]');
const $checkbox = document.querySelector('input[type=checkbox]');
const $button = document.querySelector('button');
// load 이벤트가 발생하면 Event 타입의 이벤트 객체가 생성된다.
window.onload = console.log;
// change 이벤트가 발생하면 Event 타입의 이벤트 객체가 생성된다.
$checkbox.onchange = console.log;
// focus 이벤트가 발생하면 FocusEvent 타입의 이벤트 객체가 생성된다.
$input.onfocus = console.log;
// input 이벤트가 발생하면 InputEvent 타입의 이벤트 객체가 생성된다.
$input.oninput = console.log;
// keyup 이벤트가 발생하면 KeyboardEvent 타입의 이벤트 객체가 생성된다.
$input.onkeyup = console.log;
// click 이벤트가 발생하면 MouseEvent 타입의 이벤트 객체가 생성된다.
$button.onclick = console.log;
</script>
</body>
</html>
이벤트 객체의 공통 프로퍼티
Event 인터페이스, 즉 Event.prototype에 정의되어 있는 이벤트 관련 프로퍼티는 모든 파생 이벤트 객체에 상속되며 이벤트 객체의 공통 프로퍼티는 다음과 같다.
공통 프로퍼티 | 설명 | 타입 |
type | 이벤트 타입 | string |
target | 이벤트를 발생시킨 DOM 요소 | DOM 요소 노드 |
currentTarget | 이벤트 핸들러가 바인딩된 DOM 요소 | DOM 요소 노드 |
eventPhase | 이벤트 전파 단계 0: 이벤트 없음, 1: 캡처링 단계, 2: 타깃 단계, 3: 버블링 단계 |
number |
bubbles | 이벤트를 버블링으로 전파하는지 여부. 다음 이벤트는 bubbles: false로 버블링하지 않는다. 포커스 이벤트: focus/blur 리소스 이벤트: load/unload/abort/error 마우스 이벤트: mouseenter/mouseleave |
boolean |
cancelable | preventDefault 메서드를 호출하여 이벤트의 기본 동작을 취소할 수 있는지 여부. 다음 이벤트는 cancelable: false로 취소할 수 없다. 포커스 이벤트: focus/blur 리소스 이벤트: load/unload/abort/error 마우스 이벤트: mouseenter/mouseleave |
boolean |
defaultPrevented | preventDefault 메서드를 호출하여 이벤트를 취소했는지 여부 | boolean |
isTrusted | 사용자의 행위에 의해 발생한 이벤트인지 여부. | boolean |
timeStamp | 이벤트가 발생한 시각 | number |
체크박스 요소의 체크 상태가 변경되면 현재 체크 상태를 출력해보도록 하자.
<!DOCTYPE html>
<html>
<body>
<input type="checkbox">
<em class="message">off</em>
<script>
const $checkbox = document.querySelector('input[type=checkbox]');
const $msg = document.querySelector('.message');
// change 이벤트가 발생하면 Event 타입의 이벤트 객체가 생성된다.
$checkbox.onchange = e => {
console.log(Object.getPrototypeOf(e) === Event.prototype); // true
// e.target은 change 이벤트를 발생시킨 DOM 요소 $checkbox를 가리키고
// e.target.checked는 체크박스 요소의 현재 체크 상태를 나타낸다.
$msg.textContent = e.target.checked ? 'on' : 'off';
};
</script>
</body>
</html>
마우스 정보 취득
click, dbclick, mousedown, mouseup 등 이벤트가 발생하면 생성되는 MouseEvent 타입의 이벤트 객체가 있다.
해당 이벤트를 활용해서 드래그 & 드롭 예제를 만들자.
드래그는 마우스 버튼을 누른 상태에서 마우스를 이동하는 것으로 시작하고 마우스 버튼을 때면 종료한다.
<!DOCTYPE html>
<html>
<head>
<style>
.box {
width: 100px;
height: 100px;
background-color: #fff700;
border: 5px solid orange;
cursor: pointer;
}
</style>
</head>
<body>
<div class="box"></div>
<script>
// 드래그 대상 요소
const $box = document.querySelector('.box');
// 드래그 시작 시점의 마우스 포인터 위치
const initialMousePos = { x: 0, y: 0 };
// 오프셋: 이동할 거리
const offset = { x: 0, y: 0 };
// mousemove 이벤트 핸들러
const move = e => {
// 오프셋 = 현재(드래그하고 있는 시점) 마우스 포인터 위치 - 드래그 시작 시점의 마우스 포인터 위치
offset.x = e.clientX - initialMousePos.x;
offset.y = e.clientY - initialMousePos.y;
// translate3d는 GPU를 사용하므로 absolute의 top, left를 사용하는 것보다 빠르다.
// top, left는 레이아웃에 영향을 준다.
$box.style.transform = `translate3d(${offset.x}px, ${offset.y}px, 0)`;
};
// mousedown 이벤트가 발생하면 드래그 시작 시점의 마우스 포인터 좌표를 저장한다.
$box.addEventListener('mousedown', e => {
// 이동 거리를 계산하기 위해 mousedown 이벤트가 발생(드래그를 시작)하면
// 드래그 시작 시점의 마우스 포인터 좌표(e.clientX/e.clientY: 뷰포트 상에서 현재
// 마우스의 포인터 좌표)를 저장해 둔다. 한번 이상 드래그로 이동한 경우 move에서
// translate3d(${offset.x}px, ${offset.y}px, 0)으로 이동한 상태이므로
// offset.x와 offset.y를 빼주어야 한다.
initialMousePos.x = e.clientX - offset.x;
initialMousePos.y = e.clientY - offset.y;
// mousedown 이벤트가 발생한 상태에서 mousemove 이벤트가 발생하면
// box 요소를 이동시킨다.
document.addEventListener('mousemove', move);
});
// mouseup 이벤트가 발생하면 mousemove 이벤트를 제거해 이동을 멈춘다.
document.addEventListener('mouseup', () => {
document.removeEventListener('mousemove', move);
});
</script>
</body>
</html>
키보드 정보 취득
keydown, keyup, keypress 이벤트가 발생하면 생성되는 KeyboardEvent 타입의 이벤트 객체를 사용하는 예제를 만들어 보자.
input 요소의 입력 필드에 엔터 키가 입력되면 현재까지 입력 필드에 입력된 값을 출력하는 예제를 만들어 보자.
<!DOCTYPE html>
<html>
<body>
<input type="text" />
<em class="message"></em>
<script>
const $input = document.querySelector('input[type=text]');
const $msg = document.querySelector('.message');
$input.onkeyup = e => {
// e.key는 입력한 키 값을 문자열로 반환한다.
// 입력한 키가 'Enter', 즉 엔터 키가 아니면 무시한다.
if (e.key !== 'Enter') return;
// 엔터키가 입력되면 현재까지 입력 필드에 입력된 값을 출력한다.
$msg.textContent = e.target.value;
e.target.value = '';
};
</script>
</body>
</html>
'JavaScript > 모던 자바스크립트 Deep Dive' 카테고리의 다른 글
[Deep Dive] 타이머 (0) | 2024.10.17 |
---|---|
[Deep Dive] 이벤트 - 2 (3) | 2024.10.08 |
[Deep Dive] DOM - 3 (1) | 2024.09.20 |
[Deep Dive] DOM - 2 (1) | 2024.09.10 |
[Deep Dive] DOM - 1 (1) | 2024.09.05 |