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>, і сторінка завантажиться без видимих затримок. Перевірте на своєму проєкті сьогодні.