Живое дерево: DOM и события
Это слой, на котором страница перестаёт быть документом и становится программой, — и слой, на котором тихо стоит любой фреймворк, которым ты когда-либо воспользуешься.
До сих пор страница была документом: написали один раз, отрисовали один раз, лежит. Теперь она должна реагировать — меню открывается, строка удаляется, счётчик растёт, поле проверяется по мере набора.
Всё в этой фразе происходит через один интерфейс. Браузер отдаёт страницу в JavaScript как дерево объектов, которые можно читать, менять, дополнять и слушать. В конце концов это будут делать за тебя фреймворки, но делают они ровно это, и в тот день, когда что-то поведёт себя странно, ты вернёшься сюда читать напрямую. Стоит уметь.
DOM — не твой HTML. Это живой объект, который браузер по нему построил, и с первого же скрипта существует только он.
Как найти
Почти весь поиск покрывают два метода, и оба принимают CSS-селектор — тот же синтаксис, что ты уже знаешь по стилям; большой кусок обучения, который не приходится повторять.
querySelector возвращает первое совпадение или null. querySelectorAll возвращает все, статическим списком — снимком, а не живым представлением, поэтому добавленных потом элементов в нём не будет.
Две привычки экономят реальное время. Ищи внутри контейнера, а не по всему документу: спросить .title у корневого элемента компонента быстрее и, что важнее, невозможно случайно найти заголовок чужого компонента. И проверяй на null: querySelector, не нашедший ничего, — источник изрядной доли ошибок «cannot read properties of null», и обычно это значит, что скрипт отработал раньше, чем появился элемент, то есть история про парсер из первой статьи явилась лично.
Как менять
Чтение и запись содержимого сводятся к нескольким свойствам, и одно из них — решение про безопасность.
textContent задаёт текст как текст. innerHTML разбирает строку как разметку и строит из неё элементы. Второе удобно и является стандартным способом сделать дыру для межсайтового скриптинга: любая пользовательская строка, пропущенная через innerHTML, может принести с собой скрипт. Правило простое и безусловное: textContent, пока нет настоящей причины, и никогда innerHTML с тем, что набрал пользователь. По-настоящему эту историю рассказывает сфера «Безопасность»; здесь просто прими правило.
classList — add, remove, toggle, contains — то, чем меняют внешний вид. Настоятельно предпочитай его установке инлайновых стилей из JavaScript. Переключение is-open оставляет внешний вид в файле стилей, где ему и место, и даёт одно место для правки; запись в element.style рассыпает дизайнерские решения по логике и порождает самые специфичные стили в документе, которые потом ничем не перебить.
dataset читает и пишет атрибуты data-* — узаконенный способ прицепить к элементу маленький кусочек состояния: идентификатор строки, статус элемента.
И одно различие, которое один раз путает всех: атрибуты — не свойства. Атрибут — значение в HTML; свойство — живое состояние объекта. Свойство checked у чекбокса меняется по мере кликов пользователя; атрибут checked по-прежнему говорит то, что было в документе при загрузке. Хочешь знать, как есть сейчас, — читай свойство.
Как создавать
createElement делает элемент, append кладёт его на место. Для повторяющейся структуры чище держать разметку в элементе template и клонировать её через cloneNode, чем собирать элементы по одному.
Знать нужно про цену. Каждая вставка в живой документ может запустить раскладку и отрисовку — дорогие станции из первой статьи. Добавить тысячу строк по одной — попросить эту работу тысячу раз; собрать их в фрагмент документа и вставить один раз — попросить один раз. Это разница между списком, появляющимся мгновенно, и вкладкой, зависшей на две секунды.
Тот же принцип в самой частой форме: не чередуй чтение и запись. Чтение размера или позиции заставляет браузер вычислить раскладку прямо сейчас; запись её обесценивает. Чередование этих двух вещей в цикле заставляет пересчитывать на каждой итерации — у патологии даже есть имя, layout thrashing. Прочитай всё, что нужно, потом запиши всё, что нужно.
События
addEventListener связывает элемент, имя события и функцию. Функция получает объект события, и основную работу делают три его части:
target— элемент, на котором событие действительно произошло.currentTarget— элемент, чей обработчик сейчас выполняется. Отличается отtargetвсякий раз, когда событие пришло от потомка, и на этом целиком стоит следующий раздел.preventDefault()— отменить встроенную реакцию браузера: отправку формы с перезагрузкой, переход по ссылке, переключение чекбокса.
Есть ещё stopPropagation(), останавливающий путешествие события. Иногда он необходим и обычно является ловушкой: он означает, что какой-то компонент в другом месте молча перестал получать события, а получившийся баг — «почему выпадашка не закрывается при клике именно по этой кнопке?» — невидим с обеих сторон. Тянись к нему в последнюю очередь.
Всплытие и делегирование
Событие происходит не на одном элементе. Оно идёт вниз от документа к цели (фаза перехвата), срабатывает на цели, а потом идёт вверх через всех предков (фаза всплытия). Почти все обработчики выполняются на пути вверх.
Выглядит как справочная мелочь, а на деле это самый полезный механизм статьи, потому что он даёт делегирование: вместо обработчика на каждую из тысячи строк вешаешь один на таблицу и спрашиваешь event.target, из какой строки пришло.
Делегирование даёт две вещи. Оно заменяет тысячу обработчиков одним, что важно и для памяти, и для времени инициализации. И оно работает для элементов, которых ещё нет: строка, добавленная через пять минут, обрабатывается обработчиком, повешенным сейчас, без всякой перепривязки. Всякий, кто когда-либо недоумевал, почему его обработчик клика не работает на динамически добавленных элементах, встретился с проблемой, которую делегирование решает.
События, которые стоит знать
click — срабатывает и на касание, и на клавиатурную активацию настоящих кнопок и ссылок, что ещё один довод в пользу настоящих элементов.
input против change — input срабатывает на каждое нажатие, change — когда значение зафиксировано и поле покинуто. Поиску по мере ввода нужен input; проверке «когда закончил» — change.
submit на форме, а не click на кнопке: только так ловится и Enter.
keydown для работы с клавиатурой, с чтением event.key — настоящего имени клавиши.
focus и blur для момента проверки, и их всплывающие аналоги focusin и focusout, когда нужно делегирование.
scroll и resize срабатывают чудовищно часто, поэтому всё дорогое на них надо ограничивать: debounce ждёт, пока активность прекратится, и только потом выполняет (поле поиска), throttle выполняет не чаще раза в интервал (индикатор положения прокрутки). Понимание, что из двух нужно в этой ситуации, — маленький и по-настоящему переносимый кусок суждения.
Уборка
Обработчик держит ссылку на свою функцию, а та — на всё, что в её замыкании. Удали элемент, не сняв обработчики, в долгоживущем приложении — и получишь утечку памяти, растущую каждый раз, когда пользователь открывает ту же панель.
Два способа остаться чистым: держать функцию в переменной, чтобы передать её в removeEventListener, или — сильно лучше — создать AbortController и передавать его сигнал каждому добавляемому обработчику. Один вызов abort() снимает их все разом. В компонентном коде для этого существует размонтирование, и забытая уборка — одна из самых частых настоящих утечек в одностраничных приложениях.
Почему отсюда растут фреймворки
Собери что-нибудь небольшое таким способом — и почувствуешь проблему сам. У тебя есть данные в переменных и картинка в DOM, и каждое изменение означает обновление обоих, руками, в правильном порядке. Добавь третье место, где показано то же число, — придётся помнить про все три. Следующие за этим баги не сложные, они бесконечные: экран и данные тихо расходятся.
Ровно это решают фреймворки, и поэтому существует следующий этап. Но сначала сделай это руками, на чём-то маленьком — список дел, таблица с фильтром, — потому что причина всего в следующем этапе невидима, пока ты не имел проблемы, которую он убирает.
И заметь, чем ты на самом деле занят, пока это строишь: делаешь то, чего ещё не умеешь, с немедленной обратной связью и исправлением по ходу. Это и есть осознанная практика в описании сферы «Как учиться», и поэтому час сборки здесь стоит трёх часов чтения.
На практике
Собери одну интерактивную вещь без фреймворка. Список дел с добавлением, удалением, фильтром и счётчиком. Двести строк, один вечер — и статья превращается в то, что знают руки.
По умолчанию делегируй всё, что имеет форму списка. Один обработчик на контейнере, event.target для поиска элемента.
Переключай классы, а не задавай стили. Поймал себя на element.style — спроси, какой класс следовало переключить.
Проговаривай фазу вслух, когда событие делает неожиданное: где началось, через что прошло, кто ещё слушает. Половина событийных багов снимается этой фразой.
Добавляй AbortController в каждый собираемый компонент. Сделай это привычкой до утечки, а не после.
Когда необъяснимо — отойди на десять минут. Два режима из сферы «Как учиться»: сфокусированное внимание находит баг, который ты ищешь, а рассеянный режим — тот, который ты не ищешь. Смотреть ещё пристальнее на те же восемь строк — наименее продуктивное, что можно сделать.
Проверь себя
Закрой статью и ответь своими словами:
- Почему результат
querySelectorAllне живой и когда это имеет значение? - В чём разница между
textContentиinnerHTMLи каково правило? - Почему переключение класса лучше установки инлайнового стиля?
- В чём разница между атрибутом и свойством?
- Опиши три фазы события и объясни делегирование.
- Когда нужен
input, а когдаchange? Debounce или throttle? - Что создаёт утечку обработчиков и что снимает их группой?
Коротко
- DOM — живое дерево объектов, а не твой HTML-файл, и с первого скрипта существует только эта версия.
- Выбирай CSS-селекторами, ограничивай поиск контейнером и жди
null, если скрипт отработал слишком рано. textContentпо умолчанию;innerHTMLс пользовательскими данными — это входная дверь для XSS.- Меняй вид переключением классов, храни мелкое состояние в
data-*и помни, что свойства живые, а атрибуты — это то, что сказал документ. - Собирай вставки в DOM пачкой и не чередуй чтение с записью, иначе браузер будет пересчитывать раскладку на каждой итерации.
- События идут вниз с перехватом и вверх со всплытием; делегирование заменяет множество обработчиков одним и покрывает ещё не существующие элементы.
- Знай разницу
inputиchange, вешайsubmitна форму и понимай, когда нужен debounce, а когда throttle. - Убирай обработчики через
AbortController— и сначала собери что-нибудь руками, потому что иначе не почувствовать проблему, ради которой существуют фреймворки.