Отписка и однократные обработчики

события≈ 6 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Подписаться на событие — одна строка. Отписаться — тоже одна, но сработает она только если передать ту же самую функцию, что и при подписке: браузер сравнивает обработчики по ссылке, а не по тексту кода. Разберём removeEventListener, опцию once, AbortController — и то, чем оборачивается забытая отписка.

Разделы темы:·

Подписка живёт ровно до тех пор, пока её не сняли. Элемент можно спрятать, очистить, перерисовать — обработчик всё равно останется висеть на своей цели. Снимает его removeEventListener, и аргументы у него те же, что у addEventListener: тип события, функция, необязательные опции.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ключевые слова здесь — та же функция. Браузер хранит подписки списком и сличает обработчики по ссылке. Две одинаковые с виду стрелки — два разных объекта в памяти, и снять первую второй не выйдет. Ошибки при этом не будет: removeEventListener молча не найдёт совпадения и ничего не сделает.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Дубликат браузер отбрасывает: одна и та же функция на одной цели, с одним типом и одной фазой попадает в список ровно один раз. Значит, и снимать её нужно один раз.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

Часть подпискиУчитывается при снятии
Элемент, на котором висит подпискаДа — снимать нужно с него же
Тип события ("click")Да
Ссылка на функциюДа, сравнение по ссылке
capture (третий аргумент)Да
once, passive, signalНет, при сравнении игнорируются
js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Третьим аргументом можно передать и объект — { capture: true } равнозначно true. Остальные опции при снятии не смотрят: подписку с { once: true, passive: true } снимет обычный вызов без опций вовсе.

Осторожнее с bind: this.onClick.bind(this) каждый раз возвращает новую функцию. Подписались одним результатом, отписываетесь другим — обработчик остаётся жить. Сохраните результат bind один раз в поле объекта и снимайте им же.

Часто обработчик нужен ровно на один раз: показать подсказку при первом клике, догрузить данные при первом открытии, проиграть анимацию появления. Городить ради этого ручное снятие незачем — есть опция once. Браузер снимает такую подписку сам, прямо перед тем как позвать вашу функцию, так что повторный вызов невозможен даже если событий прилетит пачка.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

once работает с любым типом событий и спокойно сочетается с другими опциями. И это не «особая» подписка: пока она не сработала, её можно снять обычным removeEventListener — если, конечно, вы сохранили ссылку на функцию.

Приём всё ещё полезен, когда снять подписку нужно не на первом событии, а по условию: функциональному выражению дают имя, и внутри оно снимает само себя через event.currentTarget.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Когда подписок несколько и живут они одним куском — экран, модальное окно, компонент — снимать их по одной утомительно, а забыть одну легко. AbortController превращает пачку подписок в одну ручку: передайте его signal в каждую, а потом позовите abort(). Цели и типы событий могут быть какими угодно.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

abort() — билет в один конец: прерванный сигнал остаётся прерванным навсегда, и новая подписка с ним уже не встанет (addEventListener с таким сигналом просто ничего не делает). На следующий цикл жизни заводят новый контроллер. Тот же signal понимает и fetch, так что один abort() способен погасить сразу и обработчики, и висящий запрос — приём, ради которого контроллер и стоит держать под рукой.

Обработчик — это ссылка. Пока подписка жива, жива и функция, а с ней всё, что она замкнула: узлы DOM, загруженные данные, целый компонент. Если подписка стоит на самом элементе и элемент выбросили — сборщик мусора уберёт их вместе, беды нет. Опасны подписки на долгожителей: document, window, медиа-запросы, сокеты. Они переживают ваш экран и утаскивают его за собой в память.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Кнопки в документе больше нет, а обработчик по-прежнему отзывается на клики и держит heavy. Хуже того, снять его нечем: функцию передали анонимно, ссылки не сохранили. Правило простое — у каждой подписки должен быть момент, когда её снимают, и придумывается он сразу, а не когда-нибудь потом.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Привяжите контроллер к жизни экрана: создали при появлении, abort() при уходе. Дальше это ложится на любую архитектуру: в коде без каркаса — метод вроде destroy(), а в React — уборка эффекта, которую разбирает статья «Уборка, двойной запуск и гонки запросов». Главное, что отписка перестаёт быть отдельным делом, о котором можно забыть.

  • removeEventListener снимает подписку только при полном совпадении: та же цель, тот же тип события, та же функция по ссылке, та же фаза. Промах не вызывает ошибки — обработчик просто остаётся.
  • Стрелка, записанная прямо в вызове addEventListener, снятию не поддаётся: сохраняйте функцию в переменную, если планируете отписку.
  • Опция { once: true } снимает обработчик автоматически, ровно перед первым вызовом; до срабатывания его всё ещё можно снять вручную.
  • AbortController собирает любое число подписок под один signal и снимает их одним abort(); контроллер одноразовый, на новый цикл жизни нужен новый.
  • Забытая подписка на document, window или сокет держит в памяти всё, что замкнула, вместе с уже удалёнными узлами — момент отписки продумывают одновременно с подпиской.

1/3Подписались стрелкой: btn.addEventListener("click", () => log()). Следующей строкой вызвали btn.removeEventListener("click", () => log()). Почему обработчик остался?

2/3Обработчик поставили так: box.addEventListener("click", onClick, true). Какой вызов его снимет?

3/3Что происходит с подписками, сделанными с { signal: controller.signal }, после вызова controller.abort()?

Кнопка, которая срабатывает один раз

Кнопка «Отправить» должна сработать ровно один раз, даже если по ней кликнули трижды — двойной клик не должен слать форму дважды. Сейчас счётчик растёт с каждым кликом. Исправьте подписку так, чтобы вывелась 1.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Короткий путь — опция once: браузер снимет обработчик сам, перед первым же вызовом.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Если снять подписку нужно не всегда, а по условию, сохраните ссылку на функцию и снимайте вручную — эффект тот же:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Чего делать не стоит: снимать подписку новой стрелкой (send.removeEventListener("click", () => { sent += 1; })) — это другая функция, совпадения не найдётся.

Снять три подписки одним движением

Экран подписался на три разных события у трёх разных целей. Сделайте так, чтобы все три снимались одной строкой на месте комментария, и после неё ни один из вызовов ниже не сработал. Одним добавлением не обойтись: у анонимных стрелок нет ссылки, по которой их сняли бы, — придётся поправить и сами подписки.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Один AbortController на все подписки: его signal передаётся в каждый addEventListener, а abort() гасит их разом — цели и типы событий значения не имеют.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Обратите внимание: обработчики так и остались анонимными стрелками, и это теперь не мешает — снимать их поимённо не нужно. Если экран потом откроется снова, заведите новый контроллер: прерванный сигнал не оживает.