Skriptauswertung und lange Aufgaben

Beim Laden von Skripts benötigt der Browser einige Zeit, um sie vor der Ausführung auszuwerten. Dies kann zu langen Aufgaben führen. Hier erfahren Sie, wie die Skriptbewertung funktioniert und was Sie tun können, um zu verhindern, dass die Skriptauswertung beim Seitenaufbau zu langwierigen Aufgaben führt.

Wenn es um die Optimierung von Interaction to Next Paint (INP) geht, sollten Sie die Interaktionen selbst optimieren. Im Leitfaden zum Optimieren langer Aufgaben werden beispielsweise Techniken wie das Ergebnis mit setTimeout und isInputPending behandelt. Diese Techniken sind vorteilhaft, da sie dem Hauptthread etwas Raum geben, indem lange Aufgaben vermieden werden. So können mehr Interaktionen und andere Aktivitäten früher ausgeführt werden, anstatt auf eine einzige lange Aufgabe warten zu müssen.

Aber was ist mit den langen Aufgaben, die durch das Laden der Skripts selbst entstehen? Diese Aktionen können Nutzerinteraktionen beeinträchtigen und die INP während des Ladevorgangs beeinflussen. In diesem Leitfaden erfahren Sie, wie Browser Aufgaben handhaben, die bei der Skriptauswertung gestartet wurden. Außerdem erfahren Sie, wie Sie die Skriptbewertung aufteilen können, damit Ihr Hauptthread schneller auf Nutzereingaben reagiert, während die Seite geladen wird.

Was ist eine Skriptauswertung?

Wenn Sie ein Profil für eine Anwendung erstellt haben, die sehr viel JavaScript ausgibt, sind Ihnen vielleicht langwierige Aufgaben aufgefallen, bei denen der Täter mit Script auswerten gekennzeichnet ist.

Funktionsweise der Skriptbewertung, wie im Leistungsprofiler der Chrome-Entwicklertools dargestellt. Die Arbeit verursacht beim Start eine lange Aufgabe, wodurch der Hauptthread nicht mehr auf Nutzerinteraktionen reagieren kann.
Die Skriptbewertung funktioniert wie im Leistungsprofiler in den Chrome-Entwicklertools dargestellt. In diesem Fall reicht die Arbeit aus, um eine lange Aufgabe zu verursachen, die den Hauptthread daran hindert, andere Aufgaben zu übernehmen, einschließlich Aufgaben, die Interaktionen der Nutzenden fördern.

Die Skriptauswertung ist ein notwendiger Bestandteil der Ausführung von JavaScript im Browser, da JavaScript genau vor der Ausführung kompiliert wird. Bei der Auswertung eines Scripts wird es zuerst auf Fehler geparst. Wenn der Parser keine Fehler findet, wird das Skript in bytecode kompiliert und kann dann mit der Ausführung fortfahren.

Das ist zwar notwendig, kann aber problematisch sein, da Nutzer versuchen können, kurz nach dem Rendern mit einer Seite zu interagieren. Nur weil eine Seite gerendert wurde, bedeutet das nicht, dass sie vollständig geladen wurde. Interaktionen, die während des Ladevorgangs stattfinden, können sich verzögern, weil die Seite mit dem Auswerten von Skripts beschäftigt ist. Es kann zwar nicht garantiert werden, dass die gewünschte Interaktion zu diesem Zeitpunkt stattfinden kann, da ein dafür verantwortliches Skript möglicherweise noch nicht geladen wurde. Es können jedoch Interaktionen auftreten, die von JavaScript abhängig sind, das bereit ist, oder die Interaktivität hängt gar nicht von JavaScript ab.

Die Beziehung zwischen Skripts und den Aufgaben, die sie bewerten

Wie Aufgaben für die Skriptauswertung gestartet werden, hängt davon ab, ob das Skript, das Sie laden, über ein reguläres <script>-Element geladen wird oder ob ein Skript ein Modul ist, das mit dem type=module geladen wird. Da Browser dazu neigen, Dinge unterschiedlich zu behandeln, wird die Art und Weise, wie die großen Browser-Engines mit der Skriptauswertung umgehen, erörtert, wo das Verhalten der Skriptauswertung zwischen ihnen variiert.

Skripts mit dem Element <script> werden geladen

Die Anzahl der Aufgaben, die zur Bewertung von Skripts gesendet werden, steht im Allgemeinen in direktem Zusammenhang mit der Anzahl der <script>-Elemente auf einer Seite. Jedes <script>-Element löst eine Aufgabe aus, um das angeforderte Skript auszuwerten, damit es geparst, kompiliert und ausgeführt werden kann. Das trifft auf Chromium-basierte Browser, Safari und Firefox zu.

Warum ist das relevant? Angenommen, Sie verwenden einen Bundler, um Ihre Produktionsskripts zu verwalten, und haben ihn so konfiguriert, dass alles, was auf Ihrer Seite erforderlich ist, in einem einzigen Skript gebündelt wird. Ist das bei Ihrer Website der Fall, wird eine einzelne Aufgabe zur Auswertung des Skripts gesendet. Ist das etwas Schlechtes? Nicht unbedingt – es sei denn, das Skript ist riesig.

Sie können die Skriptbewertung aufteilen, indem Sie das Laden großer JavaScript-Chunks vermeiden und mehr einzelne, kleinere Scripts mit zusätzlichen <script>-Elementen laden.

Sie sollten immer versuchen, beim Seitenaufbau so wenig JavaScript wie möglich zu laden. Durch das Aufteilen Ihrer Skripts wird jedoch sichergestellt, dass Sie statt einer großen Aufgabe, die den Hauptthread blockieren könnte, eine größere Anzahl kleinerer Aufgaben haben, die den Hauptthread gar nicht oder zumindest weniger als den Anfang blockieren.

Mehrere Aufgaben mit Skriptbewertung, die im Leistungsprofiler der Chrome-Entwicklertools visualisiert werden Da mehrere kleinere Skripts statt weniger größeren Skripts geladen werden, wird die Wahrscheinlichkeit geringer, dass aus den Aufgaben lange Aufgaben werden, sodass der Hauptthread schneller auf Nutzereingaben reagieren kann.
Es wurden mehrere Aufgaben erstellt, um Skripts auszuwerten, wenn im HTML-Code der Seite mehrere <script>-Elemente vorhanden sind. Diese Methode ist besser, als ein großes Skript-Bundle an Nutzer zu senden, da dadurch der Hauptthread eher blockiert wird.

Sie können sich die Aufteilung von Aufgaben zur Skriptauswertung ähnlich wie die Leistung bei Ereignis-Callbacks, die während einer Interaktion ausgeführt werden, ähneln. Bei der Skriptauswertung wird der geladene JavaScript-Code jedoch in mehrere kleinere Skripts aufgeteilt, anstatt den Hauptthread mit größerer Wahrscheinlichkeit zu blockieren.

Skripts mit dem Element <script> und dem Attribut type=module werden geladen

Es ist jetzt möglich, ES-Module mit dem Attribut type=module für das <script>-Element nativ im Browser zu laden. Dieser Ansatz für das Laden von Skripts bietet einige Vorteile für Entwickler, sodass sie z. B. keinen Code für die Produktion transformieren müssen, insbesondere wenn er in Kombination mit Importkarten verwendet wird. Wenn die Skripts jedoch auf diese Weise geladen werden, werden Aufgaben geplant, die sich von Browser zu Browser unterscheiden.

Chromium-basierte Browser

In Browsern wie Chrome – oder solchen, die daraus abgeleitet sind – werden beim Laden von ES-Modulen mit dem Attribut type=module andere Aufgaben ausgeführt als normalerweise, wenn type=module nicht verwendet wird. Beispielsweise wird für jedes Modulskript eine Aufgabe ausgeführt, die eine Aktivität mit der Bezeichnung Modul kompilieren umfasst.

Die Modulkompilierung kann in mehreren Aufgaben ausgeführt werden, wie in den Chrome-Entwicklertools dargestellt.
Ladeverhalten des Moduls in Chromium-basierten Browsern. Jedes Modulskript generiert einen Compile-Modulaufruf, um den Inhalt vor der Auswertung zu kompilieren.

Sobald die Module kompiliert wurden, löst jeder Code, der anschließend darin ausgeführt wird, eine Aktivität namens Bewerten des Moduls aus.

Echtzeitauswertung eines Moduls, wie im Leistungsbereich der Chrome-Entwicklertools dargestellt.
Wenn Code in einem Modul ausgeführt wird, wird dieses Modul gerade rechtzeitig ausgewertet.

Der Effekt ist hier – zumindest in Chrome und den zugehörigen Browsern –, dass die Kompilierungsschritte bei Verwendung von ES-Modulen aufgeteilt werden. Das ist ein klarer Vorteil bei der Verwaltung langer Aufgaben. Die daraus resultierenden Modulbewertungsergebnisse führen jedoch immer noch dazu, dass Ihnen unvermeidliche Kosten entstehen. Auch wenn Sie möglichst wenig JavaScript zur Verfügung stellen sollten, bietet die Verwendung von ES-Modulen – unabhängig vom Browser – folgende Vorteile:

  • Der gesamte Modulcode wird automatisch im strikten Modus ausgeführt. Dadurch sind mögliche Optimierungen durch JavaScript-Engines möglich, die sonst nicht in einem nicht strikten Kontext durchgeführt werden könnten.
  • Mit type=module geladene Skripts werden standardmäßig so behandelt, als wären sie verzögert. Sie können das Attribut async in Skripts verwenden, die mit type=module geladen werden, um dieses Verhalten zu ändern.

Safari und Firefox

Beim Laden von Modulen in Safari und Firefox wird jedes Modul in einer separaten Aufgabe ausgewertet. Das bedeutet, dass Sie theoretisch ein einzelnes Modul der obersten Ebene, das nur aus statischen import-Anweisungen besteht, in andere Module laden kann. Für jedes geladene Modul wird eine separate Netzwerkanfrage und Aufgabe zu ihrer Auswertung gesendet.

Skripts mit dynamischem import() werden geladen

Dynamische import() ist eine weitere Methode zum Laden von Skripts. Im Gegensatz zu statischen import-Anweisungen, die ganz oben in einem ES-Modul stehen müssen, kann ein dynamischer import()-Aufruf an einer beliebigen Stelle im Skript vorkommen, um einen JavaScript-Block bei Bedarf zu laden. Diese Methode wird als Codeaufteilung bezeichnet.

Das dynamische import() bietet zwei Vorteile bei der Verbesserung von INP:

  1. Module, deren Laden später verzögert wird, reduzieren die zu diesem Zeitpunkt geladene Menge von JavaScript, um Konflikte des Hauptthreads während des Starts zu reduzieren. Dadurch wird der Hauptthread freigegeben und kann schneller auf Nutzerinteraktionen reagieren.
  2. Bei dynamischen import()-Aufrufen wird die Kompilierung und Auswertung jedes Moduls effektiv in eine eigene Aufgabe unterteilt. Ein dynamisches import(), das ein sehr großes Modul lädt, löst natürlich eine ziemlich umfangreiche Skriptauswertung aus. Dies kann die Fähigkeit des Hauptthreads beeinträchtigen, auf Nutzereingaben zu reagieren, wenn die Interaktion gleichzeitig mit dem dynamischen import()-Aufruf erfolgt. Daher ist es weiterhin sehr wichtig, so wenig JavaScript wie möglich zu laden.

Dynamische import()-Aufrufe verhalten sich in allen gängigen Browser-Engines ähnlich: Die resultierenden Aufgaben zur Skriptauswertung entsprechen der Anzahl der Module, die dynamisch importiert werden.

Skripts in einem Web Worker laden

Web Worker sind ein spezieller JavaScript-Anwendungsfall. Web Worker werden im Hauptthread registriert und der Code innerhalb des Workers wird dann in einem eigenen Thread ausgeführt. Dies ist in dem Sinne enorm vorteilhaft, dass der Code, der den Web Worker registriert, im Hauptthread ausgeführt wird, der Code innerhalb des Web Workers jedoch nicht. Dadurch wird die Überlastung des Hauptthreads reduziert und der Hauptthread kann besser auf Nutzerinteraktionen reagieren.

Web Worker reduzieren nicht nur die Arbeit des Hauptthreads, sondern können auch selbst externe Skripts laden, die im Worker-Kontext verwendet werden – entweder über importScripts- oder statische import-Anweisungen in Browsern, die Modul-Worker unterstützen. Das hat zur Folge, dass jedes von einem Web Worker angeforderte Skript außerhalb des Hauptthreads ausgewertet wird.

Vor- und Nachteile

Die Aufteilung Ihrer Skripts in separate, kleinere Dateien hilft, lange Aufgaben zu begrenzen, anstatt weniger, aber viel größere Dateien zu laden. Bei der Entscheidung, wie Skripts aufgeteilt werden sollen, müssen jedoch einige Dinge berücksichtigt werden.

Komprimierungseffizienz

Die Komprimierung ist ein Faktor bei der Aufteilung von Skripts. Wenn die Skripts kleiner sind, wird die Komprimierung etwas weniger effizient. Bei größeren Skripts profitieren Sie viel stärker von der Komprimierung. Eine höhere Komprimierungseffizienz trägt zwar dazu bei, die Ladezeiten für Skripts so kurz wie möglich zu halten, aber es ist eine Art Balanceakt, sicherzustellen, dass Skripts in genügend kleinere Blöcke aufgeteilt werden, um eine bessere Interaktivität beim Start zu ermöglichen.

Bundler sind ideale Tools, um die Ausgabegröße der Skripts zu verwalten, von denen Ihre Website abhängt:

  • Bei Fragen zu Webpack kann das SplitChunksPlugin-Plug-in helfen. In der SplitChunksPlugin-Dokumentation finden Sie Informationen zu Optionen, die Sie zur Verwaltung von Asset-Größen festlegen können.
  • Bei anderen Bundlern wie Rollup und esbuild können Sie die Größe von Scriptdateien mithilfe von dynamischen import()-Aufrufen in Ihrem Code verwalten. Diese Bundler – und Webpacks – teilen das dynamisch importierte Asset automatisch in eine eigene Datei auf und vermeiden so größere anfängliche Bundle-Größen.

Cache-Entwertung

Die Cache-Entwertung spielt eine wichtige Rolle bei der Ladegeschwindigkeit einer Seite bei wiederholten Besuchen. Wenn Sie große, monolithische Script-Bundles versenden, haben Sie beim Browser-Caching im Nachteil. Das liegt daran, dass das gesamte Bundle ungültig wird und noch einmal heruntergeladen werden muss, wenn Sie Ihren Erstanbietercode aktualisieren – entweder durch Aktualisieren von Paketen oder Fehlerkorrekturen beim Versand.

Wenn Sie Ihre Skripts aufteilen, teilen Sie nicht nur die Skriptbewertung auf kleinere Aufgaben auf, sondern erhöhen auch die Wahrscheinlichkeit, dass wiederkehrende Besucher mehr Skripts aus dem Browser-Cache statt aus dem Netzwerk abrufen. Dadurch wird die Seite insgesamt schneller geladen.

Verschachtelte Module und Ladeleistung

Wenn Sie ES-Module in der Produktion versenden und sie mit dem Attribut type=module laden, müssen Sie wissen, wie sich die Modulverschachtelung auf die Startzeit auswirken kann. Bei einer Modulverschachtelung importiert ein ES-Modul ein weiteres ES-Modul statisch, das wiederum ein weiteres ES-Modul statisch importiert:

// a.js
import {b} from './b.js';

// b.js
import {c} from './c.js';

Wenn Ihre ES-Module nicht gebündelt sind, führt der vorherige Code zu einer Netzwerkanfragekette: Wenn a.js von einem <script>-Element angefordert wird, wird eine weitere Netzwerkanfrage für b.js gesendet, die dann eine weitere Anfrage für c.js umfasst. Eine Möglichkeit, dies zu vermeiden, ist die Verwendung eines Bundles. Stellen Sie jedoch sicher, dass Sie Ihren Bundler so konfigurieren, dass Skripts aufgeteilt werden.

Wenn Sie keinen Bundler verwenden möchten, können Sie verschachtelte Modulaufrufe auch mit dem Ressourcenhinweis modulepreload umgehen. Damit werden ES-Module im Voraus geladen, um Netzwerkanfrageketten zu vermeiden.

Fazit

Es ist keine leichte Aufgabe, die Auswertung von Skripts im Browser zu optimieren. Der Ansatz hängt von den Anforderungen und Einschränkungen Ihrer Website ab. Durch die Aufteilung von Skripts verteilen Sie die Arbeit der Skriptauswertung jedoch auf zahlreiche kleinere Aufgaben. Dadurch kann der Hauptthread Nutzerinteraktionen effizienter verarbeiten, anstatt den Hauptthread zu blockieren.

Hier noch einmal einige Dinge, die Sie tun können, um große Aufgaben zur Skriptbewertung aufzuteilen:

  • Wenn Sie Scripts mit dem <script>-Element ohne das type=module-Attribut laden, sollten Sie sehr große Scripts vermeiden, da sie ressourcenintensive Aufgaben zur Skriptbewertung starten, die den Hauptthread blockieren. Verteilen Sie Ihre Skripts auf mehr <script>-Elemente, um diese Arbeit aufzuteilen.
  • Wenn Sie das Attribut type=module verwenden, um ES-Module nativ im Browser zu laden, werden für jedes einzelne Modulskript einzelne Aufgaben zur Bewertung gestartet.
  • Reduzieren Sie die Größe der ersten Bundles mit dynamischen import()-Aufrufen. Dies funktioniert auch in Bundlern, da Bundler jedes dynamisch importierte Modul als „Splitpoint“ behandeln, was dazu führt, dass für jedes dynamisch importierte Modul ein separates Skript generiert wird.
  • Abwägen Sie Kompromisse wie Komprimierungseffizienz und Cache-Entwertung ab. Größere Scripts werden besser komprimiert, erfordern aber mit größerer Wahrscheinlichkeit teurere Skriptauswertung bei weniger Aufgaben und führen zur Entwertung des Browsercache, was insgesamt zu einer geringeren Caching-Effizienz führt.
  • Wenn ES-Module nativ ohne Bündelung verwendet werden, verwenden Sie den Ressourcenhinweis modulepreload, um das Laden der Module beim Start zu optimieren.
  • Versende wie immer so wenig JavaScript-Code wie möglich.

Es ist mit Sicherheit ein Balanceakt. Aber wenn Sie Scripts aufteilen und die anfängliche Nutzdaten über ein dynamisches import() reduzieren, können Sie eine bessere Startleistung erzielen und Nutzerinteraktionen während dieser entscheidenden Startphase besser berücksichtigen. So können Sie beim INP-Messwert besser abschneiden und für eine bessere Nutzererfahrung sorgen.

Hero-Image aus Unsplash von Markus Spiske