[ 살펴보기 ] Javascript - Event bubbling, capturing, delegation

[ 살펴보기 ] Javascript - Event bubbling, capturing, delegation

·

6 min read

다음과 같은 element 구조가 있다고 가정해보자.

...
<div class="parent">
  <div class="box box-container">
    <div class="box box-child box-a">box A</div>
  </div>
</div>
...

위의 element 구조에서 box A을 click한다면 해당 click event는 어떤 경로를 통해 전달될까? click event가 box a element를 통해 발생하더라도 event가 box A element에서만 발생하고 끝나진 않는다. DOM tree의 특정 부분에서 event가 발생하면 DOM tree의 최상단 document부터 시작해서 box A element를 wrapping 하고 있는 각각의 element를 거쳐 box A element에 도달하고 다시 event가 거쳐온 경로를 거쳐서 최상단 document로 이동한다.

즉, DOM event가 발생하면 해당 event는 다음 세 가지 단계를 거치게 된다.

  • Capturing : document로 부터 event가 발생한 element까지 전달되어 내려오는 단계

  • Target : doccument로 부터 전달 된 event가 target element에 도달한 단계

  • Bubbling : target element에 도달한 event가 다시 document로 전달되어 올라가는 단계

Event Bubbling

흔히 사용하는 addEventListener를 통해 추가된 event는 default로 target 단계와 bubbling 단계에서 실행된다.

...
<div class="parent">
  <div class="box box-container">
    <div class="box box-child box-a">box A</div>
  </div>
</div>
<script>
  const parentEl = document.querySelector(".parent");
  const containerEl = document.querySelector(".box-container");
  const childEl = document.querySelector(".box-child");

  parentEl.addEventListener(
    "click",
    (e) => {
      console.log(" ::: parent - bubble ::: ");
    }
  );

  containerEl.addEventListener(
    "click",
    (e) => {
      console.log(" ::: container - bubble ::: ");
    }
  );

  childEl.addEventListener(
    "click",
    (e) => {
      console.log(" ::: child ::: ");
    }
  );

</script>
...

위의 코드를 실행해보면 console에 log가 child → container → parent 순서대로 출력되는 것을 확인할 수 있다. 즉, target element로 부터 target element를 wrapping하고 있는 parent element 그리고 document까지 event가 전파되어 올라가는 것이다. ( bubbling )

그렇다면 위의 예제와 같이 특정 element를 wrapping하는 여러 element에 click event가 존재할 때 event listener에 추가된 event handler에서 실제로 click이 발생한 element를 어떻게 구분할 수 있을까? event handler는 event object를 parameter로 전달 받고 event object의 target property를 통해 실제 event가 발생한 element가 무엇인지 확인할 수 있다.

반면 event object의 currentTarget property는 호출된 event handler가 부착된 element 정보를 return한다. 다음 예제에서 box A element를 click하면 parentEl event handler에서 log하는 e.target은 box A element가 되고 e.currentTarget은 parant class를 가진 element가 된다.

...
<div class="parent">
  <div class="box box-container">
    <div class="box box-child box-a">box A</div>
  </div>
</div>
<script>
  const parentEl = document.querySelector(".parent");
  const containerEl = document.querySelector(".box-container");
  const childEl = document.querySelector(".box-child");

  parentEl.addEventListener(
    "click",
    (e) => {
      console.log(" ::: parent ::: ");
      console.log(e.target); // <div class="box box-child box-a">box A</div>
      console.log(e.currentTarget); // <div class="parent">...</div>
    }
  );

  containerEl.addEventListener(
    "click",
    (e) => {
      console.log(" ::: container ::: ");
    }
  );

  childEl.addEventListener(
    "click",
    (e) => {
      console.log(" ::: child ::: ");
    }
  );

</script>
...

하지만 상황에 따라 event bubbling을 막아야 할 경우도 있다. 만일 event가 상위 element로 bubblign되는 것을 막고 싶다면 event object의 stopPropagaton method를 사용한다. 아래 예제에서 childEl event handler에서 stopPropagation method를 호출하고 있기에 chilEl에서 발생한 event는 더 이상 bubbling되지 않는다.

...
<div class="parent">
  <div class="box box-container">
    <div class="box box-child box-a">box A</div>
  </div>
</div>
<script>
  const parentEl = document.querySelector(".parent");
  const containerEl = document.querySelector(".box-container");
  const childEl = document.querySelector(".box-child");

  parentEl.addEventListener(
    "click",
    (e) => {
      console.log(" ::: parent ::: ");    }
  );

  containerEl.addEventListener(
    "click",
    (e) => {
      console.log(" ::: container ::: ");
    }
  );

  childEl.addEventListener(
    "click",
    (e) => {
      console.log(" ::: child ::: ");
      e.stopPropagaton();
    }
  );

</script>
...

만약 bubbling 방지와 더불어 element에 추가된 동일한 event에 대한 다른 event handler의 실행을 막고 싶다면 event object의 stopImmediatePropagation method를 사용할 수 있다. 예를 들어 아래 예제에서 child 2를 log하는 event handler는 실행되지 않는다.

...
<div class="parent">
  <div class="box box-container">
    <div class="box box-child box-a">box A</div>
  </div>
</div>
<script>
  const parentEl = document.querySelector(".parent");
  const containerEl = document.querySelector(".box-container");
  const childEl = document.querySelector(".box-child");

  parentEl.addEventListener(
    "click",
    (e) => {
      console.log(" ::: parent ::: ");    }
  );

  containerEl.addEventListener(
    "click",
    (e) => {
      console.log(" ::: container ::: ");
    }
  );

  childEl.addEventListener(
    "click",
    (e) => {
      console.log(" ::: child ::: ");
      e.stopPropagaton();
      e.stopImmediatePropagation();
    }
  );

  childEl.addEventListener(
    "click",
    (e) => {
      console.log(" ::: child 2 ::: ");
    }
  );
</script>
...

stopPropagation과 stopImmediatePropagation method는 필요한 상황에서만 사용하는 것이 좋으므로 불필요하게 bubbling을 막지는 않도록 주의하자.

Capturing

bubbling 단계와 반대로 capturing 단계는 최상단 document로부터 event가 전파되어 target event까지 도달하는 단계에서 event가 실행된다. 그리고 capturing 단계에서 event를 실행하기 위해선 다음과 같이 addEventListener에 capturing option을 true로 설정해주어야 한다.

...
<div class="parent">
  <div class="box box-container">
    <div class="box box-child box-a">box A</div>
  </div>
</div>
<script>
  const parentEl = document.querySelector(".parent");
  const containerEl = document.querySelector(".box-container");
  const childEl = document.querySelector(".box-child");

  parentEl.addEventListener(
    "click",
    (e) => {
      console.log(" ::: parent - capture ::: ");
    },
    { capture: true }
  );

  parentEl.addEventListener("click", (e) => {
    console.log(" ::: parent - bubble ::: ");
  });

  containerEl.addEventListener(
    "click",
    (e) => {
      console.log(" ::: container - capture ::: ");
    },
    { capture: true }
  );

  containerEl.addEventListener("click", (e) => {
    console.log(" ::: container - bubble ::: ");
  });

  childEl.addEventListener("click", (e) => {
    console.log(" ::: child ::: ");
  });
</script>
...

위의 코드를 실행해보면 log 순서가 parent capture → container capture → child → container bubble → parent button 순으로 출력되는 것을 확인할 수 있다. capture option을 설정하지 않고 addEventListener를 통해 event handler를 등록하면 default value인 false가 적용되므로 event는 target과 bubbling 단계에서만 실행된다.

만약 removeEventListener를 통해 capturing 단계의 event handler를 제거할 때는 아래와 같이 capture option 역시 함께 설정해 주어야 한다.

...
<script>

const handleContainerCapture = (e) => {
  console.log(" ::: container - capture ::: ");
};

containerEl.addEventListener("click", handleContainerCapture, {
  capture: true,
});

const handleRemoveContainerCapture = () => {
    containerEl.removeEventListener("click", handleContainerCapture, {
      capture: true,
    });
}

</script>
...

또한 capturing 단계에서 stopPropagation method가 호출되면 event는 호출된 event handler에서 멈추고 더 이상 전파되지 않는다. 즉, event bubbling 역시 발생하지 않고 click이 발생한 element에 추가된 event handler도 실행되지 않는다. 예를 들어 아래 코드에서 box A element를 click하면 parentEl의 capture 단계에 추가된 event handler까지만 실행되고 그 다음 event handler는 실행되지 않는다.

...
<div class="parent">
  <div class="box box-container">
    <div class="box box-child box-a">box A</div>
  </div>
</div>

<script>

parentEl.addEventListener(
  "click",
  (e) => {
    console.log(" ::: parent - capture ::: ");
    e.stopPropagation();
  },
  { capture: true }
);

parentEl.addEventListener("click", (e) => {
  console.log(" ::: parent - bubble ::: ");
});

containerEl.addEventListener(
  "click",
  (e) => {
    console.log(" ::: container - capture ::: ");
  },
  { capture: true }
);

containerEl.addEventListener("click", (e) => {
  console.log(" ::: container - bubble ::: ");
});

childEl.addEventListener("click", (e) => {
  console.log(" ::: child  ::: ");
});

</script>
...

여기서 주의할 점은 모든 event에서 bubbling이 발생하진 않는다는 점이다. 예를 들어 focus와 같은 event는 capturing 단계에서 event를 catch할 수 있지만 event 전파가 target 단계에서 종료되고 bubbling 되진 않는다.

<div class="input-container">
  <input type="text" class="test-input" />
</div>

<script>
const inputContainerEl = document.querySelector(".input-container");
const inputEl = document.querySelector(".test-input");

inputContainerEl.addEventListener(
  "focus",
  (e) => {
    console.log(" ::: input container - focus ( capture ) ::: ");
  },
  { capture: true }
);

// focus event가 bubbling되지 않는다.
inputContainerEl.addEventListener("focus", (e) => {
  console.log(" ::: input container - focus ( bubbling ) ::: ");
});

inputEl.addEventListener("focus", (e) => {
  console.log(" ::: input - focus ::: ");
});
</script>

Event delegation

위에서 살펴볼 capturing 또는 bubbling을 통해 특정 element에서 발생한 event의 처리를 해당 element가 아닌 element를 wrapping하고 있는 상단 element에서 처리할 수 있다. 그리고 이러한 방법을 통해 event를 처리하는 방법을 event delegation이라고 한다.

예를 들어 다음과 같은 예제가 있다고 가정해보자.

...
<div class="box box-container">
  <div class="box box-child">box</div>
  <div class="box box-child">box</div>
  <div class="box box-child">box</div>
  <div class="box box-child">box</div>
</div>
...

위의 예제에서 box-child class를 가진 element를 click 했을 때 해당 element에 active라는 class를 추가하고 싶다면 어떻게 할 수 있을까? box-child class를 가진 모든 element에 event handler를 추가해줄 수도 있지만 아래와 같이 event delegation을 통해 필요한 작업을 수행할 수도 있다.

...
  <div class="box box-container">
    <div class="box box-child">box</div>
    <div class="box box-child">box</div>
    <div class="box box-child">box</div>
    <div class="box box-child">box</div>
  </div>

  <script>
    const containerEl = document.querySelector(".box-container");
    const childEl = document.querySelector(".box-child");

    containerEl.addEventListener("click", (e) => {
      if (e.target.classList.contains("box")) {
        e.target.classList.add("active");
      }
    });
  </script>
...

위의 예제에서 볼 수 있듯이 click event에 대한 handler를 box-child element에 각각 추가하는 것이 아닌 보다 상위에 있는 element인 box-container element에 click event handler를 추가하고 box-child element에 click event가 발생하면 bubbling 단계에서 box-container element에 추가한 event handler를 통해 필요한 작업이 수행된다. 즉, box-child element에 필요한 event에 대한 처리를 상위 element인 box-container element에 delegation 시켜 필요한 작업을 수행하는 것이다.