Как правильно удалять EventListener?
Glossary overview

Как правильно удалять EventListener?

1. Простой случай: удаление обработчика напрямую

  • Когда использовать:
    Если обработчик — это заранее объявленная функция с фиксированной ссылкой, и вы можете её легко переиспользовать при удалении.
  • Как работает:
    При добавлении слушателя через addEventListener вы передаёте функцию. Чтобы удалить этот слушатель, нужно передать точно ту же функцию в removeEventListener.
// Объявляем функцию-обработчик
function clickHandler(event) {
  console.log("Элемент был кликнут");
}

// Добавляем обработчик
element.addEventListener("click", clickHandler);

// Позже удаляем обработчик
element.removeEventListener("click", clickHandler);

2. Случай с использованием Map: динамические обработчики для нескольких элементов

  • Когда использовать:
    Если вы добавляете обработчики для множества элементов, и для каждого элемента создаётся уникальная функция (например, внутри цикла).
    При этом ссылка на функцию создаётся “на лету” (например, через анонимную функцию) и без хранения вы не сможете потом удалить слушатель, так как ссылку на неё потеряете.
  • Как работает:
    Использование структуры Map позволяет сохранить ассоциацию между элементом и его обработчиком. Это особенно полезно, когда:
    • Обработчики создаются динамически.
    • Понадобится изменить или удалить слушатель при изменении условий (например, при изменении ширины экрана).
// Получаем коллекцию элементов
const elements = document.querySelectorAll(".item");
// Создаём Map для хранения обработчиков
const handlersMap = new Map();

// Добавляем обработчик для каждого элемента и сохраняем ссылку в Map
elements.forEach(item => {
  const handler = () => {
    console.log("Элемент был кликнут");
  };
  // Сохраняем ссылку на обработчик, связав её с элементом
  handlersMap.set(item, handler);
  item.addEventListener("click", handler);
});

// Позже удаляем обработчики, используя сохранённые ссылки
elements.forEach(item => {
  const handler = handlersMap.get(item);
  item.removeEventListener("click", handler);
});

Мой пример

1. Использование Map для хранения обработчиков подменю

  • Почему Map:
    При динамическом создании обработчиков для каждого элемента меню возникает проблема: если функция создаётся «на лету» (например, анонимная функция внутри цикла), её нельзя будет удалить через removeEventListener, так как ссылка на неё будет потеряна. Чтобы избежать этого, вы сохраняете созданные обработчики в структуре Map. Ключом является сам элемент меню, а значением – объект с тремя функциями:
const submenuHandlers = new Map();

Создание и сохранение обработчиков:
Внутри функции updateSubmenuEvents для каждого элемента меню проверяется, есть ли уже сохранённый набор обработчиков. Если нет, создаются:

if (!submenuHandlers.has($menuItem)) {
  const openHandler = () => handleSubmenuOpen($menuItem);
  const closeHandler = () => handleSubmenuClose($menuItem);
  const clickHandler = (e) => {
    const $link = $menuItem.querySelector('a');
    const $subMenu = $menuItem.querySelector('.sub-menu');
    if (!$link || !$subMenu) return;
    if (!$subMenu.classList.contains('open')) {
      e.preventDefault();
      $subMenu.classList.add('open');
    }
    // иначе — переход разрешён
  };
  submenuHandlers.set($menuItem, {
    openHandler,
    closeHandler,
    clickHandler,
  });
}

Таким образом, для каждого элемента меню создаются и сохраняются ссылки на функции-обработчики.

2. Функция updateSubmenuEvents: удаление и повторное назначение обработчиков

  • Удаление обработчиков:
    Перед повторным назначением обработчиков для каждого элемента меню выполняется удаление уже привязанных обработчиков:
$menuItem.removeEventListener('mouseover', openHandler);
$menuItem.removeEventListener('mouseout', closeHandler);
$menuItem.removeEventListener('click', clickHandler);

Это гарантирует, что если ранее слушатели уже были добавлены, они не будут оставаться “подвешенными” и не вызовут нежелательных эффектов (например, дублирование вызовов).

Назначение новых обработчиков в зависимости от ширины экрана:
После удаления обработчиков, на основании текущей ширины экрана, добавляются нужные события:

  • Для экранов шире 1023.5px:
    Привязываются обработчики mouseover и mouseout для открытия и закрытия подменю.
  • Для узких экранов:
    Привязывается обработчик click, который предотвращает переход по ссылке и открывает подменю, если оно ещё не открыто.
if (screenWidth > 1023.5) {
  $menuItem.addEventListener('mouseover', openHandler);
  $menuItem.addEventListener('mouseout', closeHandler);
} else {
  $menuItem.addEventListener('click', clickHandler);
}

Весь код:

const header = () => {
	const SELECTORS = {
		header: '.js-header',
		menuTrigger: '.js-header-menu-trigger',
		headerNav: '.js-header-nav',
		headerNavItems: '.js-header-nav .header-menu > .menu-item'
	};

	const CLASSNAMES = {
		bodyOpenMenuState: 'body--open_menu_state',
		headerScrollState: 'header--scroll_state',
		headerNavState: 'header__nav--active'
	};

	const $body = document.body;
	const $header = document.querySelector(SELECTORS.header);
	const $menuTriggers = document.querySelectorAll(SELECTORS.menuTrigger);
	const $headerNav = document.querySelector(SELECTORS.headerNav);
	const $headerNavItems = document.querySelectorAll(SELECTORS.headerNavItems);

	let isMenuOpen = false;

	if (!$header) return;

	const handleSubmenuOpen = ($menuItem) => {
		const $subMenu = $menuItem.querySelector('.sub-menu');
		if ($subMenu) {
			$subMenu.classList.add('open');
		}
	};

	const handleSubmenuClose = ($menuItem) => {
		const $subMenu = $menuItem.querySelector('.sub-menu');
		if ($subMenu) {
			$subMenu.classList.remove('open');
		}
	};

	const headerScroll = () => {
		const scrollY = window.scrollY;
		if (scrollY > 10) {
			$header.classList.add(CLASSNAMES.headerScrollState);
		} else {
			$header.classList.remove(CLASSNAMES.headerScrollState);
		}
	};

	const handleTriggerClick = () => {
		$body.classList.toggle(CLASSNAMES.bodyOpenMenuState);
		$headerNav?.classList.toggle(CLASSNAMES.headerNavState);
		isMenuOpen = !isMenuOpen;
	};

	// храним ссылки на обработчики, чтобы их можно было удалять
	const submenuHandlers = new Map();

	const updateSubmenuEvents = () => {
		const screenWidth = window.innerWidth;

		$headerNavItems.forEach(($menuItem) => {
			if (!submenuHandlers.has($menuItem)) {
				const openHandler = () => handleSubmenuOpen($menuItem);
				const closeHandler = () => handleSubmenuClose($menuItem);

				const clickHandler = (e) => {
					const $link = $menuItem.querySelector('a');
					const $subMenu = $menuItem.querySelector('.sub-menu');
					if (!$link || !$subMenu) return;

					if (!$subMenu.classList.contains('open')) {
						e.preventDefault();
						$subMenu.classList.add('open');
					}
					// иначе — переход разрешён
				};

				submenuHandlers.set($menuItem, {
					openHandler,
					closeHandler,
					clickHandler,
				});
			}

			const { openHandler, closeHandler, clickHandler } = submenuHandlers.get($menuItem);

			$menuItem.removeEventListener('mouseover', openHandler);
			$menuItem.removeEventListener('mouseout', closeHandler);
			$menuItem.removeEventListener('click', clickHandler);

			if (screenWidth > 1023.5) {
				$menuItem.addEventListener('mouseover', openHandler);
				$menuItem.addEventListener('mouseout', closeHandler);
			} else {
				$menuItem.addEventListener('click', clickHandler);
			}
		});
	};

	const initializeEventListeners = () => {
		$menuTriggers.forEach(($trigger) => {
			$trigger.addEventListener('click', handleTriggerClick);
		});
		updateSubmenuEvents();
	};

	window.addEventListener('scroll', headerScroll);
	window.addEventListener('resize', updateSubmenuEvents);

	initializeEventListeners();
};

export default header;

Итог

  • Простой случай:
    Если функция-обработчик объявлена заранее и не создаётся динамически, достаточно сохранить её в переменной и использовать эту переменную как для добавления, так и для удаления слушателя.
  • Случай с Map:
    Когда обработчик создаётся динамически для каждого элемента (или при изменении условий) и его нельзя просто переиспользовать, необходимо сохранить ссылку на него (например, в Map). Это позволяет корректно удалять слушатели, используя именно ту функцию, которая была установлена ранее.