Skriptauswertung und lange Aufgaben

Beim Laden von Skripten benötigt der Browser Zeit, um sie vor der Ausführung auszuwerten. Dies kann zu langen Aufgaben führen. Hier erfahren Sie, wie die Skriptauswertung funktioniert und was Sie tun können, um zu verhindern, dass sie während des Seitenaufbaus lange Aufgaben verursacht.

Wenn es um die Optimierung von Interaction to Next Paint (INP) geht, beziehen sich die meisten Empfehlungen auf die Optimierung von Interaktionen selbst. Im Leitfaden zum Optimieren langer Aufgaben werden beispielsweise Techniken wie das Yielding mit setTimeout behandelt. Diese Techniken sind nützlich, da sie dem Hauptthread etwas Spielraum geben, indem sie lange Aufgaben vermeiden. Dadurch können Interaktionen und andere Aktivitäten schneller ausgeführt werden, als wenn sie auf eine einzelne lange Aufgabe warten müssten.

Was ist aber mit den langen Aufgaben, die durch das Laden von Skripten entstehen? Diese Aufgaben können Nutzerinteraktionen beeinträchtigen und sich auf den INP einer Seite während des Ladevorgangs auswirken. In diesem Guide wird erläutert, wie Browser Aufgaben verarbeiten, die durch die Skriptauswertung ausgelöst werden. Außerdem wird untersucht, wie Sie die Skriptauswertung aufteilen können, damit Ihr Hauptthread während des Ladens der Seite besser auf Nutzereingaben reagieren kann.

Was ist die Skriptbewertung?

Wenn Sie ein Profil für eine Anwendung erstellt haben, die viel JavaScript enthält, haben Sie möglicherweise lange Aufgaben gesehen, bei denen Evaluate Script als Ursache angegeben ist.

Die Auswertung von Skripts wird im Leistungsprofiler der Chrome-Entwicklertools visualisiert. Die Arbeit führt zu einer lang andauernden Aufgabe beim Start, die den Hauptthread daran hindert, auf Nutzerinteraktionen zu reagieren.
Die Skriptauswertung wird im Leistungsprofiler in den Chrome-Entwicklertools angezeigt. In diesem Fall ist die Arbeit so umfangreich, dass sie eine lange Aufgabe verursacht, die den Hauptthread daran hindert, andere Aufgaben zu übernehmen, einschließlich Aufgaben, die Nutzerinteraktionen steuern.

Die Script-Auswertung ist ein notwendiger Bestandteil der Ausführung von JavaScript im Browser, da JavaScript erst kurz vor der Ausführung kompiliert wird. Wenn ein Skript ausgewertet wird, wird es zuerst auf Fehler analysiert. Wenn der Parser keine Fehler findet, wird das Script in Bytecode kompiliert und kann dann ausgeführt werden.

Die Skriptauswertung ist zwar notwendig, kann aber problematisch sein, da Nutzer möglicherweise kurz nach dem ersten Rendern versuchen, mit einer Seite zu interagieren. Nur weil eine Seite gerendert wurde, bedeutet das jedoch nicht, dass sie auch geladen wurde. Interaktionen, die während des Ladevorgangs stattfinden, können sich verzögern, da die Seite mit der Auswertung von Skripts beschäftigt ist. Es gibt zwar keine Garantie dafür, dass zu diesem Zeitpunkt eine Interaktion stattfinden kann, da ein dafür verantwortliches Script möglicherweise noch nicht geladen wurde, aber es kann Interaktionen geben, die von JavaScript abhängig sind und bereit sind, oder die Interaktivität hängt überhaupt nicht von JavaScript ab.

Beziehung zwischen Scripts und den Aufgaben, mit denen sie bewertet werden

Wie Aufgaben, die für die Scriptauswertung verantwortlich sind, gestartet werden, hängt davon ab, ob das geladene Script mit einem typischen <script>-Element oder als Modul mit dem type=module geladen wird. Da Browser dazu neigen, Dinge unterschiedlich zu handhaben, wird darauf eingegangen, wie die wichtigsten Browser-Engines die Skriptauswertung handhaben, wenn sich das Verhalten bei der Skriptauswertung zwischen ihnen unterscheidet.

Mit dem <script>-Element geladene Skripts

Die Anzahl der Aufgaben, die zum Bewerten von Scripts gesendet werden, steht in der Regel in direktem Zusammenhang mit der Anzahl der <script>-Elemente auf einer Seite. Jedes <script>-Element startet eine Aufgabe, um das angeforderte Skript zu bewerten, damit es geparst, kompiliert und ausgeführt werden kann. Das gilt für Chromium-basierte Browser, Safari und Firefox.

Warum ist das relevant? Angenommen, Sie verwenden einen Bundler, um Ihre Produktionsskripts zu verwalten, und haben ihn so konfiguriert, dass alles, was Ihre Seite zum Ausführen benötigt, in einem einzigen Skript gebündelt wird. Wenn dies auf Ihre Website zutrifft, wird wahrscheinlich nur eine Aufgabe zur Überprüfung dieses Skripts gesendet. Ist das etwas Schlechtes? Nicht unbedingt, es sei denn, das Skript ist sehr lang.

Sie können die Auswertung von Scripts aufteilen, indem Sie das Laden großer JavaScript-Blöcke vermeiden und stattdessen einzelne, kleinere Scripts mit zusätzlichen <script>-Elementen laden.

Sie sollten immer versuchen, beim Seitenaufbau so wenig JavaScript wie möglich zu laden. Wenn Sie Ihre Skripts aufteilen, haben Sie anstelle einer großen Aufgabe, die den Hauptthread blockieren kann, eine größere Anzahl kleinerer Aufgaben, die den Hauptthread überhaupt nicht oder zumindest weniger blockieren als zuvor.

Mehrere Aufgaben, die die Auswertung von Skripts umfassen, wie im Leistungsprofiler von Chrome-Entwicklertools dargestellt. Da mehrere kleinere Skripts anstelle weniger großer Skripts geladen werden, ist es weniger wahrscheinlich, dass Aufgaben zu langen Aufgaben werden. So kann der Hauptthread schneller auf Nutzereingaben reagieren.
Es wurden mehrere Aufgaben zum Auswerten von Skripts erstellt, da im HTML-Code der Seite mehrere <script>-Elemente vorhanden sind. Das ist besser, als Nutzern ein großes Script-Bundle zu senden, da dies den Hauptthread eher blockiert.

Das Aufteilen von Aufgaben für die Script-Auswertung ist in etwa vergleichbar mit dem Yielding während Ereignis-Callbacks, die während einer Interaktion ausgeführt werden. Bei der Skriptauswertung wird das geladene JavaScript jedoch in mehrere kleinere Skripts aufgeteilt, anstatt in eine kleinere Anzahl größerer Skripts, die den Hauptthread eher blockieren.

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

Mit dem Attribut type=module für das Element <script> können ES-Module jetzt nativ im Browser geladen werden. Diese Methode zum Laden von Skripts bietet einige Vorteile für Entwickler, z. B. dass kein Code für die Produktionsumgebung transformiert werden muss, insbesondere in Kombination mit Import Maps. Wenn Sie Skripts auf diese Weise laden, werden jedoch Aufgaben geplant, die sich von Browser zu Browser unterscheiden.

Auf Chromium basierende Browser

In Browsern wie Chrome oder Browsern, die auf Chrome basieren, werden beim Laden von ES-Modulen mit dem Attribut type=module andere Arten von Aufgaben ausgeführt als normalerweise, wenn type=module nicht verwendet wird. So wird beispielsweise für jedes Modulskript eine Aufgabe ausgeführt, die die Aktivität mit dem Label Modul kompilieren umfasst.

Die Modulkompilierung erfolgt in mehreren Aufgaben, wie in den Chrome-Entwicklertools dargestellt.
Verhalten beim Laden von Modulen in Chromium-basierten Browsern. Für jedes Modulskript wird ein Compile module-Aufruf generiert, um die Inhalte vor der Auswertung zu kompilieren.

Sobald die Module kompiliert wurden, wird durch jeden Code, der anschließend in ihnen ausgeführt wird, eine Aktivität mit dem Label Modul auswerten ausgelöst.

Just-in-time-Auswertung eines Moduls, wie im Leistungsbereich der Chrome-Entwicklertools dargestellt.
Wenn Code in einem Modul ausgeführt wird, wird dieses Modul just-in-time ausgewertet.

In Chrome und ähnlichen Browsern werden die Kompilierungsschritte bei Verwendung von ES-Modulen aufgeteilt. Das ist ein klarer Vorteil bei der Verwaltung langer Aufgaben. Die resultierende Modulbewertung führt jedoch zu unvermeidlichen Kosten. Sie sollten zwar versuchen, so wenig JavaScript wie möglich zu senden, aber die Verwendung von ES-Modulen bietet unabhängig vom Browser die folgenden Vorteile:

  • Der gesamte Modulcode wird automatisch im Strict Mode ausgeführt. Dadurch können JavaScript-Engines potenzielle Optimierungen vornehmen, die in einem nicht strengen Kontext nicht möglich wären.
  • Skripts, die mit type=module geladen werden, werden standardmäßig so behandelt, als wären sie aufgeschoben. Sie können dieses Verhalten ändern, indem Sie das Attribut async für Skripts verwenden, die mit type=module geladen werden.

Safari und Firefox

Wenn Module in Safari und Firefox geladen werden, wird jedes von ihnen in einer separaten Aufgabe ausgewertet. Theoretisch könnten Sie also ein einzelnes Modul der obersten Ebene laden, das nur statische import-Anweisungen für andere Module enthält. Für jedes geladene Modul wird eine separate Netzwerkanfrage und Aufgabe zur Auswertung gestellt.

Mit dynamischem import() geladene Skripts

Dynamisches import() ist eine weitere Methode zum Laden von Scripts. Im Gegensatz zu statischen import-Anweisungen, die am Anfang eines ES-Moduls stehen müssen, kann ein dynamischer import()-Aufruf an einer beliebigen Stelle in einem Skript erfolgen, um einen JavaScript-Chunk bei Bedarf zu laden. Dieses Verfahren wird als Code-Splitting bezeichnet.

Dynamisches import() hat zwei Vorteile, wenn es darum geht, den INP zu verbessern:

  1. Module, deren Laden auf später verschoben wird, verringern die Belastung des Hauptthreads beim Start, da dann weniger JavaScript geladen wird. Dadurch wird der Hauptthread entlastet, sodass er schneller auf Nutzerinteraktionen reagieren kann.
  2. Bei dynamischen import()-Aufrufen werden Kompilierung und Auswertung jedes Moduls effektiv in separate Aufgaben unterteilt. Wenn ein dynamisches import() ein sehr großes Modul lädt, wird natürlich eine ziemlich umfangreiche Aufgabe zur Skriptauswertung gestartet. Das kann die Fähigkeit des Hauptthreads beeinträchtigen, auf Nutzereingaben zu reagieren, wenn die Interaktion gleichzeitig mit dem dynamischen import()-Aufruf erfolgt. Es ist daher weiterhin sehr wichtig, dass Sie so wenig JavaScript wie möglich laden.

Dynamische import()-Aufrufe verhalten sich in allen wichtigen Browser-Engines ähnlich: Die resultierenden Script-Evaluierungsaufgaben entsprechen der Anzahl der dynamisch importierten Module.

In einem Webworker geladene Skripts

Web Workers sind ein spezieller JavaScript-Anwendungsfall. Web-Worker werden im Hauptthread registriert und der Code im Worker wird dann in einem eigenen Thread ausgeführt. Das ist sehr vorteilhaft, da der Code, mit dem der Web-Worker registriert wird, zwar im Hauptthread ausgeführt wird, der Code im Web-Worker jedoch nicht. Dadurch wird die Überlastung des Hauptthreads reduziert und er kann besser auf Nutzerinteraktionen reagieren.

Neben der Reduzierung der Arbeit im Hauptthread können Web-Worker selbst externe Skripts laden, die im Worker-Kontext verwendet werden sollen. Dies kann entweder über importScripts oder über statische import-Anweisungen in Browsern erfolgen, die Modul-Worker unterstützen. Das Ergebnis ist, dass jedes von einem Webworker angeforderte Skript außerhalb des Haupt-Threads ausgewertet wird.

Vor- und Nachteile und wichtige Aspekte

Wenn Sie Ihre Skripts in separate, kleinere Dateien aufteilen, können Sie lange Aufgaben begrenzen, anstatt weniger, aber viel größere Dateien zu laden. Bei der Entscheidung, wie Sie Skripts aufteilen, sollten Sie jedoch einige Dinge berücksichtigen.

Komprimierungseffizienz

Die Komprimierung spielt eine Rolle, wenn es darum geht, Skripts aufzuteilen. Bei kleineren Skripts ist die Komprimierung etwas weniger effizient. Bei größeren Scripts ist der Nutzen der Komprimierung viel größer. Eine höhere Komprimierungseffizienz trägt dazu bei, die Ladezeiten für Skripts so gering wie möglich zu halten. Es ist jedoch wichtig, Skripts in ausreichend kleine Teile aufzuteilen, 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:

  • Für webpack kann das SplitChunksPlugin-Plug-in hilfreich sein. Informationen zu Optionen, die Sie festlegen können, um die Asset-Größen zu verwalten, finden Sie in der SplitChunksPlugin-Dokumentation.
  • Bei anderen Bundlern wie Rollup und esbuild können Sie die Größe von Scriptdateien mit dynamischen import()-Aufrufen in Ihrem Code verwalten. Diese Bundler sowie webpack teilen das dynamisch importierte Asset automatisch in eine eigene Datei auf, um größere anfängliche Bundle-Größen zu vermeiden.

Cache-Entwertung

Die Cache-Invalidierung spielt eine große Rolle dabei, wie schnell eine Seite bei wiederholten Besuchen geladen wird. Wenn Sie große, monolithische Script-Bundles ausliefern, haben Sie einen Nachteil beim Browser-Caching. Das liegt daran, dass das gesamte Bundle ungültig wird und noch einmal heruntergeladen werden muss, wenn Sie Ihren selbst erhobenen Code aktualisieren, z. B. durch Aktualisieren von Paketen oder Bereitstellen von Fehlerkorrekturen.

Wenn Sie Ihre Skripts aufteilen, verteilen Sie die Auswertung von Skripts nicht nur auf kleinere Aufgaben, sondern erhöhen auch die Wahrscheinlichkeit, dass wiederkehrende Besucher mehr Skripts aus dem Browsercache anstatt aus dem Netzwerk abrufen. Das führt zu einem insgesamt schnelleren Seitenaufbau.

Geschachtelte 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 das Verschachteln von Modulen auf die Startzeit auswirken kann. Bei der Modulverschachtelung wird ein ES-Modul statisch in ein anderes ES-Modul importiert, das wiederum statisch in ein anderes ES-Modul importiert wird:

// 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 obige Code zu einer Kette von Netzwerkanfragen: 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 beinhaltet. Eine Möglichkeit, dies zu vermeiden, ist die Verwendung eines Bundlers. Achten Sie jedoch darauf, dass Sie Ihren Bundler so konfigurieren, dass Skripts aufgeteilt werden, um die Auswertung von Skripts zu verteilen.

Wenn Sie keinen Bundler verwenden möchten, können Sie verschachtelte Modulaufrufe auch mit dem Ressourcenhinweis modulepreload umgehen. Dadurch werden ES-Module vorab geladen, um Ketten von Netzwerkanfragen zu vermeiden.

Fazit

Die Optimierung der Auswertung von Scripts im Browser ist zweifellos eine schwierige Aufgabe. Der Ansatz hängt von den Anforderungen und Einschränkungen Ihrer Website ab. Wenn Sie Skripts jedoch aufteilen, verteilen Sie die Arbeit der Skriptauswertung auf zahlreiche kleinere Aufgaben. So kann der Hauptthread Nutzerinteraktionen effizienter verarbeiten, anstatt blockiert zu werden.

Zusammenfassend finden Sie hier einige Möglichkeiten, um große Skriptbewertungsaufgaben aufzuteilen:

  • Wenn Sie Skripts mit dem <script>-Element ohne das type=module-Attribut laden, sollten Sie keine sehr großen Skripts laden, da dadurch ressourcenintensive Aufgaben zur Skriptauswertung ausgelöst werden, die den Hauptthread blockieren. Verteilen Sie Ihre Skripts auf mehrere <script>-Elemente, um die Arbeit aufzuteilen.
  • Wenn Sie das Attribut type=module verwenden, um ES-Module nativ im Browser zu laden, werden für jedes separate Modulskript einzelne Aufgaben zur Auswertung gestartet.
  • Reduzieren Sie die Größe Ihrer ersten Bundles, indem Sie dynamische import()-Aufrufe verwenden. Das funktioniert auch in Bundlern, da diese jedes dynamisch importierte Modul als „Split Point“ behandeln. Dadurch wird für jedes dynamisch importierte Modul ein separates Skript generiert.
  • Berücksichtigen Sie dabei Kompromisse wie Komprimierungseffizienz und Cache-Invalidierung. Größere Skripts lassen sich besser komprimieren, erfordern aber wahrscheinlich mehr Aufwand für die Skriptauswertung in weniger Aufgaben und führen zu einer Invalidierung des Browser-Cache, was insgesamt zu einer geringeren Caching-Effizienz führt.
  • Wenn Sie ES-Module nativ ohne Bündelung verwenden, können Sie den Ressourcenhinweis modulepreload verwenden, um das Laden während des Starts zu optimieren.
  • Wie immer gilt: Senden Sie so wenig JavaScript wie möglich.

Das ist natürlich ein Balanceakt. Wenn Sie aber Skripts aufteilen und die anfänglichen Nutzlasten mit dynamischen import() reduzieren, können Sie die Startleistung verbessern und Nutzerinteraktionen während dieser wichtigen Startphase besser berücksichtigen. Das sollte Ihnen helfen, beim INP-Messwert besser abzuschneiden und so die Nutzerfreundlichkeit zu verbessern.