
⚡ Как загрузить внешний 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:
1 function 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. Разница в моменте выполнения:
Атрибут | Загрузка | Выполнение | Порядок |
|---|---|---|---|
| Параллельно с парсингом | Сразу после загрузки | Не гарантирован |
| Параллельно с парсингом | После полного разбора HTML | Гарантирован (как в документе) |
Правило большого пальца:
asyncдля независимых скриптов: аналитика, реклама, счётчики. Им не нужен DOM, им не важен порядок.deferдля основного приложения: манипуляции с DOM, инициализация интерфейса. Скрипт дожидается готовности страницы и выполняется в правильной последовательности.
На практике связка проста: ставите defer на все скрипты в <head>, и они ведут себя так, будто находятся внизу страницы, но загружаются раньше. Никакой магии, просто браузерный планировщик.
И да, можно комбинировать с динамической загрузкой. Например, ядро приложения грузите через <script defer>, а тяжёлые виджеты подключаете динамически через loadScript() только тогда, когда они реально понадобились.
Динамический import(): модули по требованию
ES2020 принёс динамический import(), нативный способ загрузить модуль асинхронно, без бандлера и без дополнительных функций:
1 // Загружается только когда пользователь кликнул 2 button.addEventListener('click', async () => { 3 const { heavyChart } = await import('./chart-component.js'); 4 heavyChart.render(); 5 });
Вызов import() возвращает Promise. Модуль загружается в фоне, парсинг не блокируется, страница остаётся отзывчивой. Код внутри модуля выполняется в строгом режиме и в собственной области видимости, конфликты имён исключены.
Это идеальный инструмент для code splitting без бандлера. Тяжёлые компоненты (графики, редакторы, карты) выносятся в отдельные файлы и загружаются при первом взаимодействии. Пользователь, который никогда не открывает график, не платит за него трафиком и временем загрузки.
Сравнение подходов
У каждого способа, своя ниша. Чтобы не гадать, свели характеристики в таблицу:
Подход | Блокирует рендеринг | Нужен JS | Порядок выполнения | Для каких скриптов |
|---|---|---|---|---|
| Да | Нет | Гарантирован | Не используется без необходимости |
Динамический | Нет | Да | Через цепочку | Загрузка по условию, тяжёлые зависимости |
| Нет | Нет | Не гарантирован | Аналитика, реклама, счётчики |
| Нет | Нет | Гарантирован | Основное приложение, манипуляции DOM |
| Нет | Да (ES модуль) | Через | 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>, и страница загрузится без видимых задержек. Проверьте на своём проекте сегодня.



