티스토리 뷰

320x100

이 글은 주니어 개발자가 쓴 글로 오류가 있을 수 있습니다.
문제가 있거나 수정이 필요한 부분은 댓글로 알려주시면 감사하겠습니다.

 

프론트엔드 개발자를 위한 자바스크립트 프로그래밍(일명 노란책)을 공부하고 정리한 내용입니다.

 

HTML 코드는 코드 블럭 내에서 해석이 되어서 부득이하게 이미지로 올립니다.


자바스크립트와 HTML의 상호작용은 문서나 브라우저 창에서 특정 순간에 일어난 일을 가리키는 '이벤트'에 의해 처리됩니다. 이벤트는 '리스너'(핸들러)로 추적하며 리스너는 이벤트가 일어날 때만 실행됩니다. 전통적인 소프트웨어 공학에서는 이 모델을 옵저버 패턴이라고 부릅니다.

1. 이벤트 흐름

'이벤트 흐름'은 페이지에서 이벤트가 전달되는 순서입니다.

초기에 이벤트를 도입한 목적은 폼을 처리할 때 서버와 브라우저 사이의 데이터 교환을 줄이려는 것이었습니다다. 이후 몇 세대 동안 각 브라우저에서 비슷하지만 다른 API를 도입하다가 DOM 레벨 2에서 DOM 이벤트 API를 논리적으로 표준화하려는 시도를 하고 있습니다.

1-1. 이벤트 버블링(Event Bubbling)

  • 인터넷 익스플로러에서 만든 이벤트 흐름
  • 이벤트가 가장 명시적인 요소, 즉 이벤트 흐름상 문서 트리에서 가장 깊이 위치한 요소에서 시작해 버블처럼 올라가는 흐름

1-2. 이벤트 캡처링(Event Capturing)

  • 넷프케이프 커뮤니케이터에서 만든 이벤트 흐름
  • 이벤트가 최상위 노드에서 발생하여 가장 명시적인 노드로 향하는 흐름
  • 이벤트가 의도한 요소에 도달하기 전에 잡아채려는 목적으로 만들어짐
  • DOM 레벨 2 이벤트 명세에는 document에서 시작해야 한다고 명시되어 있으나 브라우저에서는 window부터 시작함
  • 오래된 브라우저에서는 이벤트 캡처링을 지원하지 않으므로 일반적으로 잘 쓰이지 않고 특별한 상황에만 쓰는 것이 권장됨

1-3. DOM 이벤트 흐름

DOM 레벨 2 이벤트 명세에서 정의한 이벤트 흐름에는 이벤트 캡처링 단계, 타깃 단계, 이벤트 버블링 단계 세 가지가 있습니다. 이 명세에 따르면 이벤트 캡처링 단계에서는 타깃 엘리먼트가 포함되지 않으며, 타깃 단계는 이벤트 버블링 단계에 속하는 것으로 간주합니다.

실제 브라우저에서는 아래 예시와 같이 캡처링 단계에서도 타깃 엘리먼트에 이벤트가 전달되어, 결과적으로 타깃에서 이벤트를 작업할 기회가 두 번 생깁니다.

const eventListener = capture => e => console.log(`${!!capture ? '캡처링' : '버블링'}: ${e.currentTarget.tagName || e.currentTarget}`);
window.addEventListener("click", eventListener(true), true);
window.addEventListener("click", eventListener());
document.addEventListener("click", eventListener(true), true);
document.addEventListener("click", eventListener());
for (let elem of document.querySelectorAll('*')) {
  elem.addEventListener("click", eventListener(true), true);
  elem.addEventListener("click", eventListener());
}

2. 이벤트 핸들러(Event Handler)

이벤트란 사용자 또는 브라우저가 취하는 특정 동작입니다. 이벤트에 응답하여 호출되는 함수를 '이벤트 핸들러(이벤트 리스너)' 라고 부릅니다. 이벤트 핸들러 이름은 'on' + 이벤트명이며, 일반적으로 소문자입니다.

2-1. HTML 이벤트 핸들러

정의

  • 이벤트 핸들러 이름을 HTML 속성 키으로, 실행할 자바스크립트 코드를 속성 값으로 할당하여 이벤트 핸들러를 정의할 수 있습니다. 이벤트 핸들러로 실행하는 코드는 전역 스코프에서 실행됩니다.
  • 속성 값에 앰퍼샌드(&)나 큰따옴표, <, > 같은 HTML 문법 문자를 사용할 때는 이스케이프를 해야 합니다.

특징

  • 이벤트 핸들러 실행 시, 속성 값을 감싸는 함수가 생성됩니다. 이 함수는 event라는 이벤트 객체를 로컬 변수로 갖습니다.
  • 함수 내부에서 this는 이벤트 타깃 요소입니다.
  • with를 통한 스코프 체인 확장으로 함수 내부에서 document와 해당 요소의 속성에 로컬 변수처럼 접근할 수 있습니다. 폼 input 요소인 경우 스코프 체인에 부모인 폼 요소도 포함되어 형제 엘리먼트에 접근할 수도 있습니다.
// with를 통한 스코프 체인 확장
function () {
  with (document) {
    with (this) {
      // 속성 값 접근 가능
    }
  }
}

// input 요소인 경우
function () {
  with (document) {
    with (this.form) {
      with (this) {
        // 속성 값 접근 가능
      }
    }
  }
}

유의 사항

  1. 이벤트 핸들러 코드가 로드되기 전에 이벤트가 발생할 수 있습니다. 이럴 경우, try-catch문으로 예외처리를 하여 조용히 처리할 수 있습니다.
  2. 브라우저마다 이벤트 핸들러 함수의 스코프 체인 확장 결과가 다릅니다. 비적격 객체 멤버에 접근하는 경우 에러가 발생할 수 있습니다.
  3. HTML에 직접 이벤트 핸들러를 할당하면 HTML과 자바스크립트가 너무 단단히 묶입니다.

2-2. DOM 레벨 0 이벤트 핸들러

매우 오래되고 모든 브라우저에서 지원하는 방식으로 이벤트 핸들러 프로퍼티에 함수를 할당하여 이벤트 핸들러를 지정할 수 있습니다.

이벤트 핸들러는 해당 요소의 메소드로 간주되기 때문에 요소의 스코프에서 실행되며 핸들러 내에서 this는 요소 그 자체입니다.

이렇게 추가된 이벤트 핸들러는 버블링 단계에 실행되도록 의도한 것입니다.

const btn = document.getElementById('myButton');
btn.onclick = function () {
  console.log(`${this.id} Clicked`);
}; // 버튼 클릭 시 => myButton Clicked
btn.onclick = null; // 이벤트 핸들러 제거

2-3. DOM 레벨 2 이벤트 핸들러

DOM 레벨 2에서는 이벤트 핸들러를 할당/제거하는 메서드로 addEventListener와 removeEventLister가 추가되었습니다. 이 메서드는 모든 DOM노드에 존재합니다. IE9 이상, 파이어폭스, 사파리, 크롬, 오페라에서 지원합니다.

 

구문

  • target.addEventListener(type,listener[,useCapture]);
  • target.addEventListener(type,listener[,options]);

매개변수 [참고]

  • type: 이벤트 이름
  • listener: 이벤트 핸들러 함수
  • options: Optional. 이벤트 핸들러에 대한 특성을 지정하는 옵션 객체
    • capture: 캡처링(true)/버블링(false) 여부를 나타내는 Boolean. default false.
    • once: 한 번만 호출되어야 함을 나타내는 Boolean 값. true이면 호출할 때 이벤트 핸들러가 자동으로 삭제됨.
    • passive: true일 경우, 이벤트 리스너가 preventDefault() 메서드를 호출하지 않음을 나타냄. default false. [참고]  (true로 등록한 이벤트 리스너에서 preventDefault()를 호출할 경우, 에러가 발생하고 preventDefault()는 동작하지 않음.)
  • useCapture: Optional. 캡처링(true)/버블링(false) 여부를 나타내는 Boolean. default false.

특징

  • 이벤트 핸들러를 여러 개 추가할 수 있습니다. 핸들러는 추가된 순서대로 실행됩니다.
  • 동일한 매개변수를 넘겨서 제거할 수 있습니다.
  • 이벤트 핸들러는 요소의 스코프에서 실행되며 핸들러 내에서 this는 요소 그 자체입니다.
const btn = document.getElementById('myButton');
// 익명 함수로 추가한 경우 제거할 수 없음
btn.addEventListener('click', function () {
  console.log(this.id);
});
btn.addEventListener('click', function () {
  console.log('Hello, World!!');
});

const handler = function () {
  console.log(this.id);
};
btn.addEventListener('click', handler); // 추가
btn.removeEventListener('click', handler); // 제거

3. DOM event 객체 [링크]

DOM 표준을 준수하는 브라우저에서 이벤트 핸들러에 전달되는 매개변수는 event 객체 하나뿐입니다. DOM 레벨 0 방법과 DOM 레벨 2 방법 모두 event 객체가 전달됩니다.

 

event 객체 속성과 메서드

  • currentTarget: 현재 이벤트를 처리 중인 요소. 이벤트 핸들러 함수의 this와 같습니다(화살표 함수인 경우 예외로 this는 전역 객체).
  • target: 이벤트 실제 타깃 요소
const btn = document.getElementById("myButton");
btn.onclick = function (e) {
  console.log("--- button event listener ---");
  console.log("this", this); // button
  console.log("e.currentTarget", e.currentTarget); // button
  console.log("e.target", e.target); // button
  console.log("--- button event listener end ---");
};

document.body.onclick = function (e) {
  console.log("--- body event listener ---");
  console.log("this", this); // body
  console.log("e.currentTarget", e.currentTarget); // body
  console.log("e.target", e.target); // (버튼 클릭 시)button
  console.log("--- body event listener end ---");
};
  • 번외) 이벤트 핸들러 함수의 this가 항상 요소 그 자체임을 유의해야 하는 코드
const obj = {
  name: "joey",
  getName() {
    console.log(`i'm ${this.name}`);
  },
};

document.addEventListener("click", obj.getName); // i'm undefined => this는 document

/* bind 함수로 this를 바인딩하여 해결할 수 있습니다. */
// 이벤트 리스너를 제거할 때 매개변수로 넘기기 위해 bind한 함수를 따로 저장합니다.
const boundGetName = obj.getName.bind(obj);

document.addEventListener("click", boundGetName);
  • type: 발생한 이벤트 타입. 하나의 함수에서 여러 이벤트를 처리하는 경우 유용합니다.
const handler = (e) => {
  switch (e.type) {
    case "click":
      console.log("Clicked");
      break;
    case "mouseover":
      e.target.style.backgroundColor = "red";
      break;
    case "mouseout":
      e.target.style.backgroundColor = "";
      break;
  }
};

btn.onclick = handler;
btn.onmouseover = handler;
btn.onmouseout = handler;
  • eventPhase: 이벤트 핸들러가 호출된 단계(1: 캡처링 단계, 2: 타깃, 3: 버블링 단계). 타깃 단계가 버블링 단계에 포함되기는 하지만 eventPhase값은 2입니다.
const logEventPhase = (e) => console.log(e.eventPhase);
btn.addEventListener("click", logEventPhase);
document.body.addEventListener("click", logEventPhase, true);
document.body.addEventListener("click", logEventPhase);
  • isTrusted: 이벤트가 브라우저에서 생성한 이벤트라면 true, 개발자가 생성한 이벤트라면 false 반환.
const btn = document.getElementById("myButton");
btn.onclick = function (e) {
  console.log("isTrusted", e.isTrusted);
};

const myEvent = new MouseEvent("click", {
  bubbles: true,
  cancelable: true,
  screenX: 0,
  screenY: 0,
});

btn.dispatchEvent(myEvent); // dispatch 한 이벤트의 경우 false, 사용자가 클릭한 경우 true
  • composedPath(): 이벤트 핸들러가 호출될 객체의 배열을 반환. ShadowRoot.mode가 closed이면 shadow tree 내의 노드는 포함하지 않습니다.
btn.addEventListener("click", (e) => console.log(e.composedPath(), e.composed));
  • preventDefault(): 이벤트를 취소할 수 있는 경우, (이벤트의 전파를 막지 않고) 고유의 이벤트를 취소함. ex) a tag, form submit tag, input checkbox etc.
const btn = document.getElementById("myButton");

btn.addEventListener("contextmenu", (e) => {
  e.preventDefault();
  console.log("prevented");
});
  • stopPropagation(): 이벤트 캡처링과 버블링에 있어 현재 이벤트 이후의 전파를 막음.
const btn = document.getElementById("myButton");
const eventListener = (capture) => (e) =>
  console.log(
    `${!!capture ? "캡처링" : "버블링"}: ${
      e.currentTarget.tagName || e.currentTarget
    }`
  );
window.addEventListener("click", eventListener(true), true);
window.addEventListener("click", eventListener());
document.addEventListener("click", eventListener(true), true);
document.addEventListener("click", eventListener());
for (let elem of document.querySelectorAll("*")) {
  elem.addEventListener("click", eventListener(true), true);
  elem.addEventListener("click", eventListener());
}

btn.addEventListener("click", (e) => {
  e.stopPropagation();
  console.log("stopPropagation");
});
  • stopImmediatePropagation(): 같은 이벤트에 남아있는 핸들러들이 호출되는 것을 막고, 전파를 막음
const btn = document.getElementById("myButton");
const eventListener = (capture) => (e) =>
  console.log(
    `${!!capture ? "캡처링" : "버블링"}: ${
      e.currentTarget.tagName || e.currentTarget
    }`
  );
window.addEventListener("click", eventListener(true), true);
window.addEventListener("click", eventListener());
document.addEventListener("click", eventListener(true), true);
document.addEventListener("click", eventListener());
for (let elem of document.querySelectorAll("*")) {
  elem.addEventListener("click", eventListener(true), true);
  elem.addEventListener("click", eventListener());
}

btn.addEventListener("click", (e) => {
  e.stopImmediatePropagation();
  console.log("stopImmediatePropagation");
});

btn.addEventListener("click", (e) => {
  console.log("test - dose it really stop propagation immediately?"); // 출력되지 않음
});
  • 번외) relatedTarget: 이벤트를 발생시킨 요소와 관련된 요소를 반환한다. tab key로 포커스가 이동되는 등 마우스나 커서의 대상이 변경되는 동작에서 활용할 수 있다. 대상 요소는 이벤트마다 다르게 정의된다. [MouseEvent.relatedTarget 참고] [FocusEvent.relaetedTarget 참고]

번외) 개발자 도구 활용

  • Command Line API로 이벤트 리스너 확인하기 [참고]
getEventListeners(elem);

  • Element Tab > 요소 선택 > Event Listeners Tab에서 이벤트 리스너 확인하기

 

아래는 예제로 만든 코드 전문입니다.

더보기
/* bubbling, capturing */
const eventListener = (capture) => (e) =>
  console.log(
    `${!!capture ? "캡처링" : "버블링"}: ${
      e.currentTarget.tagName || e.currentTarget
    }`
  );
window.addEventListener("click", eventListener(true), true);
window.addEventListener("click", eventListener());
document.addEventListener("click", eventListener(true), true);
document.addEventListener("click", eventListener());
for (let elem of document.querySelectorAll("*")) {
  elem.addEventListener("click", eventListener(true), true);
  elem.addEventListener("click", eventListener());
}

/* currentTarget, target */
// const btn = document.getElementById("myButton");
// btn.onclick = function (e) {
//   console.log("--- button event listener ---");
//   console.log("this", this); // button
//   console.log("e.currentTarget", e.currentTarget); // button
//   console.log("e.target", e.target); // button
//   console.log("--- button event listener end ---");
// };

// document.body.onclick = function (e) {
//   console.log("--- body event listener ---");
//   console.log("this", this); // body
//   console.log("e.currentTarget", e.currentTarget); // body
//   console.log("e.target", e.target); // (버튼 클릭 시)button
//   console.log("--- body event listener end ---");
// };

/* type */
// const btn = document.getElementById("myButton");
// const handler = (e) => {
//   switch (e.type) {
//     case "click":
//       console.log("Clicked");
//       break;
//     case "mouseover":
//       e.target.style.backgroundColor = "red";
//       break;
//     case "mouseout":
//       e.target.style.backgroundColor = "";
//       break;
//   }
// };

// btn.onclick = handler;
// btn.onmouseover = handler;
// btn.onmouseout = handler;

/* eventPhase */
// const btn = document.getElementById("myButton");
// const logEventPhase = (e) => console.log(e.eventPhase);
// btn.addEventListener("click", logEventPhase);
// document.body.addEventListener("click", logEventPhase, true);
// document.body.addEventListener("click", logEventPhase);

/* composedPath() */
// const btn = document.getElementById("myButton");
// btn.addEventListener("click", (e) => console.log(e.composedPath(), e.composed));

/* preventDefault() */
// const btn = document.getElementById("myButton");

// btn.addEventListener("contextmenu", (e) => {
//   e.preventDefault();
//   console.log("prevented");
// });

/* stopPropagation() */
// const btn = document.getElementById("myButton");
// const eventListener = (capture) => (e) =>
//   console.log(
//     `${!!capture ? "캡처링" : "버블링"}: ${
//       e.currentTarget.tagName || e.currentTarget
//     }`
//   );
// window.addEventListener("click", eventListener(true), true);
// window.addEventListener("click", eventListener());
// document.addEventListener("click", eventListener(true), true);
// document.addEventListener("click", eventListener());
// for (let elem of document.querySelectorAll("*")) {
//   elem.addEventListener("click", eventListener(true), true);
//   elem.addEventListener("click", eventListener());
// }

// btn.addEventListener("click", (e) => {
//   e.stopPropagation();
//   console.log("stopPropagation");
// });

/* stopImmediatePropagation() */
// const btn = document.getElementById("myButton");
// const eventListener = (capture) => (e) =>
//   console.log(
//     `${!!capture ? "캡처링" : "버블링"}: ${
//       e.currentTarget.tagName || e.currentTarget
//     }`
//   );
// window.addEventListener("click", eventListener(true), true);
// window.addEventListener("click", eventListener());
// document.addEventListener("click", eventListener(true), true);
// document.addEventListener("click", eventListener());
// for (let elem of document.querySelectorAll("*")) {
//   elem.addEventListener("click", eventListener(true), true);
//   elem.addEventListener("click", eventListener());
// }

// btn.addEventListener("click", (e) => {
//   e.stopImmediatePropagation();
//   console.log("stopImmediatePropagation");
// });

// btn.addEventListener("click", (e) => {
//   console.log("test - dose it really stop propagation immediately?"); // 출력되지 않음
// });

참고 자료

DOM 레벨 2 이벤트 명세

320x100
댓글