Рендер из состояния и делегирование

проект≈ 7 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Кнопки, галочки и подписи — не данные, а их отражение. Соберём доску задач так, чтобы разметка всегда была функцией массива: render() рисует состояние целиком, один обработчик на контейнере ловит все клики, а data-атрибуты держат связь между узлом и записью.

Разделы темы:·

Соблазн такой: клик по кнопке — находим нужный li, вешаем класс, меняем текст, уменьшаем счётчик в шапке. Работает ровно до третьего места, где ту же задачу видно иначе. Дальше начинается сверка вручную: галочка стоит, а счётчик не сдвинулся; строку удалили, а в фильтре она осталась.

Развернём поток. Истина живёт в обычном массиве объектов, страница — только его отражение. Обработчик не трогает узлы: он меняет данные и просит перерисовать. Читать состояние обратно из DOM (if (li.classList.contains("done"))) — тот же спор двух источников правды, просто замаскированный.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

render — функция одного направления: смотрит в состояние, отдаёт разметку. Она ничего не спрашивает у страницы и не помнит, что рисовала в прошлый раз, поэтому звать её можно сколько угодно раз подряд — результат один и тот же. Свойство дороже, чем кажется: баг «на экране одно, в данных другое» становится невозможным по построению.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Внутри — обычный map по массиву и join(""). Присваивание innerHTML выбрасывает старое поддерево целиком и разбирает строку заново: старых узлов после этого не существует, даже если внешне ничего не изменилось.

Первое: строка вставляется как разметка. Если в заголовке задачи окажется <b>, пришедший из формы, он станет тегом, а не текстом. Для пользовательских данных либо экранируйте их перед вставкой, либо собирайте узлы через createElement и textContent.

Второе: пересборка уносит всё, чего нет в состоянии, — введённый текст, фокус, позицию курсора, прокрутку. Ниже видно, почему: свойство value живёт в узле, а в разметке остаётся только атрибут.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Разметка обязана помнить, чьё она отражение. Для этого есть атрибуты data-*: любое имя после data- валидно и никогда не столкнётся с браузерным. Свойство dataset отдаёт их объектом, переводя дефисы в верблюжий регистр: data-created-at становится createdAt.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Одно важное «но»: атрибут — всегда строка. dataset.id вернёт "7", и сравнение с числом провалится молча; приводите через Number(). И держите в data- только ключ — сам объект задачи пусть живёт в состоянии, а не размазывается по разметке. Обратный перевод имён, ловушку с цифрами и разницу с getAttribute разбирает статья «data-атрибуты и dataset».

Двадцать задач по три кнопки — шестьдесят подписок, и все они умирают при первой же пересборке. Но события всплывают: клик по кнопке проходит через li, ul и дальше вверх. Значит, подписаться можно один раз — на контейнере, которого render не касается.

В обработчике два разных элемента. event.currentTarget — контейнер, на котором висит слушатель. event.target — самый глубокий узел, по которому щёлкнули: не кнопка, а иконка внутри неё. Поэтому от target поднимаются вверх через closest(), а если ничего не нашлось — тихо выходят: значит, клик был мимо.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
Слушатель на каждой кнопкеОдин на контейнере
Подписок на 20 задач по 3 кнопки601
После render()подписать зановоничего не делать
Кнопка нового типаещё одна подпискаещё одна ветка в обработчике
Что видит обработчиктолько свой элементtarget, контейнер и всю доску

Условие одно: событие должно всплыть до контейнера. У focus и blur всплытия нет — для делегирования берут их близнецов focusin и focusout; та же история у mouseenter/mouseleave против mouseover/mouseout. Полный список невсплывающих событий и обход для каждого собран в статье «Делегирование событий».

Второй случай — события, которые вы создаёте сами: new Event("input") по умолчанию не всплывает и до контейнера не дойдёт.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Теперь всё вместе. Клик всплывает до контейнера, обработчик находит запись по data-id, меняет данные и зовёт render(). Кнопка, по которой щёлкнули, при этом перестаёт существовать — и это нормально: слушатель принадлежит контейнеру, а не ей.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Заметьте, чего в этом коде нет: поиска нужного 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". Затем повесьте на список один обработчик: он меняет состояние и перерисовывает. Прямых правок разметки внутри обработчика быть не должно.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Разметку целиком собирает render, а обработчик знает только про состояние: находит запись по data-id, меняет её и просит перерисовать.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Подписка делается один раз и до первого render(): контейнер не пересоздаётся, поэтому переживает любую пересборку. Обратите внимание на Number() — без него find сравнит строку с числом и вернёт undefined.

Клик мимо цели

Обработчик читает действие прямо из event.target.dataset. Кнопка с иконкой внутри — и клик по иконке не делает ничего. Объясните, почему счётчик остался нулевым, и почините одной строкой.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

event.target здесь — span внутри кнопки: своего data-action у него нет, и обработчик выходит на if (!action) return. Кнопка находится не чтением с target, а подъёмом от него:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

closest проверяет и сам элемент, и всех предков, поэтому работает одинаково при клике по иконке, по тексту и по самой кнопке. Проверка if (!button) return при этом остаётся — она отсекает клики по пустому месту контейнера.