Рендер из состояния и делегирование
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Кнопки, галочки и подписи — не данные, а их отражение. Соберём доску задач так, чтобы разметка всегда была функцией массива: render() рисует состояние целиком, один обработчик на контейнере ловит все клики, а data-атрибуты держат связь между узлом и записью.
Соблазн такой: клик по кнопке — находим нужный li, вешаем класс, меняем текст, уменьшаем счётчик в шапке. Работает ровно до третьего места, где ту же задачу видно иначе. Дальше начинается сверка вручную: галочка стоит, а счётчик не сдвинулся; строку удалили, а в фильтре она осталась.
Развернём поток. Истина живёт в обычном массиве объектов, страница — только его отражение. Обработчик не трогает узлы: он меняет данные и просит перерисовать. Читать состояние обратно из DOM (if (li.classList.contains("done"))) — тот же спор двух источников правды, просто замаскированный.
render — функция одного направления: смотрит в состояние, отдаёт разметку. Она ничего не спрашивает у страницы и не помнит, что рисовала в прошлый раз, поэтому звать её можно сколько угодно раз подряд — результат один и тот же. Свойство дороже, чем кажется: баг «на экране одно, в данных другое» становится невозможным по построению.
Внутри — обычный map по массиву и join(""). Присваивание innerHTML выбрасывает старое поддерево целиком и разбирает строку заново: старых узлов после этого не существует, даже если внешне ничего не изменилось.
Первое: строка вставляется как разметка. Если в заголовке задачи окажется <b>, пришедший из формы, он станет тегом, а не текстом. Для пользовательских данных либо экранируйте их перед вставкой, либо собирайте узлы через createElement и textContent.
Второе: пересборка уносит всё, чего нет в состоянии, — введённый текст, фокус, позицию курсора, прокрутку. Ниже видно, почему: свойство value живёт в узле, а в разметке остаётся только атрибут.
Разметка обязана помнить, чьё она отражение. Для этого есть атрибуты data-*: любое имя после data- валидно и никогда не столкнётся с браузерным. Свойство dataset отдаёт их объектом, переводя дефисы в верблюжий регистр: data-created-at становится createdAt.
Одно важное «но»: атрибут — всегда строка. dataset.id вернёт "7", и сравнение с числом провалится молча; приводите через Number(). И держите в data- только ключ — сам объект задачи пусть живёт в состоянии, а не размазывается по разметке. Обратный перевод имён, ловушку с цифрами и разницу с getAttribute разбирает статья «data-атрибуты и dataset».
Двадцать задач по три кнопки — шестьдесят подписок, и все они умирают при первой же пересборке. Но события всплывают: клик по кнопке проходит через li, ul и дальше вверх. Значит, подписаться можно один раз — на контейнере, которого render не касается.
В обработчике два разных элемента. event.currentTarget — контейнер, на котором висит слушатель. event.target — самый глубокий узел, по которому щёлкнули: не кнопка, а иконка внутри неё. Поэтому от target поднимаются вверх через closest(), а если ничего не нашлось — тихо выходят: значит, клик был мимо.
| Слушатель на каждой кнопке | Один на контейнере | |
|---|---|---|
| Подписок на 20 задач по 3 кнопки | 60 | 1 |
| После render() | подписать заново | ничего не делать |
| Кнопка нового типа | ещё одна подписка | ещё одна ветка в обработчике |
| Что видит обработчик | только свой элемент | target, контейнер и всю доску |
Условие одно: событие должно всплыть до контейнера. У focus и blur всплытия нет — для делегирования берут их близнецов focusin и focusout; та же история у mouseenter/mouseleave против mouseover/mouseout. Полный список невсплывающих событий и обход для каждого собран в статье «Делегирование событий».
Второй случай — события, которые вы создаёте сами: new Event("input") по умолчанию не всплывает и до контейнера не дойдёт.
Теперь всё вместе. Клик всплывает до контейнера, обработчик находит запись по data-id, меняет данные и зовёт render(). Кнопка, по которой щёлкнули, при этом перестаёт существовать — и это нормально: слушатель принадлежит контейнеру, а не ей.
Заметьте, чего в этом коде нет: поиска нужного li, снятия и навешивания классов, ручной синхронизации счётчиков, переподписки после перерисовки. Добавить фильтр «только невыполненные» — значит дописать одну строку в render, и он заработает сразу и при первой отрисовке, и после клика, и после удаления.
Полная пересборка через innerHTML — честный размен: кода меньше, работы браузеру больше. На доске в десятки строк этого не заметно.
Заметно станет на тысячах строк, но раньше скажется другая цена: стоит в перерисовываемой разметке появиться полю ввода, и каждая пересборка будет сбрасывать набранный текст и фокус. Обновлять разметку из состояния, не пересоздавая её целиком, — ровно та задача, ради которой существуют React, Vue и их родня; как её решает React, разбирает статья «Зачем React, если вы уже писали рендер из состояния».
- Источник правды — состояние (обычный массив объектов). Страница только отражает его, и никогда не наоборот.
- render() собирает разметку из состояния целиком и ничего не спрашивает у страницы: два вызова подряд дают один и тот же результат.
- data-* связывает узел с записью, dataset читает их в верблюжьем регистре, и значения там всегда строки — приводите через Number().
- Один слушатель на контейнере плюс event.target.closest() заменяет десятки подписок и переживает любую пересборку.
- Цикл замкнут: событие меняет данные, данные вызывают render. Точечных правок разметки мимо состояния в коде не остаётся.
1/3Слушатель клика висит на <ul>. После render() всё содержимое списка пересобрано через innerHTML. Что стало с обработчиком?
2/3Для <li data-id="7"> что выведет console.log(li.dataset.id === 7)?
3/3Внутри делегированного обработчика event.target — это...
Доска из состояния
Дан массив задач и пустой ul. Напишите render(), который рисует по строке на задачу: li с data-id и двумя кнопками — data-action="toggle" и data-action="remove". Затем повесьте на список один обработчик: он меняет состояние и перерисовывает. Прямых правок разметки внутри обработчика быть не должно.
Разметку целиком собирает render, а обработчик знает только про состояние: находит запись по data-id, меняет её и просит перерисовать.
Подписка делается один раз и до первого render(): контейнер не пересоздаётся, поэтому переживает любую пересборку. Обратите внимание на Number() — без него find сравнит строку с числом и вернёт undefined.
Клик мимо цели
Обработчик читает действие прямо из event.target.dataset. Кнопка с иконкой внутри — и клик по иконке не делает ничего. Объясните, почему счётчик остался нулевым, и почините одной строкой.
event.target здесь — span внутри кнопки: своего data-action у него нет, и обработчик выходит на if (!action) return. Кнопка находится не чтением с target, а подъёмом от него:
closest проверяет и сам элемент, и всех предков, поэтому работает одинаково при клике по иконке, по тексту и по самой кнопке. Проверка if (!button) return при этом остаётся — она отсекает клики по пустому месту контейнера.