Отписка и однократные обработчики
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Подписаться на событие — одна строка. Отписаться — тоже одна, но сработает она только если передать ту же самую функцию, что и при подписке: браузер сравнивает обработчики по ссылке, а не по тексту кода. Разберём removeEventListener, опцию once, AbortController — и то, чем оборачивается забытая отписка.
Подписка живёт ровно до тех пор, пока её не сняли. Элемент можно спрятать, очистить, перерисовать — обработчик всё равно останется висеть на своей цели. Снимает его removeEventListener, и аргументы у него те же, что у addEventListener: тип события, функция, необязательные опции.
Ключевые слова здесь — та же функция. Браузер хранит подписки списком и сличает обработчики по ссылке. Две одинаковые с виду стрелки — два разных объекта в памяти, и снять первую второй не выйдет. Ошибки при этом не будет: removeEventListener молча не найдёт совпадения и ничего не сделает.
Дубликат браузер отбрасывает: одна и та же функция на одной цели, с одним типом и одной фазой попадает в список ровно один раз. Значит, и снимать её нужно один раз.
Совпасть должна не только функция. Подписка опознаётся по четвёрке: цель, тип события, обработчик и фаза. Промах в любой части — и вызов уходит впустую.
| Часть подписки | Учитывается при снятии |
|---|---|
| Элемент, на котором висит подписка | Да — снимать нужно с него же |
| Тип события ("click") | Да |
| Ссылка на функцию | Да, сравнение по ссылке |
| capture (третий аргумент) | Да |
| once, passive, signal | Нет, при сравнении игнорируются |
Третьим аргументом можно передать и объект — { capture: true } равнозначно true. Остальные опции при снятии не смотрят: подписку с { once: true, passive: true } снимет обычный вызов без опций вовсе.
Осторожнее с bind: this.onClick.bind(this) каждый раз возвращает новую функцию. Подписались одним результатом, отписываетесь другим — обработчик остаётся жить. Сохраните результат bind один раз в поле объекта и снимайте им же.
Часто обработчик нужен ровно на один раз: показать подсказку при первом клике, догрузить данные при первом открытии, проиграть анимацию появления. Городить ради этого ручное снятие незачем — есть опция once. Браузер снимает такую подписку сам, прямо перед тем как позвать вашу функцию, так что повторный вызов невозможен даже если событий прилетит пачка.
once работает с любым типом событий и спокойно сочетается с другими опциями. И это не «особая» подписка: пока она не сработала, её можно снять обычным removeEventListener — если, конечно, вы сохранили ссылку на функцию.
Приём всё ещё полезен, когда снять подписку нужно не на первом событии, а по условию: функциональному выражению дают имя, и внутри оно снимает само себя через event.currentTarget.
Когда подписок несколько и живут они одним куском — экран, модальное окно, компонент — снимать их по одной утомительно, а забыть одну легко. AbortController превращает пачку подписок в одну ручку: передайте его signal в каждую, а потом позовите abort(). Цели и типы событий могут быть какими угодно.
abort() — билет в один конец: прерванный сигнал остаётся прерванным навсегда, и новая подписка с ним уже не встанет (addEventListener с таким сигналом просто ничего не делает). На следующий цикл жизни заводят новый контроллер. Тот же signal понимает и fetch, так что один abort() способен погасить сразу и обработчики, и висящий запрос — приём, ради которого контроллер и стоит держать под рукой.
Обработчик — это ссылка. Пока подписка жива, жива и функция, а с ней всё, что она замкнула: узлы DOM, загруженные данные, целый компонент. Если подписка стоит на самом элементе и элемент выбросили — сборщик мусора уберёт их вместе, беды нет. Опасны подписки на долгожителей: document, window, медиа-запросы, сокеты. Они переживают ваш экран и утаскивают его за собой в память.
Кнопки в документе больше нет, а обработчик по-прежнему отзывается на клики и держит heavy. Хуже того, снять его нечем: функцию передали анонимно, ссылки не сохранили. Правило простое — у каждой подписки должен быть момент, когда её снимают, и придумывается он сразу, а не когда-нибудь потом.
Привяжите контроллер к жизни экрана: создали при появлении, 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.
Короткий путь — опция once: браузер снимет обработчик сам, перед первым же вызовом.
Если снять подписку нужно не всегда, а по условию, сохраните ссылку на функцию и снимайте вручную — эффект тот же:
Чего делать не стоит: снимать подписку новой стрелкой (send.removeEventListener("click", () => { sent += 1; })) — это другая функция, совпадения не найдётся.
Снять три подписки одним движением
Экран подписался на три разных события у трёх разных целей. Сделайте так, чтобы все три снимались одной строкой на месте комментария, и после неё ни один из вызовов ниже не сработал. Одним добавлением не обойтись: у анонимных стрелок нет ссылки, по которой их сняли бы, — придётся поправить и сами подписки.
Один AbortController на все подписки: его signal передаётся в каждый addEventListener, а abort() гасит их разом — цели и типы событий значения не имеют.
Обратите внимание: обработчики так и остались анонимными стрелками, и это теперь не мешает — снимать их поимённо не нужно. Если экран потом откроется снова, заведите новый контроллер: прерванный сигнал не оживает.