Создавайте новый AbortController для каждого смонтированного представления JavaScript, регистрируйте его обработчики с сигналом этого контроллера и прерывайте его при размонтировании. В этом руководстве создаётся небольшое представление для браузера и показывается, как проверять повторные циклы монтирования в Chrome на Mac.
Почему при повторном открытии представления накапливаются обработчики событий?
При первом открытии представление может работать правильно, а после повторного открытия начать реагировать дважды. Частая причина — код настройки, который при каждом монтировании создаёт новый обратный вызов, не удаляя предыдущую регистрацию. Функции, созданные отдельно, являются разными объектами-обработчиками, даже если их исходный текст совпадает. В документации MDN по addEventListener() объясняется это правило идентичности обратных вызовов.
DOM предотвращает повторные регистрации с одинаковыми типом события, обратным вызовом и значением capture на одной цели. Но эта защита не помогает при повторном монтировании, когда каждая настройка создаёт новый обратный вызов. Стандарт также указывает, что прерывание переданного обработчику сигнала удаляет этот обработчик. Эти правила позволяют применить простой подход: один контроллер управляет всеми нативными обработчиками событий в течение одного периода монтирования. Стандарт DOM: EventTarget.
В этом руководстве задача читателя — открывать, закрывать и снова открывать представление счётчика на чистом JavaScript, не добавляя лишних обработчиков кнопки или окна. В примере используются условные подписи, не требуется фреймворк, а значения счётчиков сохраняются между монтированиями.
Какой контракт жизненного цикла должно соблюдать представление?
Прежде чем реализовывать настройку, определите, что означают повторные вызовы методов жизненного цикла. Следующий контракт делает случайные повторные вызовы безвредными и назначает очистке чётко определённого владельца.
| Операция | Требуемое поведение |
|---|---|
Первый mount() |
Подключить представление и зарегистрировать один набор обработчиков. |
Ещё один mount(), пока представление смонтировано |
Ничего не менять и выполнить возврат. |
unmount() |
Удалить обработчики текущего периода монтирования, затем отсоединить представление. |
Ещё один unmount() |
Ничего не менять и выполнить возврат. |
mount() после размонтирования |
Зарегистрировать новый набор с новым контроллером; сохранить счётчики. |
destroy() |
Окончательно размонтировать; последующая попытка монтирования вызывает ошибку. |
Это решения на уровне проектирования приложения. Браузер предоставляет регистрацию и удаление обработчиков событий; методы жизненного цикла и порядок их вызовов определяет само представление. В более крупном приложении компонент или маршрутизатор, вызывающий mount(), должен также обеспечить соответствующий вызов unmount().
Новый контроллер важен, потому что прерванный сигнал нельзя снова активировать. Если addEventListener() получает уже прерванный сигнал, алгоритм регистрации DOM прекращает выполнение, не добавляя обработчик. Поэтому при повторном использовании старого контроллера повторно открытое представление может перестать реагировать на действия пользователя. Стандарт DOM: добавление обработчика событий.
Создайте полный пример в Chrome на Mac
Откройте новую вкладку Chrome, введите about:blank в адресную строку и нажмите Return. Когда страница будет в фокусе, нажмите Command + Option + J, чтобы открыть Console. Google указывает это сочетание клавиш для Mac в справочнике сочетаний клавиш Chrome DevTools.
Выполните в Console приведённую ниже программу целиком. Она создаёт интерфейс с помощью JavaScript, поэтому разметка документа, установка пакетов и сборка не нужны. Консоль Chrome может выполнять JavaScript и изменять DOM текущей страницы, как показано в руководстве Google Запуск JavaScript в консоли.
(() => {
// Clean up the previous demo if this entire program is run again.
window.lifecycleDemo?.destroy();
const host = document.body;
const root = document.createElement("section");
const button = document.createElement("button");
const output = document.createElement("p");
button.type = "button";
button.textContent = "Add one";
root.append(button, output);
let controller = null;
let destroyed = false;
let clicks = 0;
let resizes = 0;
function render() {
output.textContent =
`Clicks: ${clicks}; resize events: ${resizes}`;
}
function mount() {
if (destroyed) {
throw new Error("This view has been destroyed.");
}
if (controller !== null) return;
const next = new AbortController();
const options = { signal: next.signal };
try {
button.addEventListener("click", () => {
clicks += 1;
render();
}, options);
window.addEventListener("resize", () => {
resizes += 1;
render();
}, options);
render();
host.append(root);
controller = next;
} catch (error) {
// Roll back any registrations made before setup failed.
next.abort();
root.remove();
throw error;
}
}
function unmount() {
if (controller === null) return;
controller.abort();
controller = null;
root.remove();
}
function destroy() {
unmount();
destroyed = true;
}
window.lifecycleDemo = {
mount,
unmount,
destroy,
button,
get counts() {
return { clicks, resizes };
}
};
mount();
})();
Теперь на странице есть кнопка Add one и строка со счётчиком. Нажмите кнопку: число нажатий должно увеличиться на единицу. Измените размер окна Chrome: число событий изменения размера должно измениться. Физическое изменение размера может вызвать несколько событий, поэтому это счётчик событий, а не жестов мышью.
Обработчики кнопки и окна используют один и тот же сигнал. При размонтировании контроллер прерывается до отсоединения корневого элемента. При следующем монтировании создаются другой контроллер и новый набор обратных вызовов. Значения счётчиков сохраняются в замыкании представления, поэтому при повторном открытии они остаются прежними; уничтожение и повторный запуск всей программы создают новый экземпляр.
Блок настройки также обрабатывает частичный сбой: если после регистрации обработчика возникает ошибка, блок catch прерывает новый контроллер, отсоединяет корневой элемент и повторно выбрасывает ошибку. Благодаря этому неудачное монтирование не оставляет незаметно активную часть настройки.
Как проверить очистку при повторных монтированиях?
Сначала проверьте жизненный цикл вручную. После установки основной программы выполните следующую последовательность. Представление исчезнет, повторное размонтирование не даст дополнительного эффекта, а при повторном открытии оно появится с прежними значениями счётчиков.
window.lifecycleDemo.unmount();
window.lifecycleDemo.unmount();
window.lifecycleDemo.mount();
window.lifecycleDemo.mount();
Затем выполните в той же консоли эту полную проверку. Она зависит от основной программы выше. В каждом цикле намеренно дважды вызываются настройка и очистка, пока представление смонтировано на каждой цели вызывается по одному событию, а в размонтированном состоянии вызываются те же события. При несовпадении проверка сразу выбрасывает ошибку.
(() => {
const view = window.lifecycleDemo;
if (!view) throw new Error("Run the main program first.");
function check(actual, expected, label) {
if (actual !== expected) {
throw new Error(`${label}: expected ${expected}, got ${actual}`);
}
}
for (let cycle = 0; cycle < 20; cycle += 1) {
view.mount();
view.mount();
const before = view.counts;
view.button.click();
window.dispatchEvent(new Event("resize"));
check(view.counts.clicks, before.clicks + 1, "Mounted click");
check(view.counts.resizes, before.resizes + 1, "Mounted resize");
view.unmount();
view.unmount();
const stopped = view.counts;
// The button reference remains available while detached.
view.button.click();
window.dispatchEvent(new Event("resize"));
check(view.counts.clicks, stopped.clicks, "Unmounted click");
check(view.counts.resizes, stopped.resizes, "Unmounted resize");
}
view.mount();
console.log("Passed 20 mount/unmount cycles.");
})();
Искусственно созданное событие изменения размера обеспечивает предсказуемое изменение счётчика без перемещения окна. Проверка отсоединённой кнопки полезна, потому что в этом примере кнопка намеренно сохраняется для повторного монтирования. Обе проверки должны пройти: проверка только видимой кнопки не выявит проблемы с обработчиком окна. Тест добавляет 20 к каждому счётчику и завершает работу со смонтированным представлением.
Для непосредственной проверки в консоли DevTools Chrome доступна команда getEventListeners(object). Она возвращает обработчики, зарегистрированные на указанном объекте, сгруппированные по типу события. Это утилита DevTools, поэтому не используйте её в коде приложения. Справочник API утилит консоли Chrome.
После запуска основной программы выполните эту последовательность для сравнения регистраций до и после очистки. На пустой демонстрационной странице ожидается один обработчик щелчка кнопки и один обработчик события изменения размера окна во время монтирования, а после размонтирования — ни одного.
(() => {
const view = window.lifecycleDemo;
if (!view) throw new Error("Run the main program first.");
view.mount();
console.log("Mounted", {
button: (getEventListeners(view.button).click ?? []).length,
window: (getEventListeners(window).resize ?? []).length
});
view.unmount();
console.log("Unmounted", {
button: (getEventListeners(view.button).click ?? []).length,
window: (getEventListeners(window).resize ?? []).length
});
view.mount();
})();
На существующей странице приложения другие части кода могут владеть обработчиками окна. Сравните разницу до и после очистки и проверьте сами обратные вызовы, не предполагая, что общее количество должно стать нулевым.
Когда вместо этого следует использовать явное удаление?
Используйте removeEventListener() при интеграции с существующим кодом, который уже хранит обратные вызовы, или если требуемая среда не поддерживает параметр signal для обработчика. Для очистки сохраните исходные цель, тип события, ссылку на обратный вызов и значение capture. В документации MDN указано, что значение capture должно совпадать, а такие параметры, как passive, не влияют на сопоставление при удалении. Справочник MDN по removeEventListener().
Например, если настройка использует bind(), сохраните привязанный обратный вызов один раз. Повторный вызов bind() при очистке создаёт другую ссылку на функцию. Аналогично, повторная запись той же стрелочной функции в вызове удаления не восстановит функцию, зарегистрированную ранее. Держите явную очистку рядом с настройкой, чтобы они менялись согласованно.
Подход с контроллером распространяется на нативные обработчики, зарегистрированные с его сигналом. Если представление также создаёт таймеры, наблюдатели или подписки библиотек, назначьте соответствующие документированные операции очистки той же границе размонтирования. Передача сигнала нативным обработчикам не обеспечивает очистку других ресурсов.
Что проверить, если обратные вызовы всё ещё дублируются?
Используйте симптомы, чтобы сузить поиск причины, вместо того чтобы сразу менять несколько методов жизненного цикла.
| Симптом | Что проверить дальше |
|---|---|
| Один щелчок увеличивает счётчик дважды | Проверьте наличие второго пути регистрации или второго активного экземпляра представления. |
| После повторного открытия элементы управления перестают реагировать | Проверьте, не использует ли настройка повторно прерванный сигнал. |
| Счётчики меняются после размонтирования | Проверьте каждую цель события и передачу нужного сигнала каждой регистрации. |
| Явное удаление ничего не меняет | Сравните цель, тип события, идентичность обратного вызова и значение capture. |
| Первое действие работает, а последующие — нет | Проверьте, не использует ли обработчик параметр once: true. |
Параметр once удаляет обработчик после первого вызова. Он может подойти для взаимодействия, которое действительно происходит только один раз, но не описывает время жизни смонтированного представления: событие может не произойти до размонтирования. В документации MDN описаны как once, так и удаление по сигналу в справочнике по addEventListener().
Когда закончите с демонстрацией, выполните window.lifecycleDemo.destroy(), чтобы отсоединить представление и удалить его обработчики. Для собственного представления придерживайтесь того же практического контракта: одна активная настройка, повторяемая очистка, новые регистрации при повторном открытии и проверка как видимых элементов управления, так и долгоживущих целей событий.
Источники
- Стандарт DOM — WHATWG.
- EventTarget: метод addEventListener() — MDN Web Docs.
- EventTarget: метод removeEventListener() — MDN Web Docs.
- Сочетания клавиш — Chrome for Developers, Google.
- Запуск JavaScript в консоли — Chrome for Developers, Google.
- Справочник API утилит консоли — Chrome for Developers, Google.