Чтобы обработчики щелчков продолжали работать после замены DOM-содержимого представления, добавьте один обработчик к родительскому элементу, который сохраняется после обновления. В этом руководстве показано, как реализовать и проверить этот подход в Safari на Mac с помощью небольшого демонстрационного средства выбора плиток.
Задача конкретна: заново создать содержимое представления JavaScript, сохранив отзывчивость кнопок и выбранный элемент. Вам понадобятся базовые знания JavaScript и актуальный браузер Safari. В примере используются стандартные API браузера, установка фреймворка или пакетов не требуется.
Почему щелчки перестают работать после замены DOM?
Обработчик, зарегистрированный с помощью addEventListener(), принадлежит целевому элементу события, на котором он был зарегистрирован. Присваивание значения свойству innerHTML контейнера разбирает переданную разметку и заменяет его дочерние элементы. Поэтому новая кнопка не наследует обработчик, добавленный к предыдущей кнопке, даже если их идентификаторы и подписи совпадают. См. справочник MDN по innerHTML и модель обработчиков событий в стандарте DOM.
Важно различать замену содержимого контейнера и замену самого контейнера. Если контейнер сохраняется, а код заново создаёт только его дочерние элементы, обработчик на этом контейнере может остаться на месте. Если внешний процесс отрисовки заменяет и контейнер, выберите сохраняющегося предка или добавьте обработчик при инициализации нового контейнера.
Для обычных всплывающих событий щелчка делегирование событий позволяет родителю обрабатывать взаимодействия, исходящие от его потомков. Вместо привязки обработчика к каждой новой кнопке привяжите его к стабильному родителю и определяйте нужную кнопку при получении события. Этот подход описан в руководстве MDN по всплытию событий.
Где разместить обработчик?
Сначала определите точную границу DOM, которую заменяет операция отрисовки. Затем выберите подходящего родителя, находящегося за пределами этой границы, и как можно ближе к ней. В следующей таблице решения описанное поведение при замене и работе обработчиков применено к распространённым способам обновления. MDN: replaceChildren(), стандарт DOM: EventTarget.
| Ваше обновление | Размещение обработчика | Требование к жизненному циклу |
|---|---|---|
| Заново создать дочерние элементы существующего корневого элемента представления | Делегировать щелчки этому корневому элементу | Зарегистрировать обработчик один раз при создании корневого элемента |
| Заменить также и корневой элемент представления | Делегировать события от сохраняющегося внешнего контейнера или привязать обработчик к новому корневому элементу | Удалить регистрацию предыдущего представления |
| Создать кнопки, которым нужны отдельные обработчики | Привязать обработчик к каждой новой кнопке при создании | Завершить привязку до того, как новые элементы управления станут доступны |
| Перезагрузить весь документ | Инициализировать обработчики в новом документе | Снова выполнить обычную инициализацию страницы |
В примере ниже структура остаётся неизменной: корневой элемент представления содержит заголовок, статус, упорядоченный набор вариантов плиток и кнопку обновления. При каждой отрисовке эти дочерние элементы создаются заново в том же порядке. Сохраняются только корневой элемент представления, обработчик и состояние JavaScript.
Создайте в Safari полностью устойчивое к замене представление
Сначала подготовьте отдельную вкладку браузера. В Safari на Mac откройте Settings…, выберите Advanced и включите Show features for web developers. Откройте веб-инспектор с помощью Develop → Show Web Inspector или нажмите Option-Command-I. Эти шаги описаны в руководстве WebKit по включению веб-инспектора.
Перейдите на about:blank, откройте веб-инспектор и выберите Console. Вставьте всю программу ниже в поле ввода и выполните её. В документации WebKit указано, что поле ввода консоли выполняет JavaScript в выбранном контексте выполнения проверяемой страницы. Используйте пустую вкладку, чтобы демонстрация оставалась отдельно от вашего приложения.
Это полноценная программа для браузера: она создаёт собственный DOM, задаёт демонстрационные данные, монтирует представление и предоставляет метод очистки. Для замены дочерних элементов представления она использует replaceChildren(), который принимает узлы и заменяет существующий набор дочерних элементов. Эта операция описана в справочнике MDN по методу.
(() => {
window.domClickDemo?.destroy();
const root = document.createElement("section");
root.id = "dom-click-demo";
const controller = new AbortController();
const items = [
{ id: "a", label: "Tile A" },
{ id: "b", label: "Tile B" },
{ id: "c", label: "Tile C" }
];
const state = { selected: "None", refreshes: 0 };
function makeButton(action, label, id) {
const button = document.createElement("button");
button.type = "button";
button.dataset.action = action;
if (id !== undefined) button.dataset.id = id;
const caption = document.createElement("span");
caption.textContent = label;
button.append(caption);
return button;
}
function render() {
const heading = document.createElement("h2");
heading.textContent = "Tile picker";
const status = document.createElement("p");
status.textContent =
`Selected: ${state.selected}; refreshes: ${state.refreshes}`;
const list = document.createElement("ul");
for (const item of items) {
const row = document.createElement("li");
row.append(makeButton("select", item.label, item.id));
list.append(row);
}
const refresh = makeButton("refresh", "Replace view content");
root.replaceChildren(heading, status, list, refresh);
}
function handleClick(event) {
if (!(event.target instanceof Element)) return;
const button = event.target.closest("button[data-action]");
if (!button || !root.contains(button) || button.disabled) return;
if (button.dataset.action === "select") {
const item = items.find(item => item.id === button.dataset.id);
if (!item) return;
state.selected = item.label;
} else if (button.dataset.action === "refresh") {
state.refreshes += 1;
} else {
return;
}
render();
}
root.addEventListener("click", handleClick, {
signal: controller.signal
});
render();
document.body.append(root);
window.domClickDemo = {
root,
destroy() {
controller.abort();
root.remove();
}
};
})();
Обработчик регистрируется вне render(). При каждом выборе и обновлении создаются новые кнопки, но все они остаются внутри исходного корневого элемента. Объект состояния тоже находится за пределами отрисовываемой ветви DOM, поэтому замена абзаца статуса не сбрасывает выбранную плитку или счётчик обновлений.
Вложенная подпись добавлена намеренно. Щелчок по тексту кнопки может быть нацелен на её дочерний элемент. Метод closest("button[data-action]") ищет соответствующий элемент управления среди целевого элемента и его предков, поэтому обработчик работает не только тогда, когда целевым элементом события является сама кнопка. Поиск и результат null при отсутствии совпадений описаны в справочнике MDN по closest().
Проверка на принадлежность гарантирует, что найденный элемент управления находится внутри границы этого представления. В стандарте DOM свойство contains() определяется тем, является ли переданный узел самим узлом или одним из его потомков. Стандарт DOM: Node.contains().
Разделяйте инициализацию, отрисовку и очистку
В этом примере явно выделены три задачи. Инициализация создаёт корневой элемент и регистрирует его обработчик. Отрисовка обновляет дочерние элементы корневого элемента на основе состояния. Очистка удаляет обработчик и корневой элемент. Это контракт жизненного цикла данной реализации; при адаптации к приложению сохраните эквивалентные задачи.
Обработчик получает AbortSignal, а destroy() прерывает работу контроллера. Прерывание удаляет связанную регистрацию, как указано в справочнике MDN по addEventListener(). При повторном выполнении всей программы сначала удаляется предыдущая демонстрация, поэтому несколько её представлений не накапливаются.
Не создавайте новую анонимную функцию-обработчик при каждой отрисовке. Каждое повторное вычисление анонимного функционального выражения создаёт новый объект функции, поэтому регистрации могут накапливаться на сохраняющемся корневом элементе. Повторное использование того же обработчика, типа события и параметра capture предотвращает дублирование регистрации, однако привязка при инициализации делает владение обработчиком более понятным. MDN: addEventListener(), стандарт DOM: добавление обработчиков.
Проверьте повторные замены, выбор и очистку
Проверьте пример в следующей последовательности. Ожидаемые результаты следуют из явных обновлений состояния в программе; это не утверждение о том, что проверка в Safari уже выполнена.
- Щёлкните
Tile B. В статусе должно появитьсяSelected: Tile B; refreshes: 0. - Трижды щёлкните
Replace view content. В статусе должно появитьсяSelected: Tile B; refreshes: 3. - Щёлкните по тексту
Tile C. Статус должен измениться наSelected: Tile C; refreshes: 3. - Щёлкните заголовок или абзац статуса. Ни одно значение не должно измениться.
- Выполните в консоли
window.domClickDemo.destroy(). Демонстрация должна исчезнуть. - Выполните программу целиком ещё раз. Должно появиться одно новое представление со значениями
Selected: None; refreshes: 0.
Первые три проверки охватывают замену, сохранение состояния и вложенные цели щелчка. Четвёртая проверяет фильтрацию действий. Последние две проверяют путь очистки и инициализации. Вместе они проверяют весь жизненный цикл, а не только работу первоначальных кнопок.
Синтаксис JavaScript в программе проверен с помощью Node, а отдельная детерминированная проверка EventTarget подтвердила устранение дубликатов обработчиков и их удаление с помощью сигнала прерывания. В этой проверке не выполнялись отрисовка DOM в браузере и взаимодействие с Safari; воспользуйтесь приведённой выше последовательностью, чтобы проверить их на своём Mac.
Что делать, если обработчик по-прежнему не работает в приложении?
Найдите первый этап, на котором поведение отличается от ожидаемого. При проверке границы замены, пути события и обновлений состояния в приложении сохраняйте пример компактным.
| Симптом | Что проверить в первую очередь | Подходящее исправление |
|---|---|---|
| Щелчки работают до обновления внешнего представления | Был ли заменён и корневой элемент обработчика? | Привязать обработчик к новому корневому элементу при инициализации или выбрать сохраняющегося родителя |
| Щелчок срабатывает, но действие не выполняется | Соответствует ли новый элемент управления селектору и значению действия? | Сохранить контракт селектора и действия элемента управления |
| Текст кнопки ведёт себя иначе при щелчке | Проверяет ли обработчик только точный целевой элемент? | Найти элемент управления с помощью closest() |
| Один щелчок обновляет состояние несколько раз | Не активны ли несколько регистраций или одновременно прямые и делегированные обработчики? | Назначить один путь инициализации ответственным за действие |
| Обработчик срабатывает, но отображаемое состояние сбрасывается | Создаётся ли состояние заново внутри отрисовки? | Хранить нужное состояние за пределами заменяемой ветви DOM |
Также проверьте распространение события. Обработчик потомка, вызывающий stopPropagation(), может помешать всплывающему событию достичь вашего делегированного обработчика. MDN объясняет остановку распространения и делегирование событий. Прежде чем менять фазы обработчика, выясните, почему распространение останавливается: смена фазы также меняет порядок выполнения.
Если необходимо использовать прямые обработчики кнопок, добавляйте их при создании новых кнопок. Однократная выборка элементов и привязка обработчиков не может инициализировать узлы, созданные при последующих отрисовках: регистрации принадлежат целевым элементам событий. Аналогично делегирование не сохранит обработчик на корневом элементе, который отрисовщик удаляет. Эти границы следуют из модели обработчиков событий в стандарте DOM.
Для этой задачи надёжная схема включает стабильный корневой элемент представления, один делегированный обработчик, состояние за пределами отрисовываемых дочерних элементов и явный путь очистки. Определите, какие узлы сохраняются при каждом обновлении, разместите обработчик соответственно и проверьте несколько последовательных замен, прежде чем вносить изменение в более крупное представление.
Источники
- Включение веб-инспектора — WebKit.
- Интерфейс веб-инспектора — WebKit.
- Элемент: свойство innerHTML — MDN Web Docs.
- Всплытие событий — MDN Web Docs.
- Элемент: метод replaceChildren() — MDN Web Docs.
- Элемент: метод closest() — MDN Web Docs.
- EventTarget: метод addEventListener() — MDN Web Docs.
- Стандарт DOM — WHATWG.