Как добавить слушателей событий в набор повторяющихся элементов DOM

Распространенная задача, с которой я столкнулся в своем молодом путешествии по стране JavaScript, - это добавление слушателей событий к повторяющимся элементам DOM. Например, если у нас есть список с кучей ‹li› в DOM, мы можем захотеть, чтобы что-то произошло с одним из ‹li›, когда мы щелкнем по нему. Может быть, мы хотим изменить этот цвет ‹li›, или оживить его, или даже удалить его из DOM. Нам понадобится способ прослушивать событие на каждом ‹li›, а затем иметь возможность выполнить соответствующее действие в зависимости от того, на каком ‹li› был нажат. По моему опыту, есть два хороших способа сделать это: использовать метод, называемый делегированием, и воспользоваться закрытием. В этом посте я дам краткий обзор каждого метода, объясню различия и, наконец, раскрою код.

Что такое делегирование?

Когда мы используем делегирование, мы делегируем задачу прослушивания событий единственному родительскому элементу, который содержит все другие элементы, для которых мы хотим обрабатывать события. Если у нас есть ‹ul›, который содержит много ‹li› s, и мы хотим прослушивать событие на каждом из ‹li›, мы можем поместить одного слушателя событий на весь ‹ul›, таким образом делегируя задачу прослушивание событий от каждого ‹li› к ‹ul› в целом. Затем, когда пользователь нажимает ‹ul›, мы сначала проверяем, щелкнули ли они по ‹li› (в отличие от какого-либо другого элемента / пространства в ‹ul›) перед выполнением каких-либо задач. Здесь действительно важно то, что мы находим самого маленького стабильного родителя среди всех наших ‹li› s. Стабильные средства на исходном HTML. Если наш ‹ul› размещен в HTML, мы можем быть более уверены в том, что он, как и прослушиватель событий, всегда будет там, в то время как ‹li› динамичны и могут приходить и уходить.
(Для более подробного объяснения делегирования событий ознакомьтесь с ЭТОЙ СТАТЬЕЙ.)

Что такое закрытие?

Замыкания - фундаментально важная часть JavaScript и многих других языков программирования. Не вдаваясь в подробности, замыкания управляют тем, какие переменные доступны для конкретной функции. Помните, что в прослушивателях событий мы передаем функцию обратного вызова в качестве аргумента. Что ж, в зависимости от того, где мы определяем прослушиватель событий, его функция обратного вызова будет иметь доступ к различным переменным. Если у нас есть блок кода, который принимает данные серверной части и создает новый ‹li› для каждого фрагмента данных, мы также можем прикрепить прослушиватель событий к каждому ‹li› в том же блоке кода. Если мы это сделаем, функция обратного вызова каждого прослушивателя событий будет иметь доступ к конкретным данным, которые мы использовали для создания ‹li›. Возможно, сейчас это непонятно, но позже вы увидите на примере кода, почему это полезно.
(Замыкание - очень важная концепция за пределами прослушивателей событий и распространена во многих языках программирования. Для получения дополнительной информации о закрытии в JavaScript читайте ЭТА СТАТЬЯ.)

Какая разница?

Есть несколько ключевых различий между делегированием и использованием замыканий - некоторые очевидны, некоторые менее очевидны:

  1. Делегирование создает только один прослушиватель событий, в то время как использование закрытий приводит к одному прослушивателю событий для каждого элемента (например, для каждого ‹li›).
  2. Поскольку при делегировании используется только один прослушиватель событий, он использует мало памяти. При использовании замыканий каждый новый прослушиватель событий (и любые функции обратного вызова, определенные в прослушивателе событий) имеет свой собственный участок памяти, поэтому используется гораздо больше места в памяти.
  3. При делегировании прослушиватель событий прикрепляется к стабильному элементу HTML. При использовании замыканий слушатели событий обычно прикрепляются к динамическим элементам DOM, что делает их менее стабильными.
  4. При делегировании нам нужно настроить наш код таким образом, чтобы мы могли отслеживать информацию, к которой функция обратного вызова прослушивателя событий в противном случае не имела бы доступа. Слушатели событий, использующие замыкания, более целенаправленны, и нам не нужно беспокоиться о нашей информации отслеживания кода, потому что, если мы сделаем это правильно, обратный вызов прослушивателя событий уже будет иметь необходимую информацию.

Проблема с памятью - это то, что может стать очень актуальным в зависимости от масштаба приложения, но для большинства простых приложений это не большая проблема. Последний пункт - причина, по которой я обычно склоняюсь к использованию замыканий, когда это возможно. Приведенный ниже пример кодирования хорошо это иллюстрирует.

Покажи мне код

Давайте воспользуемся обоими этими методами для решения одной и той же задачи. Эта задача будет немного более сложной версией приведенных выше примеров ‹ul› / ‹li›, но она лучше объяснит различия между двумя методами. Он также покажет более реалистичную функциональность, которую мы, возможно, захотим встроить во внешнее приложение.

Задача заключается в следующем: у нас есть приложение для создания упаковочного списка, которое позволяет пользователям отслеживать предметы, которые им нужно упаковать в поездку. При загрузке страницы пользователь должен увидеть каждый элемент, отображаемый на странице, в новом ‹li›. Каждый ‹li› должен иметь кнопку удаления ‹Button›, чтобы, когда пользователь упаковывает элемент, он может удалить элемент, нажав кнопку удаления.

Для этой задачи предположим, что у нас есть следующий элемент ‹ul› в нашем HTML:

<ul id="items-list">
    // <li>s will go here
</ul>

Хорошо, приступим!

Использование делегирования

Когда мы создаем все наши новые элементы и добавляем все в DOM, наша DOM должна иметь такую ​​базовую структуру:

<ul id="items-list">
    <li>Item 1<button>x</button></li>
    <li>Item 2<button>x</button></li>
    <li>Item 3<button>x</button></li>
    ...
</ul>

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

const itemsUl = document.querySelector("#items-list");
fetch("http://localhost:3000/items")
.then(res => res.json())
.then(itemsArr => {
    itemsArr.forEach(item => {
        let itemLi = document.createElement("li");
        itemLi.innerText = item.name;
        itemsUl.append(itemLi);
        
        let deleteButton = document.createElement("button");
        deleteButton.innerText = "x";
        itemLi.append(deleteButton);
    });
});

Теперь мы хотим отслеживать клики по удалению ‹button› в пределах ‹li›. Итак, мы находим самый маленький стабильный родительский элемент для всех delete ‹buttons› (‹ul› из исходного HTML-кода) и даем ему прослушиватель событий:

itemsUl.addEventListener("click", evt => {
});

Затем мы проверяем, была ли нажата «кнопка»:

itemsUl.addEventListener("click", evt => {
    if (evt.target.tagName === "BUTTON") {
    };
});

Теперь, если была нажата кнопка удаления ‹Button›, мы хотим удалить связанный элемент как из базы данных, так и из DOM. Чтобы удалить из базы данных, нам нужно выполнить соответствующую выборку на сервер. Если мы предположим, что наш бэкэнд - RESTful, наша выборка будет выглядеть примерно так:

itemsUl.addEventListener("click", evt => {
    if (evt.target.tagName === "BUTTON") {
        fetch("http://localhost:3000/items/*item id*", {
            method: "DELETE"
        });
    };
});

Но подождите, какой идентификатор элемента входит в URL-адрес выборки? Как мы его находим? Что ж, мы, вероятно, могли бы сделать это несколькими способами, но я думаю, что самый простой - добавить атрибут данных к каждой ‹button›, когда мы это сделаем, а затем присвоить этому атрибуту значение, равное идентификатору связанного элемента. Для этого мы добавляем одну строку кода при создании каждой ‹button›:

deleteButton.dataset.itemId = item.id;

Итак, теперь исходный блок выглядит так:

const itemsUl = document.querySelector("#items-list");
fetch("http://localhost:3000/items")
.then(res => res.json())
.then(itemsArr => {
    itemsArr.forEach(item => {
        let itemLi = document.createElement("li");
        itemLi.innerText = item.name;
        itemsUl.append(itemLi);
        
        let deleteButton = document.createElement("button");
        deleteButton.innerText = "x";
        deleteButton.dataset.itemId = item.id;
        itemLi.append(deleteButton);
    });
});

Теперь, когда мы нажимаем кнопку удаления ‹button›, у нас есть доступ к идентификатору связанного элемента и мы можем использовать его при выборке на сервер:

itemsUl.addEventListener("click", evt => {
    if (evt.target.tagName === "BUTTON") {
        let id = evt.target.dataset.itemId;
        fetch(`http://localhost:3000/items/${id}`, {
            method: "DELETE"
        });
    };
});

Идеально. Предполагая, что наш бэкэнд работает правильно, это должно удалить элемент из базы данных. Теперь, чтобы (пессимистично) удалить ‹li› из DOM:

itemsUl.addEventListener("click", evt => {
    if (evt.target.tagName === "BUTTON") {
        let id = evt.target.dataset.itemId;
        fetch(`http://localhost:3000/items/${id}`, {
            method: "DELETE"
        })
        .then(() => {
            let li = evt.target.parentNode;
            li.remove();
        });
    };
});

Мы находим родительский узел ‹button›, которым является элемент ‹li›, затем удаляем весь ‹li› из DOM. Собирая все вместе, наш окончательный код выглядит так:

const itemsUl = document.querySelector("#items-list");
fetch("http://localhost:3000/items")
.then(res => res.json())
.then(itemsArr => {
    itemsArr.forEach(item => {
        let itemLi = document.createElement("li");
        itemLi.innerText = item.name;
        itemsUl.append(itemLi);
        
        let deleteButton = document.createElement("button");
        deleteButton.innerText = "x";
        deleteButton.dataset.itemId = item.id;
        itemLi.append(deleteButton);
    });
});
itemsUl.addEventListener("click", evt => {
    if (evt.target.tagName === "BUTTON") {
        let id = evt.target.dataset.itemId;
        fetch(`http://localhost:3000/items/${id}`, {
            method: "DELETE"
        })
        .then(() => {
            let li = evt.target.parentNode;
            li.remove();
        });
    };
});

Использование замыканий

Для начала наш код будет очень похож на предыдущий:

const itemsUl = document.querySelector("#items-list");
fetch("http://localhost:3000/items")
.then(res => res.json())
.then(itemsArr => {
    itemsArr.forEach(item => {
        let itemLi = document.createElement("li");
        itemLi.innerText = item.name;
        itemsUl.append(itemLi);
        
        let deleteButton = document.createElement("button");
        deleteButton.innerText = "x";
        itemLi.append(deleteButton);
    };
});

Однако нам не нужно добавлять атрибут данных на ‹button› для хранения идентификатора конкретного элемента. Причина станет ясна, когда мы добавим в наш код (предупреждение о спойлере: это связано с закрытием). Затем давайте поместим прослушиватель событий на каждую кнопку удаления ‹button› сразу после его создания в блоке forEach:

const itemsUl = document.querySelector("#items-list");
fetch("http://localhost:3000/items")
.then(res => res.json())
.then(itemsArr => {
    itemsArr.forEach(item => {
        let itemLi = document.createElement("li");
        itemLi.innerText = item.name;
        itemsUl.append(itemLi);
        
        let deleteButton = document.createElement("button");
        deleteButton.innerText = "x";
        itemLi.append(deleteButton);
        deleteButton.addEventListener("click", () => {
        });
    });
});

Размещение здесь слушателя событий дает нам несколько приятных преимуществ. Во-первых, нам не нужно проверять, действительно ли была нажата кнопка удаления ‹button›, как мы это делали при делегировании, потому что мы помещаем слушателя на каждую кнопку удаления. Во-вторых, поскольку определение функции обратного вызова прослушивателя событий находится внутри блока forEach, оно закрывает блок, то есть имеет доступ ко всему внутри блока. Это, в свою очередь, помогает нам с двумя задачами, первая из которых - указать правильный идентификатор в URL-адресе нашей выборки, чтобы удалить элемент из базы данных:

const itemsUl = document.querySelector("#items-list");
fetch("http://localhost:3000/items")
.then(res => res.json())
.then(itemsArr => {
    itemsArr.forEach(item => {
        let itemLi = document.createElement("li");
        itemLi.innerText = item.name;
        itemsUl.append(itemLi);
        
        let deleteButton = document.createElement("button");
        deleteButton.innerText = "x";
        vLi.append(deleteButton);
        deleteButton.addEventListener("click", () => {
            fetch(`http://localhost:3000/items/${item.id}`, {
                method: "DELETE"
            });
        });
    });
});

Поскольку у нас есть доступ к переменной item (это данные из серверной части, которые мы повторяем в нашем forEach), мы можем легко извлечь ее идентификатор и поместить в URL для нашей выборки. Это удалит элемент из базы данных, а затем удалит его из DOM:

const itemsUl = document.querySelector("#items-list");
fetch("http://localhost:3000/items")
.then(res => res.json())
.then(itemsArr => {
    itemsArr.forEach(item => {
        let itemLi = document.createElement("li");
        itemLi.innerText = item.name;
        itemsUl.append(itemLi);
        
        let deleteButton = document.createElement("button");
        deleteButton.innerText = "x";
        itemLi.append(deleteButton);
        deleteButton.addEventListener("click", () => {
            fetch(`http://localhost:3000/items/${item.id}`, {
                method: "DELETE"
            })
            .then(() => itemLi.remove());
        });
    });
});

Обратите внимание, что нам не нужно получать доступ к ‹li›, находя родительский узел кнопки удаления, как мы это делали с делегированием. Здесь у нас есть доступ к ‹li›, потому что функция обратного вызова в нашем .then () закрыта функцией обратного вызова нашего прослушивателя событий, которая закрыта на протяжении всего forEach , в котором находится наша переменная itemLi.

Самая крутая часть, на мой взгляд, заключается в том, что даже несмотря на то, что все ‹li›, delete ‹button› и прослушиватель событий создаются в одном блоке кода, каждая итерация этого кода представляет собой новую лексическую среду с новыми переменными и новыми функциями обратного вызова. Проще говоря, каждая итерация - это собственное закрытие! Это довольно здорово, правда?

Очень аккуратный.

Что нужно учитывать

Оба эти метода вполне допустимы, но в зависимости от того, какой метод мы используем, мы должны помнить о некоторых вещах при создании остальной части нашей функциональности.

Когда мы использовали делегирование, мы нашли родительский узел удаления ‹button› (который является элементом ‹li›), чтобы удалить его из DOM. Но если по какой-то причине родительский узел ‹button› изменится из-за кода, который мы (или наши партнеры по проекту, коллеги или кто-либо еще) напишем в будущем, у нас возникнут некоторые проблемы. Точно так же, если ‹li› и ‹button› когда-либо будут перемещены из родительского ‹ul›, наш слушатель событий никогда не узнает, когда были нажаты ‹button› s.

Когда мы использовали замыкания для добавления отдельных прослушивателей событий к каждой ‹button›, мы привязывали эти прослушиватели событий к определенным элементам DOM. Но эти элементы и слушатели DOM нестабильны. Если мы, например, изменим внутренний HTML-код наших ‹ul› или ‹li› с помощью .innerHTML = или .innerHTML + =, мы потеряем эти слушатели событий. Это опасно при использовании .innerHTML в целом, и здесь оно очень распространено.

Последние мысли

Как и все остальное в JavaScript, существует несколько способов прослушивания событий при повторяющихся экземплярах элементов DOM. Два обсуждаемых здесь метода, делегирование и использование закрытий, являются отличными вариантами. Для подавляющего большинства приложений подойдет любой вариант, выбор за вами. Более важным, чем выбор, является понимание того, почему вы делаете этот выбор, и понимание того, как этот выбор влияет на остальную часть вашего приложения.