이벤트 위임으로 필요한 곳에만 이벤트 적용하기

2024년 3월 17일

이번 점심 뭐먹지 미션에서 음식점 목록에서 각각 항목의 즐겨찾기 버튼을 클릭했을시에, 아이콘이 변경되어야하는 기능 구현이 필요했다.

export const clickFavoriteIconHandler = () => {
  document.addEventListener('DOMContentLoaded', () => {
    const restaurantItems = document.querySelectorAll('.restaurant');

    restaurantItems.forEach((item) => {
      item.addEventListener('click', function (this: HTMLElement) {
        const restaurantId = this.dataset.id;
        console.log('restaurantId : ', restaurantId);
        changeFavoriteIconState(restaurantId ?? '');
      });
    });
  });
};

function changeFavoriteIconState(restaurantId: string) {
    // id 값을 받아서 UI 변경하는 로직
}

초기에 이 기능을 구현하기 위해서 ul태그를 선택해서 반복문을 돌며 각각의 li 태그에 이벤트를 부착해주는 식으로 기능을 구현했었다.

하지만 여기서 문제점은, 하나의 li태그를 클릭하더라도 콘솔 값이 모든 li태그의 갯수만큼 발생했다.

restaurantId :  1
restaurantId :  1
...

1번 요소를 클릭했음에도 해당 요소 갯수만큼 콘솔값이 나왔다.

이 문제의 원인은 이벤트 핸들러가 중복으로 등록 됐기 때문인데, 예를 들어서 사용자가 해당 요소를 클릭했을 당시에, 이벤트 핸들러가 여러번 등록이 되는 것이다.

그리고 단일 음식점 아이템들 같은 경우 동적으로 음식점이 추가가 될수 있는 부분이다.

하지만 이런 경우에 clickFavoriteIconHandler가 새로운 요소들에 대해서 다시 호출이 되면서 이미 이벤트 리스너가 등록된 요소들에 중복으로 이벤트 리스너가 추가될 수도 있다.

그래서 이러한 문제를 해결하기 위해서 해결 방법을 찾던 중 이벤트 위임 방식을 알게 되었다.

- 이벤트 위임이란 ?

이벤트 위임은 하나의 상위 요소가 있을때, 해당 요소의 자식 요소에서 발생하는 이벤트를 처리하기 위해서 상위 요소에서 이벤트 리스너를 등록하는 방식이다.

<body id="app">
  <ul id="parent">
    <li class="child">아이템 1</li>
    <li class="child">아이템 2</li>
    <li class="child">아이템 3</li>
  </ul>
</body>
<script type="module">
  const parent = document.getElementById('parent');

  parent.addEventListener('click', (e) => {
    if (e.target && e.target.nodeName === 'LI') {
      console.log(e.target.textContent + '을 클릭');
    }
  });
</script>

여기서 선택자로 parent를 가져왔지만, 상위 요소에 이벤트를 바인딩 해줌으로써 하위 요소의 값들을 가져오는 것을 확인할 수 있다.

각 요소마다 이벤트리스너를 바인딩 해줄 필요 없고 상위에서 한번만 등록해주면 된다.

또한 동적으로 아이템들이 추가되더라도 그에 따른 이벤트 바인딩도 자동으로 적용 되므로 성능상 이점 또한 누릴 수 있다.

이 이벤트 위임의 개념을 바탕으로 음식점 목록에서의 즐겨찾기 버튼 또한 동적으로 변경해줄 수 있었다.

- bindChangeFavoriteIconStateHandler (이벤트 리스너 함수)

export const bindChangeFavoriteIconStateHandler = () => {
  const listContainer = document.querySelector('.restaurant-list');
  if (isHTMLElement(listContainer)) {
    listContainer.addEventListener('click', favoriteIconEventPhaseHandler);
  }
};

여기서 먼저 restaurant-list 의 클래스를 가진 ul 태그를 선택해주고 클릭시에 favoriteIconEventPhaseHandler 함수가 발생하도록 바인딩 해준다.

- favoriteIconEventPhaseHandler (이벤트 위임 받는 함수)

const favoriteIconEventPhaseHandler = (event: Event) => {
  const target = event.target as Element;
  console.log('favoriteIconEventPhaseHandler 내에서의 event.target : ', target);
  const favoritedIcon = target.closest('.favorited-icon');

  if (!isHTMLElement(favoritedIcon)) return;
  const restaurantId = getRestaurantIdFromListItem(favoritedIcon);
  if (restaurantId !== undefined) {
    changeFavoriteState(restaurantId);
  }
};

콘솔창으로 target의 값을 확인해보면 해당 li 태그 안에 가장 큰 div 컨테이너가 나온다.

여기서 closest 메서드를 통해서, 해당 컨테이너 내부에 이미지 태그를 타겟 요소로 선택해준다.

여기서 closest이라는 메서드는, 해당 target 요소에서 선택자 값을 가진 가장 가까운 조상 요소를 찾아준다.

favoritedIcon 같은 경우엔 선택한 이미지 태그가 나오고, 아이콘을 클릭하지 않고 그냥 li태그 자체를 클릭했다면 null 값이 나온다.

- getRestaurantIdFromListItem

const getRestaurantIdFromListItem = (element: Element) => {
  const listItem = element.closest('li');
  if (isHTMLElement(listItem)) {
    const restaurantId = Number(listItem.dataset.id);
    return !Number.isNaN(restaurantId) ? restaurantId : undefined;
  }
  return undefined;
};

이 함수는 이미지 태그를 클릭했을때, 그 상위 요소인 li태그의 dataset.id 값을 가져오는 함수이다.

- changeFavoriteStateComponent

const changeFavoriteState = (restaurantId: number) => {
  RestaurantListStorageService.patchData(restaurantId);
  const allRestaurants = RestaurantListStorageService.getData();
  const targetRestaurant = allRestaurants?.find((restaurant) => restaurant.id === restaurantId);
  if (targetRestaurant) {
    const listComponent = generateRestaurantListItemComponent(targetRestaurant);
    updateRestaurantListItemUI(restaurantId, listComponent);
  }
};

이 함수는 실질적으로 즐겨찾기 버튼에 대한 컴포넌트를 변경 시켜주는 함수이다.

getRestaurantIdFromListItem에서 얻은 li태그의 아이디 값을 받아와서 실질적인 데이터 업데이트를 해주고 UI를 업데이트 해준다.

하나의 태그 안에서 이벤트가 여러개 발생하거나 중복되는 하위 요소들이 많은 경우들이 많다.

이런 경우에 이벤트 위임과 closest 메서드를 사용한다면 필요한 요소의 이벤트만 바인딩 해줄수 있어서 유용한것 같다고 생각한다.