DOM и события: страница начинает отвечать
- Зачем это нужно
- Дерево документа
- Найти элемент
- Изменить: текст или разметка
- Создать и вставить
- События: поймать действие
- Всплытие: событие идёт вверх
- Форма без перезагрузки
- Цена перерисовки
- Что теряется при перерисовке
- Типичные ошибки
- Слова главы
- Тренажёр: набрать вслепую
- На чемпионате
- Куда углубиться
- Куда дальше: проверенные ресурсы
Зачем это нужно
Страница описана и оформлена, но она мертва. Нажатие на кнопку ничего не делает, введённая сумма никуда не уходит, баланс не обновляется.
Оживляет её тот же язык, на котором мы четырнадцать глав писали скрипты. Только выполняется он не в Node, а в браузере, и у него есть то, чего в Node нет: страница, к которой можно обращаться из кода.
Эта глава — про три вещи: как найти элемент, как его изменить и как поймать действие пользователя. И в конце — измерение, из которого растёт следующая глава: почему делать это руками перестают, как только интерфейс становится больше десяти элементов.
Дерево документа
Когда браузер разобрал разметку, он держит в памяти дерево объектов: у каждого элемента страницы есть соответствующий объект, у него — родитель, потомки, атрибуты и содержимое.
Это дерево и есть то, что видно на экране. Изменили объект — изменилась страница, мгновенно. Никакой «перезагрузки» для этого не нужно.
Важное следствие: исходный файл разметки и дерево — разные вещи. Файл прочитан один раз; дальше живёт дерево, и добавленные из кода элементы в файле не появятся никогда.
Найти элемент
── найти ──
querySelector("#t") → H1, текст "Хранилище"
querySelectorAll(".op") → 3 шт., тип NodeList
есть ли у него .map → false
[...список].map даёт → вклад, вывод, вклад
Селектор в поиске — тот же, что в оформлении. Это значит, что классы, придуманные для внешнего вида, годятся и для поиска. Но для поиска из кода обычно заводят отдельные имена — иначе правка оформления ломает логику.
Изменить: текст или разметка
У элемента есть два способа задать содержимое, и разница между ними — вопрос безопасности, а не удобства.
── одна и та же строка от пользователя, два способа вставки ──
строка: <img src=x onerror='document.title="взломано"'>Баланс: 100
innerHTML → заголовок вкладки стал: "взломано"
textContent → на странице осталось: "<img src=x onerror='document.title="взло…"
Первый способ разбирает строку как разметку. В прогоне это выполнилось по-настоящему: картинка не загрузилась, сработал обработчик ошибки, и заголовок вкладки сменился.
Строка пришла бы не от нас, а от пользователя — комментарий, имя, подпись к операции. Или из сети: поле чужого контракта, который любой мог записать. Результат тот же.
Второй способ вставляет строку как текст. Угловые скобки видны на экране, выполнять нечего.
Правило без исключений: всё, что не написано вами в файле, вставляется текстом. Разметку из строки собирают только тогда, когда строку собрали вы сами, из проверенных кусков.
Создать и вставить
const row = document.createElement("li");
row.className = "op";
row.textContent = `${who} — ${amount} ETH`;
row.dataset.id = id;
list.append(row);
Строка из шаблона с подстановкой (${who}) здесь безопасна: она попадает в текст, а не в разметку.
События: поймать действие
button.addEventListener("click", (event) => {
console.log("нажали", event.target);
});
Всплытие: событие идёт вверх
Событие не остаётся на элементе, где произошло: оно поднимается по дереву до самого верха, и по дороге срабатывают все обработчики.
── всплытие: нажали кнопку, слушает контейнер ──
сама кнопка 1 тоже отреагировала
контейнер поймал нажатие на кнопке 1
контейнер поймал нажатие на кнопке 2
добавили третью кнопку уже после навешивания обработчика:
контейнер поймал нажатие на кнопке 3
Порядок в первых двух строках не случаен: сначала сработал обработчик на самой кнопке, потом на контейнере. Событие шло снизу вверх.
Из этого получается приём, которым пользуются постоянно, — делегирование: вместо обработчика на каждой кнопке вешают один на контейнер и внутри смотрят, кто источник.
list.addEventListener("click", (event) => {
const button = event.target.closest("button[data-id]");
if (!button) return;
withdraw(button.dataset.id);
});
Три причины так делать:
- обработчик один, а не сто — меньше памяти и меньше работы при построении списка;
- работает для будущих элементов: третья кнопка в прогоне добавлена уже после навешивания, и её нажатие поймалось;
- не надо снимать обработчики при удалении строк — они и не навешивались.
Форма без перезагрузки
── отправка формы перехвачена ──
данные формы: {"amount":"5"}
страница не перезагрузилась, значение поля на месте: "5"
form.addEventListener("submit", (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(event.target));
deposit(data.amount);
});
Три детали, каждая из которых важна.
Слушают событие отправки у формы, а не нажатие у кнопки. Форму отправляют и клавишей ввода в поле — обработчик на кнопке этого не поймает.
Отмена поведения по умолчанию обязательна. Без неё браузер уходит на другой адрес, и всё введённое исчезает. Это самая частая причина «нажал — страница моргнула и всё пропало».
Данные берут из формы целиком. Не собирают по одному полю через поиск: имена уже написаны в разметке, и сбор одной строкой не разъезжается с ней.
Цена перерисовки
Список из двухсот операций. Изменилось одно число. Есть два способа обновить страницу, и прогон меряет оба:
── список из 200 строк ──
узлов в таблице: 400
полная перерисовка : 33.27 мс
изменение одной ячейки : 0.003 мс
разница : в 9981 раз
Тридцать три миллисекунды — это два кадра. Если такое происходит на каждое нажатие клавиши в поле поиска, интерфейс ощутимо тормозит.
Что теряется при перерисовке
Скорость — не главная проблема. Прогон ставит поле ввода внутрь перерисовываемого списка, набирает в нём текст и перерисовывает:
── поле ввода ВНУТРИ перерисовываемого списка ──
до перерисовки : фокус true, текст "моя заметка", курсор 3
после : фокус false, текст ""
это тот же узел? false
Пользователь набирал заметку. В этот момент обновилось число где-то в другой строке — и текст исчез, а курсор ушёл из поля.
Причина простая: при полной перерисовке старые узлы уничтожаются, а новые создаются с нуля. Всё, что жило в узлах и не хранилось отдельно, пропадает: набранный текст, положение курсора, прокрутка, подсветка, поставленная кодом.
Значит, делать надо точечно: менять ровно то, что изменилось. Это быстро и ничего не ломает — но писать вручную приходится десятки мест, и каждое надо не забыть обновить при любом изменении данных.
Отсюда и появились библиотеки для интерфейсов. Идея у них одна: вы описываете, как должен выглядеть интерфейс при таких данных, а сравнением старого с новым и точечными изменениями занимается библиотека. Следующая глава — про одну из них.
Типичные ошибки
Скрипт подключён в служебной части без отсрочки. Он выполняется раньше, чем существует тело документа, поиск возвращает пустоту. Лечится атрибутом отсрочки или подключением в конце тела.
Поиск вернул пустоту, а к ней обратились. Знакомое по главе про пустоту сообщение: нельзя прочитать свойство у ничего. Первым делом — опечатка в селекторе.
Вставка строки от пользователя как разметки. Разобрано выше. Это не теоретическая опасность.
Забыли отменить поведение формы по умолчанию. Страница перезагружается, введённое теряется.
Обработчик на кнопке вместо события отправки формы. Клавиша ввода в поле перестаёт работать.
Обработчик вешают в цикле на каждый элемент списка. Работает, пока список не обновился: после перерисовки обработчики исчезли вместе со старыми узлами.
class вместо className при задании класса из кода. Слово занято языком.
Слова главы
- Дерево документа — объекты в памяти, соответствующие элементам страницы; меняем объект — меняется экран.
- Поиск по селектору — одиночный и множественный; второй возвращает список узлов, а не массив.
- Текст против разметки — второе разбирает строку и выполняет то, что в ней; для чужих строк только первое.
- Создание элемента — создать, наполнить, вставить; до вставки его на странице нет.
- Атрибут данных — свой атрибут вида «данные-что-то»; так привязывают идентификатор к элементу.
- Обработчик — подписка на событие; их может быть много на одном элементе.
- Всплытие — событие поднимается по дереву, и по дороге срабатывают все обработчики.
- Делегирование — один обработчик на контейнере вместо множества на элементах.
- Отмена поведения по умолчанию — без неё форма перезагружает страницу, а ссылка уводит с неё.
Тренажёр: набрать вслепую
Цель: скорость ≥ 100 зн/мин, точность ≥ 88%
const el = document.querySelector("#total");
На чемпионате
Делегирование с самого начала. Список операций будет обновляться, и обработчики на каждой строке гарантированно отвалятся после первого же обновления.
Отмена поведения формы — в первой строке обработчика. До всего остального, чтобы не забыть.
Ничего из сети не вставлять разметкой. Адрес, имя токена, подпись операции — всё это приходит из контракта, и записать туда можно что угодно.
Кнопка на время операции выключается. Транзакция идёт несколько секунд; без блокировки пользователь нажмёт трижды и отправит три транзакции. Одна строка, а выглядит как забота.
И про подключение скрипта: <script src="app.js" defer> в служебной части. Тогда файл начинает грузиться сразу, а выполняется, когда дерево уже построено, — и поиск элементов работает.
Куда углубиться
- Наблюдатель за пересечением — узнать, что элемент появился в поле зрения; так делают подгрузку списка.
- Хранилище браузера — сохранить выбранную сеть или тему между заходами.
- Свои события — способ связать части интерфейса, не связывая их напрямую.
- Отмена подписки — когда обработчики надо снимать и чем это грозит, если не снять.
- Панель разработчика — просмотр дерева, обработчиков и того, что тормозит.
Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- MDN: работа с DOM — поиск, изменение, создание.
- MDN: события — обработчики, объект события, всплытие.
- learn.javascript.ru: браузер и DOM — большой русский учебник по этой теме.
- MDN: FormData — сбор данных формы одной строкой.
- OWASP: XSS Prevention Cheat Sheet — почему чужие строки не вставляют разметкой.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.