Skip to content

Wszystko o WordPressie, tworzeniu stron — i nie tylko

⚡ Jak załadować zewnętrzny JavaScript bez blokowania strony

⚡ Jak załadować zewnętrzny JavaScript bez blokowania strony

Przeglądarka, napotykając <script> bez atrybutów, rzuca wszystko. Renderowanie strony zatrzymuje się całkowicie, dopóki skrypt nie zostanie załadowany i wykonany. Na wolnym 4G to 2-3 sekundy białego ekranu.

Użytkownik w tym czasie zdążył już przejść do konkurencji. Core Web Vitals odnotowują spadek LCP, Google obniża stronę w wynikach wyszukiwania, a Pan traci ruch i konwersję. Tymczasem problem rozwiązuje się trzema linijkami, jeśli wie Pan, gdzie patrzeć.

Poniżej przedstawiam działający sposób na załadowanie zewnętrznego JavaScript bez blokowania. Od klasycznego podejścia z dwoma plikami po nowoczesne async/defer i dynamiczny import(). Ze sprawdzonym kodem, który można skopiować i wkleić.

💡 Szybki przegląd:

  • Proszę uświadomić sobie problem: jak zwykły <script> blokuje parsowanie HTML i zabija szybkość ładowania
  • Proszę opanować klasyczne podejście: ultralekki loader (≤300 bajtów) dynamicznie pobiera główny plik JS
  • Proszę poznać natywne atrybuty async i defer: kiedy i którego używać
  • Proszę przeanalizować dynamiczny import() do ładowania modułów na żądanie
  • Proszę wybrać strategię dla swojego projektu za pomocą tabeli porównawczej

Dlaczego JavaScript blokuje renderowanie

Gdy parser HTML dociera do <script src="app.js">, robi dokładnie trzy rzeczy: wstrzymuje analizę dokumentu, ładuje plik, wykonuje go. Dopiero potem kontynuuje budowanie DOM.

Przyczyna jest architektoniczna. Skrypt może zawierać document.write(), który zmienia HTML w locie. Przeglądarka nie wie z góry, czy takie wywołanie tam jest, więc na wszelki wypadek czeka na pełne załadowanie i wykonanie. Rezultat: nawet lekki skrypt o wielkości 5 KB dodaje setki milisekund do First Contentful Paint z powodu samego sieciowego round-trip.

Problem nie jest nowy. Już w 2009 roku Nicholas Zakas opisał technikę dynamicznego ładowania JavaScript bez blokowania, która działa do dziś, choć z uwzględnieniem nowoczesnych API. A wraz z pojawieniem się async, defer i modułów ES, programista zyskał cały zestaw narzędzi. Przeanalizujmy każde z nich.

Klasyczne podejście: dwa pliki i dynamiczne ładowanie

Pomysł jest prosty. Zamiast składać cały JS w jeden plik i umieszczać go na stronie przez <script src="...">, dzieli Pan kod na dwie części:

  • Malutki loader (200-300 bajtów po kompresji)
  • Główny plik z logiką aplikacji

Loader jest wstawiany inline na dole strony, tuż przed </body>. Tworzy on <script> programowo i dodaje go do DOM; taki tag nie blokuje już parsowania, ponieważ pojawia się poza głównym strumieniem dokumentu. Gdy tylko główny plik się załaduje, następuje inicjalizacja.

Nowoczesna wersja funkcji w czystym JS bez kompatybilności wstecznej z 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}

Dziewięć linijek. Żadnych sprawdzeń readyState, gałęzi dla starych IE, callbacków w stylu „piramidy zagłady". Po prostu funkcja zwracająca Promise, którą wygodnie łączy się z async/await.

Podłączenie na stronie wygląda tak (kod na dole, przed zamykającym </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>

Pierwszy skrypt (inline) to loader. Jest parsowany i wykonywany natychmiast, ponieważ ma mniej niż 300 bajtów. Drugi skrypt (app.js) ładuje się asynchronicznie i nie przeszkadza w renderowaniu.

Co zrobić, jeśli plików jest więcej niż dwa? Proszę je łączyć podczas budowania. Nowoczesne bundlery, takie jak Vite i Webpack, robią to automatycznie: tree-shaking, code splitting, minifikacja w jednym przebiegu. Ręczne zarządzanie kolejnością ładowania kilkunastu plików to prosta droga do wyścigów i błędów.

Async i defer: natywne odblokowanie

HTML5 podarował nam dwa atrybuty, które rozwiązują problem bez ani jednej linijki JavaScript:

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

Oba ładują plik równolegle z parsowaniem HTML. Różnica tkwi w momencie wykonania:

Atrybut

Ładowanie

Wykonanie

Kolejność

async

Równolegle z parsowaniem

Natychmiast po załadowaniu

Nie gwarantowana

defer

Równolegle z parsowaniem

Po pełnym przetworzeniu HTML

Gwarantowana (jak w dokumencie)

Praktyczna reguła:

  • async dla niezależnych skryptów: analityka, reklamy, liczniki. Nie potrzebują DOM, nie jest dla nich ważna kolejność.
  • defer dla głównej aplikacji: manipulacje DOM, inicjalizacja interfejsu. Skrypt czeka na gotowość strony i wykonuje się we właściwej kolejności.

W praktyce połączenie jest proste: daje Pan defer na wszystkie skrypty w <head>, a zachowują się one tak, jakby były na dole strony, ale ładują się wcześniej. Żadnej magii, po prostu planista przeglądarki.

I tak, można łączyć z dynamicznym ładowaniem. Na przykład rdzeń aplikacji ładuje Pan przez <script defer>, a ciężkie widżety podłącza Pan dynamicznie przez loadScript() tylko wtedy, gdy są rzeczywiście potrzebne.

Dynamiczny import(): moduły na żądanie

ES2020 przyniósł dynamiczny import(), natywny sposób na załadowanie modułu asynchronicznie, bez bundlera i bez dodatkowych funkcji:

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

Wywołanie import() zwraca Promise. Moduł ładuje się w tle, parsowanie nie jest blokowane, strona pozostaje responsywna. Kod wewnątrz modułu jest wykonywany w trybie ścisłym i we własnym zakresie widoczności, konflikty nazw są wykluczone.

To idealne narzędzie do code splitting bez bundlera. Ciężkie komponenty (wykresy, edytory, mapy) są wynoszone do osobnych plików i ładowane przy pierwszej interakcji. Użytkownik, który nigdy nie otwiera wykresu, nie płaci za niego transferem danych i czasem ładowania.

Porównanie podejść

Każdy sposób ma swoją niszę. Aby nie zgadywać, zebraliśmy charakterystyki w tabeli:

Podejście

Blokuje renderowanie

Wymaga JS

Kolejność wykonania

Dla jakich skryptów

<script src>

Tak

Nie

Gwarantowana

Nie używa się bez konieczności

Dynamiczny loadScript

Nie

Tak

Przez łańcuch .then()

Ładowanie warunkowe, ciężkie zależności

<script async>

Nie

Nie

Nie gwarantowana

Analityka, reklamy, liczniki

<script defer>

Nie

Nie

Gwarantowana

Główna aplikacja, manipulacje DOM

import()

Nie

Tak (moduł ES)

Przez await

Code splitting, komponenty na żądanie

Główny wniosek: niech się Pan nie zamyka na jedną metodę. Typowy setup produkcyjny używa dwóch lub trzech jednocześnie: defer dla rdzenia, async dla metryk, dynamiczny import() dla ciężkich komponentów.

Krótkie wideo demonstracyjne na ten temat, omówienie async i defer z wizualizacją osi czasu ładowania:

⁉️🤔 Częste pytania

Czym async różni się od defer w praktyce?

Oba nie blokują parsowania podczas ładowania. Ale async wykonuje skrypt natychmiast po załadowaniu pliku, nawet jeśli HTML nie został jeszcze do końca przetworzony, i bez gwarancji kolejności. defer zawsze czeka na pełną gotowość DOM i zachowuje kolejność skryptów jak w HTML. Dla głównego kodu aplikacji proszę brać defer, dla izolowanych liczników async.

Czy można łączyć dynamiczne ładowanie z defer?

Tak, to powszechny scenariusz. Rdzeń aplikacji ładuje się z defer w <head>, inicjalizuje on interfejs. Ciężkie lub rzadko używane moduły są pobierane przez dynamiczny loadScript() lub import() przy interakcji użytkownika. W ten sposób uzyskuje Pan zarówno szybki start, jak i odroczone ładowanie drugorzędnego kodu.

Czego użyć dla strony na WordPress?

WordPress automatycznie dodaje defer lub async przez wp_enqueue_script(), jeśli przekaże Pan odpowiedni argument w piątym parametrze: wp_enqueue_script('my-script', $url, [], null, ['strategy' => 'defer']). Dla skryptów zewnętrznych (Google Analytics, reklamy) najprościej jest użyć atrybutu async. Złożone bloki interaktywne (kalkulatory, filtry) proszę wynosić do dynamicznego import() wewnątrz niestandardowego modułu.

Czy działa to ze skryptami zewnętrznymi, takimi jak Google Analytics?

Tak. Tag GA4 gtag.js domyślnie ładuje się z async, więc nie blokuje strony. Dla innych usług zewnętrznych proszę sprawdzać dokumentację: jeśli skrypt nie potrzebuje gotowego DOM i nie zależy od kolejności ładowania, śmiało może Pan ustawić async. Jeśli potrzebuje DOM, proszę użyć defer lub dynamicznego ładowania z callbackiem.

Jak sprawdzić, czy skrypt rzeczywiście nie blokuje strony?

Proszę otworzyć Chrome DevTools → Performance → Record → przeładować stronę. Na osi czasu proszę szukać żółtych bloków „Scripting" przed zielonym „First Contentful Paint". Jeśli skrypt jest załadowany z defer lub async, jego wykonanie będzie po FCP. Lighthouse w trybie „Performance" pokaże rekomendację „Remove render-blocking resources", skryptów na tej liście nie powinno być.

Czy warto zmienić podejście do ładowania już teraz

Jeśli Pana skrypty wciąż wiszą w <head> bez atrybutów, traci Pan pozycje w wynikach wyszukiwania i irytuje użytkowników. To nie hipoteza, Lighthouse i PageSpeed Insights pokazują problem na czerwono w pierwszych linijkach raportu.

Szybki start: proszę przejrzeć <script> w szablonie, dopisać defer dla głównego kodu i async dla metryk. Zajmie to pięć minut, a LCP może poprawić się o 300-500 ms. Następnie dynamiczny import() dla ciężkich komponentów, gdy znajdzie Pan czas na refaktoryzację.

Proszę zostawić jeden sposób ładowania, <script defer> w <head>, a strona załaduje się bez widocznych opóźnień. Proszę sprawdzić na swoim projekcie jeszcze dziś.