Давайте проведем простые тесты, чтобы выяснить, где лучше всего разместить тег script.

В браузере JavaScript можно использовать для многих вещей, но обычно основной задачей JavaScript является создание интерактивного контента или, другими словами, динамическое построение DOM. Учитывая это, какое место в HTML-файле лучше всего подходит для тега script? Есть два разумных варианта - до или после визуализированного HTML. Можно также вставить тег script где-нибудь в середине HTML, но я не буду рассматривать этот вариант, потому что я не знаю никаких очевидных аргументов в пользу этого.

Я использовал Chrome 92 для тестов, потому что это институциональный инструмент, в котором я работаю, и в целом самый популярный и удобный браузер.

Зачем помещать теги скрипта в head?

Я могу думать только о эстетических соображениях, которые не могут быть подтверждены экспериментально. HTML-документ выглядит более аккуратно, если неотрисованные теги script или link изолированы в head от отображаемого содержимого, которое обычно содержится внутри body.

Также более естественно держать готовый код вверху. Если вы что-то пишете, вы пишете сверху вниз. Текст, который уже написан, находится вверху, а текст, который будет написан, будет ниже. То же самое и с кодом. После того, как вы добавили тег script, вам вряд ли когда-либо понадобится его изменять или даже видеть. Вы забываете о теге и продолжаете разрабатывать HTML ниже.

Зачем размещать тег скрипта непосредственно перед </body>?

Я слышал о двух распространенных причинах. Но, возможно, их больше.

Чтобы убедиться, что DOM готов

JavaScript в основном используется для построения DOM. Когда сценарий размещается после визуализированного HTML, DOM обязательно будет проанализирован и готов к манипуляциям при выполнении сценария. Продемонстрируем эту основную идею. Представьте себе образец файла JavaScript bundle.js, который добавляет текст DONE к элементу с идентификатором root.

// bundle.js
document.getElementById('root').replaceChildren("DONE");

Сценарий должен завершиться ошибкой, если во время его выполнения в DOM нет целевого элемента. Чтобы продемонстрировать, что я вставил <script src="js/bundle.js"></script> в head файла HTML:

<!-- head.html -->
<html>
<head>
    <script src="js/bundle.js"></script>
</head>
<body>
    <div id="root">LOADING</div>
</body>
</html>

Когда я открываю head.html в браузере, я не вижу желаемого содержимого DONE. Вместо этого я вижу ошибку в консоли:

Ошибка указывает на то, что скрипту не удается найти элемент с идентификатором root в DOM. Ошибка является ожидаемой и показывает, как браузер обрабатывает загруженные HTML-документы. Когда браузер встречает тег script, он прекращает синтаксический анализ HTML, он синхронно загружает и выполняет код JavaScript и только после этого продолжает синтаксический анализ HTML, следующего за тегом сценария. Итак, когда bundle.js выполняется, следующий за ним элемент <div id=”root”>LOADING</div> еще не был обработан и вставлен в DOM.

Чтобы скрипт заработал, его нужно запускать только после того, как цель <div id=”root”>LOADING</div> будет включена в DOM. Для этого script нужно переместить ниже целевого элемента.

Время до самого первого отрендеренного контента

Браузеры отображают стилизованную DOM загруженного HTML-документа. Браузер не может ничего отображать, пока не будет построена модель DOM или ее часть. Выше в head.html мы видели, что построение DOM приостанавливается при выполнении сценария. Если script помещается в head, а загрузка, анализ и выполнение скрипта занимают много времени, пользователи могут быть недовольны, потому что им придется слишком долго ждать, пока на экране не появится первый контент.

Напротив, если скрипт загружается только после синтаксического анализа CSS и HTML, браузер может быстро отобразить пользователю некоторый, возможно частичный контент, а затем начать выполнение скрипта.

Обратите внимание, я написал, что браузер может отображать некоторый контент. Мне кажется, что браузер ничего не будет отображать до завершения выполнения скрипта, если не будет выполнено одно из следующих двух условий:

  • Скрипт загружается из сети, а не из кеша браузера. Это происходит, если страница загружается впервые или если в DevTools отключен кеш браузера.
  • HTML-код перед тегом script достаточно велик. Чем крупнее HTML, тем больше вероятность того, что браузер что-то отобразит перед выполнением скрипта. В моих попытках я не мог воспроизводимо видеть содержимое HTML, если его размер был меньше 90 КБ.

Таким образом, чтобы воспроизводимо продемонстрировать преимущество тегов сценария терминала, я буду использовать файл slowScriptAtBodyEndHugeBody.html, заполненный тегами HTML. Файл содержит 5000 div, которые отображаются в виде пронумерованных строк. Размер файла около 90 КБ.

<!-- slowScriptAtBodyEndHugeBody.html  -->
<html>
<body>
    <div id="root">LOADING</div>
    <div>
        <div>row0</div>
        ...
        <div>row4999</div>
    </div>
    <script src="js/slowBundle.js"></script>
</body>
</html>

slowScriptAtBodyEndHugeBody.html загружает slowBundle.js, который имитирует длительное время синтаксического анализа и выполнения типичного пакета:

//slowBundle.js
document.getElementById('root').replaceChildren("DONE");
const start = Date.now();
while (Date.now() - start < 2000) {
}

Когда я открываю slowScriptAtBodyEndHugeBody.html в браузере, я сразу вижу исходное содержимое LOADING в кодировке <div id="root">LOADING</div>.

Затем браузер выполняет slowBundle.js, который заменяет LOADING на DONE.

Изменение в DOM будет отображено примерно через 2 секунды, то есть когда выполнение скрипта будет завершено. Обратите внимание: вопреки распространенному ложному мнению, изменения в DOM не могут быть отображены до тех пор, пока текущая задача не будет завершена.

Теперь я удаляю весь балласт divs из slowScriptAtBodyEndHugeBody.html:

<!-- slowScriptAtBodyEndTinyBody.html.html -->
<html>
<body>
    <div id="root">LOADING</div>
    <script src="js/slowBundle.js"></script>
</body>
</html>

Я вижу исходное содержимое ЗАГРУЗКА один раз, когда впервые открываю в браузере крошечный slowScriptAtBodyEndTinyBody.html. Но я не вижу ЗАГРУЗКА, когда перезагружаю страницу. При перезагрузке страницы браузер не отправляет сетевой запрос, вместо этого он повторно использует slowBundle.js из кеша диска или памяти. Сколько бы раз я ни перезагружал страницу, в лучшем случае я вижу пустую вкладку браузера. Если я добавлю две тысячи дополнительных HTML-тегов в HTML-файл, вероятность увидеть ЗАГРУЗКА возрастет.

Если я отключу кеш браузера, ЗАГРУЗКА будет отображаться при каждой перезагрузке. Браузер успевает нарисовать, когда он ожидает прибытия slowBundle.js из Интернета.

Как совместить преимущества head и body end?

Или, другими словами, как сохранить тег сценария внутри head и обеспечить выполнение сценария только после создания модели DOM и визуализации некоторого содержимого?

Давайте оценим две очевидные возможности. Для этого мне сначала нужно упомянуть некоторые свойства скриптов.

Варианты времени выполнения скрипта

Есть два типа скриптов - классические не модули и модули.

Вы видели в head.html, что браузер приостанавливает синтаксический анализ HTML при обнаружении тега script.

Однако, если тег script включает атрибут async или defer, браузер продолжит синтаксический анализ HTML при загрузке скрипта.

Загруженный сценарий async выполняется сразу после загрузки, возможно, до того, как DOM будет завершена. AMD, очень удобная модульная система, предшествующая родным модулям JavaScript, основана на async скриптах. Кроме того, я не знаю каких-либо распространенных вариантов использования async скриптов.

Загруженный сценарий defer выполняется после построения DOM. На этом этапе document.readyState равно interactive. Когда выполняются все отложенные скрипты, запускается событие DOMContentLoaded.

Атрибут defer не используется с модулями JavaScript, потому что они по умолчанию отложены. Модули со всеми зависимостями загружаются, пока браузер обрабатывает HTML, и выполняются, когда DOM готова.

атрибуты defer или type = ”module” гарантируют готовность DOM

Чтобы продемонстрировать это, я повторно использую первый пример head.html, который выше не прошел. Я добавляю атрибут defer к его script:

<!-- headDefer.html -->
<html>
<head>
    <script src="js/bundle.js" defer></script>
</head>
<body>
    <div id="root">LOADING</div>
</body>
</html>

И откройте в браузере новый файл headDefer.html. bundle.js успешно добавляет новое содержимое DONE в DOM.

Затем я снова использую неудачный head.html, но на этот раз добавляю type="module" к script:

<!-- headModule.html -->
<html>
<head>
    <script src="js/bundle.js" type="module"></script>
</head>
<body>
    <div id="root">LOADING</div>
</body>
</html>

Когда headModule.html открывается в браузере, DONE успешно вставляется в DOM.

Видите ли, нет необходимости размещать теги сценария в конце тела только для того, чтобы убедиться, что сценарий выполняется после того, как DOM будет готов к манипулированию.

Давайте теперь посмотрим, что происходит со временем до первого рендеринга в отложенных скриптах.

Время до первого рендера в отложенных скриптах

Напомним, что в slowScriptAtBodyEndHugeBody.html требовалось около 5000 div, чтобы браузер отображал содержимое HTML перед выполнением долго выполняющегося скрипта slowBundle.js.

Чтобы проверить, как атрибут defer влияет на способность браузера отображать некоторый контент перед выполнением длительного скрипта, я добавил defer к тегу script в slowScriptAtBodyEndHugeBody.html и переместил script в head.

<!-- slowDeferScriptInHeadHugeBody.html  -->
<html>
<head>
    <script src="js/slowBundle.js" defer></script>
</head>
<body>
    <div id="root">LOADING</div>
    <div>
        <div>row0</div>
        ...
        <div>row4999</div>
    </div>
</body>
</html>

Я также попытался удалить все или некоторые из 5000 строк div. Как и странице с классическим скриптом, slowDeferScriptInHeadHugeBody.html требуется ~ 5000 для воспроизводимого отображения LOADING при каждой перезагрузке страницы. Таким образом, отложенные сценарии, похоже, не отличаются от классических сценариев, позволяя браузеру отображать некоторый исходный контент.

Изменение типа скрипта на модуль, похоже, не помогает. Как и в случае с другими типами скриптов, при небольшом дополнительном HTML-содержимом я вижу ЗАГРУЗКА только тогда, когда slowBundle.js загружается из сети, а не из кеша браузера. Вы можете попробовать перезагрузить slowModuleInHeadTinyBody.html.

<!-- slowModuleInHeadTinyBody.html -->
<html>
<head>
    <script src="js/slowBundle.js" type="module"></script>
</head>
<body>
    <div id="root">LOADING</div>
</body>
</html>

Сообщение начинает воспроизводимо раскрашиваться, когда я включаю 5000 divs slowModuleInHeadHugeBody.html.

Так куда же поставить тег скрипта?

Таким образом, кажется, что, независимо от типа сценария, отображение HTML воспроизводимо блокируется, когда сценарий загружается из кеша браузера. Но могут быть некоторые тонкости, которые я не затронул в этом посте.

Но если вы хотите, чтобы сценарий выполнялся только после создания DOM, и не хотите добавлять небольшой, но необязательный код для DOMContentLoaded слушателей событий, поместите тег модуля script в head. Цель head - содержать не отображаемые теги. Также более естественно держать постоянный код, такой как таблицы стилей и скрипты, вверху.

Пример кода можно скачать с GitHub и запустить в браузере. Будет еще проще, если вы откроете все примеры страниц с https://placeforscript.onrender.com/.