DOMContentLoaded, load и порядок загрузки

хранение≈ 6 минут

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

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

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

Браузер не ждёт, пока приедет весь HTML. Он разбирает его потоком: прочитал кусок разметки — достроил кусок дерева. Поэтому «страница» существует в нескольких состояниях подряд, и код, запущенный в разные моменты, видит разные документы.

Обычный <script> без атрибутов останавливает разбор: пока скрипт выполняется, дерево не растёт. Скрипт с defer ждёт конца разбора и выполняется прямо перед DOMContentLoaded. Картинки же не блокируют ничего — их ждёт только load.

МоментЧто уже естьСобытие
Пришли первые байты HTMLпустой документ, readyState = loading—
Парсер идёт по разметкечасть дерева выше текущей строки—
Разметка разобрана до концавсё дерево, скрипты с defer отработалиDOMContentLoaded
Догрузились картинки, стили, фреймыстраница целикомload
js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Скрипт в <head> выполняется раньше, чем парсер дошёл до <body>. Для него кнопки, формы и футера в документе просто не существует — не «они пустые», а их нет. querySelector в этом случае возвращает null, и это не сбой, а честный ответ.

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

Падает код обычно не на самом поиске, а строкой ниже — когда у null просят свойство. Сообщение вида «Cannot read properties of null» почти всегда означает не опечатку в селекторе, а неверный момент запуска.

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

Атрибуты подключения решают ту же задачу, что и обработчик DOMContentLoaded, только на уровне разметки. Для собственного кода страницы почти всегда правильный ответ — defer или модуль. Чем три способа отличаются по моменту выполнения и по порядку, целиком разбирает следующая статья — «defer, async и куда ставить скрипт».

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

DOMContentLoaded срабатывает на document и означает одно: разметка разобрана, дерево построено, отложенные скрипты выполнены. Картинки при этом могут висеть наполовину скачанными, и это нормально — их размеры ещё неизвестны.

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

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

DOMContentLoaded — про разметку, load — про всё остальное. Если вы не измеряете картинку и не лезете внутрь фрейма, вам нужен первый.

События рассказывают о переходе, а document.readyState — о текущем состоянии. Значения три: loading — разметка ещё разбирается, interactive — дерево готово (это момент DOMContentLoaded), complete — догрузилось всё (момент load).

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

Это важно, потому что события не помнят прошлое: подписаться на уже случившийся DOMContentLoaded — то же самое, что ждать поезд, который уехал. Промис бы отдал результат, событие — нет. Поэтому код, который может подключиться поздно (скрипт из библиотеки, виджет, вставка от третьей стороны), сначала спрашивает readyState.

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

Последнее событие в жизни страницы — beforeunload на window. Вызов preventDefault() внутри обработчика просит браузер показать диалог «уйти или остаться». Свой текст туда не подставить: браузеры показывают только собственную формулировку, а диалог вообще не появится, если пользователь ни разу не взаимодействовал со страницей.

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

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

В beforeunload нельзя сделать ничего долгого: асинхронный код почти наверняка не успеет. А переложить сохранение на соседнее событие unload — и вовсе худший из вариантов: одна подписка на него лишает страницу кеша «назад-вперёд», и возврат кнопкой «назад» превращается в полную перезагрузку. У beforeunload такого последствия нет.

Для сохранения черновика надёжнее visibilitychange со значением hidden и pagehide: они приходят и при закрытии вкладки, и при сворачивании браузера на телефоне. Отправку в этот момент делают через navigator.sendBeacon — запрос переживает закрытие страницы.

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

  • Браузер разбирает HTML сверху вниз: скрипт видит только ту часть дерева, что находится выше него, поэтому в head элементы из body ещё не существуют.
  • DOMContentLoaded на document означает «разметка разобрана», load на window — «догрузились картинки, стили и фреймы»; между ними могут пройти секунды.
  • document.readyState хранит текущую стадию: loading, interactive или complete.
  • Подписка на уже случившееся событие не сработает — сначала проверяйте readyState и вызывайте обработчик напрямую.
  • beforeunload с preventDefault показывает стандартный диалог браузера; свой текст туда не передать, а для сохранения данных надёжнее visibilitychange и sendBeacon.

1/3Скрипт подключён в head обычным тегом script и вызывает document.querySelector("#footer"), а сам футер описан в конце body. Что вернёт вызов?

2/3Обработчик на DOMContentLoaded добавили в момент, когда document.readyState уже равен complete. Что произойдёт?

3/3Нужно узнать, какого размера получилась картинка на странице. Какого момента дождаться?

Надёжный старт независимо от момента подключения

Допишите onReady так, чтобы обработчик вызвался в обоих случаях: и когда скрипт подключился рано (разметка ещё разбирается), и когда он выполнился позже, чем случился DOMContentLoaded.

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

Одной подписки мало: если событие уже прошло, обработчик не вызовется. Поэтому сначала спрашиваем состояние документа.

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

Значение loading — единственное, при котором дерево ещё не готово. Значения interactive и complete оба означают, что разметка разобрана, и обработчик можно звать сразу. Флаг once не обязателен, но избавляет от ручного снятия подписки.

Предупреждение только при несохранённом черновике

Обработчик beforeunload должен просить браузер показать диалог только тогда, когда в поле что-то введено. Пустая форма уходит молча.

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

Диалог включает единственная строчка — preventDefault(), и вызывать её нужно под условием.

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

Проверка через dispatchEvent удобна тем, что не требует реально уходить со страницы: свойство defaultPrevented показывает решение обработчика. В настоящей навигации браузер дополнительно потребует, чтобы пользователь хоть раз кликнул по странице, иначе диалог не покажет вовсе.