EverProduct
Фронтенд

Этап 03 · Язык

Живое дерево: DOM и события

Это слой, на котором страница перестаёт быть документом и становится программой, — и слой, на котором тихо стоит любой фреймворк, которым ты когда-либо воспользуешься.

До сих пор страница была документом: написали один раз, отрисовали один раз, лежит. Теперь она должна реагировать — меню открывается, строка удаляется, счётчик растёт, поле проверяется по мере набора.

Всё в этой фразе происходит через один интерфейс. Браузер отдаёт страницу в JavaScript как дерево объектов, которые можно читать, менять, дополнять и слушать. В конце концов это будут делать за тебя фреймворки, но делают они ровно это, и в тот день, когда что-то поведёт себя странно, ты вернёшься сюда читать напрямую. Стоит уметь.

DOM — не твой HTML. Это живой объект, который браузер по нему построил, и с первого же скрипта существует только он.

Как найти

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

querySelector возвращает первое совпадение или null. querySelectorAll возвращает все, статическим списком — снимком, а не живым представлением, поэтому добавленных потом элементов в нём не будет.

Две привычки экономят реальное время. Ищи внутри контейнера, а не по всему документу: спросить .title у корневого элемента компонента быстрее и, что важнее, невозможно случайно найти заголовок чужого компонента. И проверяй на null: querySelector, не нашедший ничего, — источник изрядной доли ошибок «cannot read properties of null», и обычно это значит, что скрипт отработал раньше, чем появился элемент, то есть история про парсер из первой статьи явилась лично.

Как менять

Чтение и запись содержимого сводятся к нескольким свойствам, и одно из них — решение про безопасность.

textContent задаёт текст как текст. innerHTML разбирает строку как разметку и строит из неё элементы. Второе удобно и является стандартным способом сделать дыру для межсайтового скриптинга: любая пользовательская строка, пропущенная через innerHTML, может принести с собой скрипт. Правило простое и безусловное: textContent, пока нет настоящей причины, и никогда innerHTML с тем, что набрал пользователь. По-настоящему эту историю рассказывает сфера «Безопасность»; здесь просто прими правило.

classListadd, 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 против changeinput срабатывает на каждое нажатие, 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 в каждый собираемый компонент. Сделай это привычкой до утечки, а не после.

Когда необъяснимо — отойди на десять минут. Два режима из сферы «Как учиться»: сфокусированное внимание находит баг, который ты ищешь, а рассеянный режим — тот, который ты не ищешь. Смотреть ещё пристальнее на те же восемь строк — наименее продуктивное, что можно сделать.

Проверь себя

Закрой статью и ответь своими словами:

  1. Почему результат querySelectorAll не живой и когда это имеет значение?
  2. В чём разница между textContent и innerHTML и каково правило?
  3. Почему переключение класса лучше установки инлайнового стиля?
  4. В чём разница между атрибутом и свойством?
  5. Опиши три фазы события и объясни делегирование.
  6. Когда нужен input, а когда change? Debounce или throttle?
  7. Что создаёт утечку обработчиков и что снимает их группой?

Коротко

  • DOM — живое дерево объектов, а не твой HTML-файл, и с первого скрипта существует только эта версия.
  • Выбирай CSS-селекторами, ограничивай поиск контейнером и жди null, если скрипт отработал слишком рано.
  • textContent по умолчанию; innerHTML с пользовательскими данными — это входная дверь для XSS.
  • Меняй вид переключением классов, храни мелкое состояние в data-* и помни, что свойства живые, а атрибуты — это то, что сказал документ.
  • Собирай вставки в DOM пачкой и не чередуй чтение с записью, иначе браузер будет пересчитывать раскладку на каждой итерации.
  • События идут вниз с перехватом и вверх со всплытием; делегирование заменяет множество обработчиков одним и покрывает ещё не существующие элементы.
  • Знай разницу input и change, вешай submit на форму и понимай, когда нужен debounce, а когда throttle.
  • Убирай обработчики через AbortController — и сначала собери что-нибудь руками, потому что иначе не почувствовать проблему, ради которой существуют фреймворки.