웹개발을 하며 자바스크립트를 사용해 여러 DOM 요소의 이벤트를 처리함에 있어 stopPropagation, preventDefault에 대해 알아보자.

웹 이벤트의 순서

이벤트는 다음과 같은 순서로 진행된다.

  1. Capture Phase
  2. Target Phase
  3. Bubbling Phase

image.png

  1. Capture phase (캡처 단계): 이벤트는 window 객체부터 시작해서 이벤트 발생 목표(Target)로 곧장 내려가는 단계이다.

  2. Target phase (타겟 단계): 이벤트가 실제로 발생한 목표(Target) 요소에 도달하는 단계이다. 타겟 단계에서는 해당 요소에서 이벤트 핸들러가 처리된다.

  3. Bubbling phase (버블링 단계): 이벤트가 Target 요소에서 부모 요소를 거슬러 다시올라가는 단계이다.

우리가 이벤트 핸들러를 처리하는 부분들은 대개 Target phase나 Bubbling Phase이다.

예를 들어, 어떤 <button> 을 클릭했을 때 button element 자체에 이벤트 핸들러를 달아두었다고 하자.

그럼 그 버튼을 클릭했을 때 이벤트 핸들러가 호출되는 단계는 Target phase이다.

<body>  
  <div id="parent">  
    <button id="btn">Button</button>  
  </div>  
  <script>  
    document.querySelector('#btn').addEventListener('click', (e) => {  
      console.log('btn');  
    });  
  </script>  
</body>

여기서 버튼을 누르면 btn 이 보일 것이다.

그런데 다음과 같은 경우엔 버튼을 누르면 parent가 보일까?

<body>  
  <div id="parent">  
    <button id="btn">Button</button>  
  </div>  
  <script>  
    document.querySelector('#btn').addEventListener('click', (e) => {  
      console.log('btn');  
    });  
    document.querySelector('#parent').addEventListener('click', (e) => {  
      console.log('parent');  
    });  
  </script>  
</body>

다른 플랫폼들에선 gesture가 자식에게서 소비되었으니 부모가 전달받지 못하지만 웹에서는 이또한 호출된다.

btn
parent

웹에서의 이벤트는 항상 (1) Capturing - (2) Target - (3) Bubbling 을 통해 전파되기 때문이다.

이런 전파를 막을 수 있는 방법은 stopPropagation인데, 아래서 기술된다.

Default는 bubbling phase에서 캡쳐되는 것이다.

위 예시에서 순서가 중요하다. 왜 btn이 호출되고 parent가 출력되었을까?

그것을 알아보기 위해 각 이벤트의 eventPhase 속성을 살펴보자.

이는 이벤트 리스너로 들어오는 이벤트 객체에서 참조할 수 있고 다음과 같은 열거값을 갖는다.

  • 0: 이벤트 전파가 아직 발생하지 않았거나, event.stopPropagation() 메서드에 의해 전파가 중단되었을 경우에 0
  • 1: Capturing Phase
  • 2: Target Phase
  • 3: Bubbling Phase
<body>  
  <div id="parent">  
    <button id="btn">Button</button>  
  </div>  
  <script>  
    document.querySelector('#btn').addEventListener('click', (e) => {  
      console.log('btn', e.eventPhase);  
    });  
    document.querySelector('#parent').addEventListener('click', (e) => {  
      console.log('parent', e.eventPhase);  
    });  
  </script>  
</body>
btn 2
parent 3

버튼은 Target Phase, 부모 div는 Bubbling Phase에서 이벤트를 받게된 것을 알 수 있다.

Target 그 자체가 아닐 경우 기본적으로 Bubbling Phase에서 이벤트를 받게 되기 때문에 부모의 출력문이 더 늦게 나온 것이다.

Capturing 단계에서 이벤트를 받는법

addEventListener의 세 번째 인자로 true나 옵션 객체에 capture: true 를 전달하면 된다.

<body>  
  <div id="parent">  
    <button id="btn">Button</button>  
  </div>  
  <script>  
    document.querySelector('#btn').addEventListener(  
      'click',  
      (e) => {  
        console.log('btn', e.eventPhase);  
      },  
      true,  
    );  
    document.querySelector('#parent').addEventListener(  
      'click',  
      (e) => {  
        console.log('parent', e.eventPhase);  
      },  
      true,  
    );  
  </script>  
</body>
parent 1
btn 2

이제 부모의 출력문이 더 먼저 나온다.

Capturing이 Target Phase보다 더 일찍 일어나기 때문이다.

버튼은 이벤트의 주체이기 때문에 eventPhase가 여전히 Target Phase를 가리키고 있다.

React에서 이벤트의 캡슐화

일부러 본문을 바닐라 JS로 작성했지만 React에서 이것을 시도해보면 조금 다르다.

<div onClick={(e) => console.log('parent', e.eventPhase)}>  
  <button onClick={(e) => console.log('btn', e.eventPhase)}>{'Button'}</button>  
</div>
btn 3
parent 3

Target Phase라는게 없어진걸 볼 수 있다. 항상 Bubbling으로만 처리된다.

이것은 React가 이벤트를 처리하는 방식이 DOM과 완전히 동일하지않고 React만의 shortcut Prop들이나 캡슐화를 해두었기 때문이다. React Docs

또한 여러 Shortcut의 존재와 더불어 Capturing 이벤트는 onClick이 아닌 onClickCapture와 같은 것으로 쉽게 구현할 수 있다.

target vs currentTarget

이벤트 핸들러에서 이벤트 객체의 targetcurrentTarget은 조금 다른의미를 가진다.

target은 이벤트가 발생한 주체이고 currentTarget은 이벤트 핸들러가 등록된 주체이다.

이 예시를 보자.

<body>  
  <div id="parent">  
    <button id="btn">Button</button>  
  </div>  
  <script>  
    document.querySelector('#btn').addEventListener('click', (e) => {  
      console.log('btn', e.target.tagName, e.currentTarget.tagName);  
    });  
    document.querySelector('#parent').addEventListener('click', (e) => {  
      console.log('parent', e.target.tagName, e.currentTarget.tagName);  
    });  
  </script>  
</body>

버튼을 클릭하면 다음과 같이 된다.

btn BUTTON BUTTON
parent BUTTON DIV

버튼이 클릭되었으므로 e.target은 이벤트의 주체인 button 이다.

currentTarget은 이벤트 핸들러가 등록된 주체를 나타낸다고 했으므로 parent 의 이벤트 핸들러에서 currentTargetdiv를 가리킴이 옳게된다.

stopPropagation

우린 기본적인 개념을 모두 잡았으므로 이걸 쉽게 이해할 수 있다.

e.stopPropagation 의 함수 이름에서도 볼 수 있듯이 이건 이벤트의 전파를 중지시키는 함수이다.

현재 캡쳐링 단계라면 트리의 아래로 가는 전파를, 버블링이라면 트리의 위로 올라가는 전파를 막는다.

어렵게 생각하지말고 그냥 이 이벤트를 다른 이벤트 핸들러에서 더 못쓰게 만든다고 생각하면 쉽다.

<body>  
  <div id="parent">  
    <button id="btn">Button</button>  
  </div>  
  <script>  
    document.querySelector('#btn').addEventListener('click', (e) => {  
      console.log('btn');  
      e.stopPropagation();  
    });  
    document.querySelector('#parent').addEventListener('click', (e) => {  
      console.log('parent');  
    });  
  </script>  
</body>

이제 버튼을 눌러도 parent가 출력되지 않는다.

preventDefault

preventDefault는 브라우저에서 이 이벤트가 수행됨으로 인해 발생되는 기본 동작들을 중지시킨다.

preventDefault도 event 객체에서 e.preventDefault() 처럼 호출할 수 있고 stopPropagation과 혼동하지 않는것이 중요하다.

예를 들어 a 태그는 누르면 링크로 이동시키는데 이걸 방지하고 싶다고 하자.

<body>  
  <a id="link" href="https://www.mjstudio.net" target="_blank" rel="noopener noreferrer">Link</a>  
  <script>  
    document.querySelector('#link').addEventListener('click', (e) => {  
      console.log('link');  
      e.preventDefault(); // <- here
    });  
  </script>  
</body>

원래대로라면 콘솔에 link도 출력되고 페이지도 이동되어야 했지만, 페이지가 이동되지 않게 된다.

Categories:

Updated:

Comments