[모던JS: 브라우저] 브라우저 이벤트 (2)

KG·2021년 6월 15일
0

모던JS

목록 보기
32/47
post-thumbnail

Intro

본 포스팅은 여기에 올라온 게시글을 바탕으로 작성되었습니다.
파트와 카테고리 동일한 순서로 모든 내용을 소개하는 것이 아닌, 몰랐거나 새로운 내용 위주로 다시 정리하여 개인공부 목적으로 작성합니다.
중간중간 개인 판단 하에 필요하다고 생각될 시, 기존 내용에 추가로 보충되는 내용이 있을 수 있습니다.

브라우저 기본 동작

상당수의 이벤트는 발생 즉시 브라우저에 의해 특정 동작을 자동으로 수행하게 된다.

  • 링크를 클릭하면 해당 URL로 이동
  • 폼 전송 버튼 클릭 시 서버에 폼이 전송
  • 마우스 버튼을 누른 채로 글자 위에서 커서를 움직이면 글자 선택
  • ...

위와 같은 동작은 브라우저 내에서 자체적으로 구현되어 지원되는 동작이다. 그러나 어떤 경우엔 브라우저 기본동작 대신 자바스크립트를 사용해 직접 동작을 구현해야 하는 경우가 있다.

1) 브라우저 기본 동작 막기

브라우저의 기본 동작을 막을 수 있는 방법은 두 가지가 있다.

  1. event 객체를 사용해 event.preventDefault() 메서드 호출
  2. 핸들러가 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=""' />

2) addEventListener의 passive 옵션

앞서 addEventListener 메서드는 세 번째 인수로 options 객체를 전달받을 수 있었다. 해당 객체에 passive : true 옵션을 지정하여 전달하게 되면, 브라우저에게 preventDefault()를 호출하지 않겠다고 알리는 역할을 한다.

기능은 쉽게 이해할 수 있지만 이러한 옵션은 왜 필요할까? 모바일 기기에는 사용자가 스크린에 손가락을 대고 움직일 때 발생하는 touchmove와 같은 이벤트가 있다. 이런 이벤트는 기본적으로 스크롤링을 발생시킨다. 그러나 핸들러의 preventDefault()를 사용하게 되면 스크롤링까지 막을 수 있다.

브라우저는 스크롤링을 발생시키는 이벤트를 감지했을 때 먼저 모든 핸들러를 처리하는데, 이때 preventDefault가 어디에서도 호출되지 않았다고 판단되었을 때 그제서야 스크롤링을 진행한다. 이 과정에서 불필요한 지연이 생기고, 화면이 덜덜 떨리는 현상이 발생할 수 있다.

passive: true 옵션은 핸들러가 스크롤링을 취소하지 않을 것이라는 정보를 브라우저에게 전달하는 역할을 수행한다. 이 정보를 바탕으로 브라우저는 화면을 최대한 자연스럽게 스크롤링 할 수 있게 하고 이벤트는 적절하게 처리되는 것이다. 크롬과 파이어폭스 등의 몇몇 브라우저는 touchstarttouchmove와 같은 모바일 기기에서 사용되는 이벤트의 기본 passive값을 true로 지정하고 있다.

3) event.defaultPrevented

기본 동작을 막은 경우는 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 같은 내장 이벤트도 입맛에 맞게 변경이 가능하다. 이러한 커스텀한 내장 이벤트는 주로 테스팅을 자동화할 때 유용하다.

1) Event 생성자

내장 이벤트 클래스는 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 객체를 전달받는데, 사실상 거의 모든 부분이 브라우저 고유 이벤트와 동일하나 모든 브라우저에서 동일하게 동작하기 위한 처리가 추가적으로 구현되어 있다. 해당 이벤트의 자세한 이벤트 목록은 공식문서를 참고하자.

2) dispatchEvent

이벤트 객체를 생성한 다음엔 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.isTrustedtrue이면 사용자와의 상호작용을 통해 발생한 이벤트 임을 의미하고, false라면 dispatchEvent를 통해 생성되었음을 말한다.

3) 커스텀 이벤트 버블링 예시

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가 명시적으로 설정되지 않았다면 이벤트 버블링이 일어나지 않는다.

4) MouseEvent, KeyboardEvent 등 다양한 이벤트

명세서의 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 이벤트는 적확한 이벤트 타입이 따로 있고, 이를 통해 접근하는 것을 추천한다.

5) 커스텀 이벤트

지금까진 new Event 생성자로 커스텀 이벤트 객체를 생성했다. 그렇지만 제대로 된 커스텀 이벤트를 만들려면 new CustomEvent를 사용해야 한다. CustomEventEvent와 거의 유사하지만 한 가지 다른 점이 있다.

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를 사용해 커스텀 이벤트 객체를 생성하면 코드 자체만으로도 해당 이벤트가 커스텀 이벤트라는 것을 설명해주는 효과가 있다.

6) event.preventDefault()

브라우저 이벤트 대다수는 기본 동작과 함께 실행된다. 링크 클릭 시 특정 URL로 이동한다거나, 전송 버튼 클릭 시 서버에 폼을 전송하는 동작은 기본 동작의 대표적인 예시이다.

우리가 직접 만든 커스텀 이벤트에는 당연히 기본 동작이 정의되어 있지 않다. 하지만 커스텀 이벤트를 만들고 디스패치하는 코드에 원하는 동작을 구현하면, 커스텀 이벤트에도 기본 동작을 설정해 줄 수 있다.

이벤트 기본 동작은 event.preventDefault()를 호출해 취소할 수 있다는 것을 앞서 살펴보았다. 이때 이벤트 기본 동작이 취소되면 elem.dispatchEvent(event) 호출 시 결과값으로 false가 반환된다. 해당 이벤트를 디스패치 하는 코드에선 따라서 이 값을 통해 기본동작이 취소되어야 한다는 점을 미리 간파할 수 있다.

토끼 숨기기 예시를 통해 위 내용을 직접 구현해보자. idrabbit인 요소와 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를 리턴하여 원래 동작을 취하지 않도록 구현할 수 있다.

7) 이벤트 안 이벤트

이벤트는 대게 큐에서 처리된다. 따라서 브라우저가 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-opendocument에 할당된 핸들러에서 처리된다는 점이다. 중첩 이벤트의 전파와 핸들링이 외부 코드인 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('중첩이벤트'));

되도록이면 내장 이벤트와 같은 이름을 가진 브라우저 이벤트를 만들지 않는 것이 좋다. 복잡한 로직의 경우 예측하지 못한 충돌이 발생할 수 있으며 대부분의 설계 관점에서도 아주 좋지 않은 영향을 끼치는 경우가 많기 때문이다.

그렇지만 이런 경우에는 브라우저 이벤트를 만드는게 불가피할 수 있다.

  • 서드파티 라이브러리가 제대로 동작하게 하려면 꼭 필요한 경우, 네이티브 이벤트를 만드는 것 이외에는 서드 파티 라이브러리와 상호작용 할 수 있는 수단이 없는 경우
  • 테스팅을 자동화 하려는 경우로 버튼 클릭 등의 이벤트를 사용자 동작 없이 코드만으로 유발시키고 제대로 동작하는지 그 결과를 확인하고자 하는 경우

References

  1. https://ko.javascript.info/events
  2. https://www.w3.org/TR/uievents
  3. https://ko.reactjs.org/docs/events.html
profile
개발잘하고싶다

0개의 댓글