Skip to content

Всё для WordPress, веб-разработки — и не только

⚡ Как загрузить внешний JavaScript без блокировки страницы

⚡ Как загрузить внешний JavaScript без блокировки страницы

Браузер, встретив <script> без атрибутов, бросает всё. Рендеринг страницы встаёт намертво, пока скрипт не загрузится и не выполнится. На медленном 4G это 2-3 секунды белого экрана.

Пользователь за это время уже ушёл к конкурентам. Core Web Vitals фиксируют провал LCP, Google опускает страницу в выдаче, а вы теряете трафик и конверсию. Между тем проблема решается тремя строчками, если знать, куда смотреть.

Ниже, рабочий способ загрузить внешний JavaScript без блокировки. От классического подхода с двумя файлами до современных async/defer и динамического import(). С проверенным кодом, который можно копировать и вставлять.

💡 Быстрый обзор:

  • Осознайте проблему: как обычный <script> блокирует парсинг HTML и убивает скорость загрузки
  • Освойте классический подход: сверхлёгкий загрузчик (≤300 байт) динамически подтягивает основной JS-файл
  • Изучите нативные атрибуты async и defer: когда и какой использовать
  • Разберите динамический import() для загрузки модулей по требованию
  • Выберите стратегию под свой проект с помощью сравнительной таблицы

Почему JavaScript блокирует рендеринг

Когда парсер HTML доходит до <script src="app.js">, он делает ровно три вещи: прекращает разбор документа, скачивает файл, выполняет его. Только после этого продолжает строить DOM.

Причина архитектурная. Скрипт может содержать document.write(), который меняет HTML на лету. Браузер не знает заранее, есть ли там такой вызов, поэтому перестраховывается и ждёт полной загрузки и исполнения. Результат: даже лёгкий скрипт на 5 КБ добавляет сотни миллисекунд к First Contentful Paint за счёт одного лишь сетевого round-trip.

Проблема не нова. Ещё в 2009 году Николас Закас описал технику динамической загрузки JavaScript без блокировки, она и сегодня работает, хоть и с поправкой на современные API. А с приходом async, defer и ES-модулей у разработчика появился целый набор инструментов. Разберём каждый.

Классический подход: два файла и динамическая загрузка

Идея проста. Вместо того чтобы складывать весь JS в один файл и вешать его на страницу через <script src="...">, вы разделяете код на две части:

  • Крошечный загрузчик (200-300 байт после сжатия)
  • Основной файл с логикой приложения

Загрузчик вставляется инлайн внизу страницы, прямо перед </body>. Он создаёт <script> программно и добавляет его в DOM, такой тег уже не блокирует парсинг, потому что появляется вне основного потока документа. Как только основной файл загрузился, выполняется инициализация.

Современная версия функции на чистом JS без обратной совместимости с IE:

1function loadScript(url) {
2 return new Promise((resolve, reject) => {
3 const script = document.createElement('script');
4 script.src = url;
5 script.onload = resolve;
6 script.onerror = reject;
7 document.head.appendChild(script);
8 });
9}

Девять строк. Никаких проверок readyState, веток для старых IE, колбэков в стиле «pyramid of doom». Просто функция, возвращающая Promise, её удобно комбинировать с async/await.

Подключение на странице выглядит так (код внизу, перед закрывающим </body>):

1<script>
2 function loadScript(url) {
3 return new Promise((resolve, reject) => {
4 const script = document.createElement('script');
5 script.src = url;
6 script.onload = resolve;
7 script.onerror = reject;
8 document.head.appendChild(script);
9 });
10 }
11
12 loadScript('/js/app.js').then(() => {
13 // Инициализация после загрузки основного файла
14 App.init();
15 });
16</script>

Первый скрипт (инлайн), это загрузчик. Он парсится и выполняется мгновенно, потому что в нём меньше 300 байт. Второй скрипт (app.js) загружается асинхронно и не мешает рендерингу.

Что делать, если файлов больше двух? Объединяйте их при сборке. Современные бандлеры вроде Vite и Webpack делают это автоматически: tree-shaking, code splitting, минификация в один проход. Ручное управление порядком загрузки десятка файлов, путь к гонкам и ошибкам.

async и defer: нативная разблокировка

HTML5 подарил нам два атрибута, которые решают проблему без единой строчки JavaScript:

1<script async src="analytics.js"></script>
2<script defer src="app.js"></script>

Оба загружают файл параллельно с парсингом HTML. Разница в моменте выполнения:

Атрибут

Загрузка

Выполнение

Порядок

async

Параллельно с парсингом

Сразу после загрузки

Не гарантирован

defer

Параллельно с парсингом

После полного разбора HTML

Гарантирован (как в документе)

Правило большого пальца:

  • async для независимых скриптов: аналитика, реклама, счётчики. Им не нужен DOM, им не важен порядок.
  • defer для основного приложения: манипуляции с DOM, инициализация интерфейса. Скрипт дожидается готовности страницы и выполняется в правильной последовательности.

На практике связка проста: ставите defer на все скрипты в <head>, и они ведут себя так, будто находятся внизу страницы, но загружаются раньше. Никакой магии, просто браузерный планировщик.

И да, можно комбинировать с динамической загрузкой. Например, ядро приложения грузите через <script defer>, а тяжёлые виджеты подключаете динамически через loadScript() только тогда, когда они реально понадобились.

Динамический import(): модули по требованию

ES2020 принёс динамический import(), нативный способ загрузить модуль асинхронно, без бандлера и без дополнительных функций:

1// Загружается только когда пользователь кликнул
2button.addEventListener('click', async () => {
3 const { heavyChart } = await import('./chart-component.js');
4 heavyChart.render();
5});

Вызов import() возвращает Promise. Модуль загружается в фоне, парсинг не блокируется, страница остаётся отзывчивой. Код внутри модуля выполняется в строгом режиме и в собственной области видимости, конфликты имён исключены.

Это идеальный инструмент для code splitting без бандлера. Тяжёлые компоненты (графики, редакторы, карты) выносятся в отдельные файлы и загружаются при первом взаимодействии. Пользователь, который никогда не открывает график, не платит за него трафиком и временем загрузки.

Сравнение подходов

У каждого способа, своя ниша. Чтобы не гадать, свели характеристики в таблицу:

Подход

Блокирует рендеринг

Нужен JS

Порядок выполнения

Для каких скриптов

<script src>

Да

Нет

Гарантирован

Не используется без необходимости

Динамический loadScript

Нет

Да

Через цепочку .then()

Загрузка по условию, тяжёлые зависимости

<script async>

Нет

Нет

Не гарантирован

Аналитика, реклама, счётчики

<script defer>

Нет

Нет

Гарантирован

Основное приложение, манипуляции DOM

import()

Нет

Да (ES модуль)

Через await

Code splitting, компоненты по требованию

Главный вывод: не зацикливайтесь на одном методе. Типичный production-сетап использует два-три одновременно: defer для ядра, async для метрик, динамический import() для тяжёлых компонентов.

Короткое демонстрационное видео по теме, разбор async и defer с визуализацией таймлайна загрузки:

⁉️🤔 Частые вопросы

Чем async отличается от defer на практике?

Оба не блокируют парсинг при загрузке. Но async выполняет скрипт немедленно после загрузки файла, даже если HTML ещё не до конца разобран, и без гарантии порядка. defer всегда ждёт полной готовности DOM и сохраняет последовательность скриптов как в HTML. Для основного кода приложения берите defer, для изолированных счётчиков, async.

Можно ли совмещать динамическую загрузку с defer?

Да, это распространённый сценарий. Ядро приложения загружается с defer в <head>, оно инициализирует интерфейс. Тяжёлые или редко используемые модули подтягиваются через динамический loadScript() или import() при взаимодействии пользователя. Так вы получаете и быстрый старт, и отложенную загрузку второстепенного кода.

Что использовать для WordPress-сайта?

WordPress автоматически добавляет defer или async через wp_enqueue_script(), если передать соответствующий аргумент в пятом параметре: wp_enqueue_script('my-script', $url, [], null, ['strategy' => 'defer']). Для сторонних скриптов (Google Analytics, реклама) проще всего, атрибут async. Сложные интерактивные блоки (калькуляторы, фильтры) выносите в динамический import() внутри кастомного модуля.

Работает ли это со сторонними скриптами вроде Google Analytics?

Да. Тег GA4 gtag.js по умолчанию загружается с async, поэтому он не блокирует страницу. Для других сторонних сервисов проверяйте документацию: если скрипт не требует готового DOM и не зависит от порядка загрузки, смело ставьте async. Если ему нужен DOM, используйте defer или динамическую загрузку с колбэком.

Как проверить, что скрипт действительно не блокирует страницу?

Откройте Chrome DevTools → Performance → Record → перезагрузите страницу. На таймлайне ищите жёлтые блоки «Scripting» до зелёного «First Contentful Paint». Если скрипт загружен с defer или async, его выполнение будет после FCP. Lighthouse в режиме «Performance» покажет рекомендацию «Remove render-blocking resources», скриптов в этом списке быть не должно.

Стоит ли менять подход к загрузке прямо сейчас

Если ваши скрипты до сих пор висят в <head> без атрибутов, вы теряете позиции в выдаче и раздражаете пользователей. Это не гипотеза, Lighthouse и PageSpeed Insights показывают проблему красным в первых строках отчёта.

Быстрый старт: пройдитесь по <script> в шаблоне, допишите defer для основного кода и async для метрик. Займёт пять минут, а LCP может улучшиться на 300-500 мс. Дальше, динамический import() для тяжёлых компонентов, когда дойдут руки до рефакторинга.

Оставьте один способ загрузки, <script defer> в <head>, и страница загрузится без видимых задержек. Проверьте на своём проекте сегодня.