
⚡ 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
asyncidefer: 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:
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 }
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ść |
|---|---|---|---|
| Równolegle z parsowaniem | Natychmiast po załadowaniu | Nie gwarantowana |
| Równolegle z parsowaniem | Po pełnym przetworzeniu HTML | Gwarantowana (jak w dokumencie) |
Praktyczna reguła:
asyncdla niezależnych skryptów: analityka, reklamy, liczniki. Nie potrzebują DOM, nie jest dla nich ważna kolejność.deferdla 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 // Загружается только когда пользователь кликнул 2 button.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 |
|---|---|---|---|---|
| Tak | Nie | Gwarantowana | Nie używa się bez konieczności |
Dynamiczny | Nie | Tak | Przez łańcuch | Ładowanie warunkowe, ciężkie zależności |
| Nie | Nie | Nie gwarantowana | Analityka, reklamy, liczniki |
| Nie | Nie | Gwarantowana | Główna aplikacja, manipulacje DOM |
| Nie | Tak (moduł ES) | Przez | 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
asyncwykonuje skrypt natychmiast po załadowaniu pliku, nawet jeśli HTML nie został jeszcze do końca przetworzony, i bez gwarancji kolejności.deferzawsze 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ówasync.
Czy można łączyć dynamiczne ładowanie z defer?
Tak, to powszechny scenariusz. Rdzeń aplikacji ładuje się z
deferw<head>, inicjalizuje on interfejs. Ciężkie lub rzadko używane moduły są pobierane przez dynamicznyloadScript()lubimport()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
deferlubasyncprzezwp_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ć atrybutuasync. Złożone bloki interaktywne (kalkulatory, filtry) proszę wynosić do dynamicznegoimport()wewnątrz niestandardowego modułu.
Czy działa to ze skryptami zewnętrznymi, takimi jak Google Analytics?
Tak. Tag GA4
gtag.jsdomyślnie ładuje się zasync, 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ćdeferlub 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
deferlubasync, 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ś.



