Skriptauswertung und lange Aufgaben

Beim Laden von Scripts benötigt der Browser Zeit, um sie vor der Ausführung zu bewerten. Dies kann zu langen Aufgaben führen. Hier erfahren Sie, wie die Scriptauswertung funktioniert und was Sie tun können, damit beim Laden der Seite keine langen Aufgaben ausgeführt werden.

Wenn es um die Optimierung des Messwerts „Interaction to Next Paint“ (INP) geht, wird in den meisten Fällen empfohlen, die Interaktionen selbst zu optimieren. Im Leitfaden zum Optimieren langer Aufgaben werden beispielsweise Techniken wie das Yielding mit setTimeout behandelt. Diese Techniken sind vorteilhaft, da sie dem Haupt-Thread etwas Zeit verschaffen, indem lange Aufgaben vermieden werden. Dadurch können Interaktionen und andere Aktivitäten früher ausgeführt werden, als wenn sie auf eine einzige lange Aufgabe warten müssten.

Was ist aber mit den langen Aufgaben, die durch das Laden von Scripts selbst entstehen? Diese Aufgaben können die Nutzerinteraktionen beeinträchtigen und sich während des Ladevorgangs auf den INP einer Seite auswirken. In diesem Leitfaden erfahren Sie, wie Browser Aufgaben verarbeiten, die durch die Script-Bewertung ausgelöst werden, und was Sie tun können, um die Script-Bewertung aufzuteilen, damit Ihr Hauptthread während des Ladens der Seite besser auf Nutzereingaben reagieren kann.

Was ist die Script-Bewertung?

Wenn Sie eine Anwendung mit viel JavaScript geprofilet haben, haben Sie möglicherweise lange Aufgaben mit dem Label Script auswerten gesehen.

Die Skriptauswertung funktioniert wie im Leistungsprofil der Chrome DevTools dargestellt. Der Arbeitsaufwand verursacht eine lange Aufgabe beim Start, wodurch der Hauptthread nicht mehr auf Nutzerinteraktionen reagieren kann.
Die Skriptauswertung funktioniert wie im Leistungsprofiler in den Chrome-Entwicklertools dargestellt. In diesem Fall ist die Arbeit ausreichend, um eine lange Aufgabe zu verursachen, die den Hauptthread daran hindert, andere Aufgaben zu übernehmen, einschließlich Aufgaben, die Nutzerinteraktionen auslösen.

Die Script-Bewertung ist ein notwendiger Bestandteil der Ausführung von JavaScript im Browser, da JavaScript Just-in-Time vor der Ausführung kompiliert wird. Wenn ein Script ausgewertet wird, wird es zuerst auf Fehler geprüft. Wenn der Parser keine Fehler findet, wird das Script in Bytecode kompiliert und kann dann ausgeführt werden.

Die Skriptauswertung kann bei Bedarf jedoch problematisch sein, da Nutzer versuchen könnten, kurz nach dem ersten 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 verzögert werden, weil die Seite gerade Scripts auswertet. Obwohl es keine Garantie dafür gibt, dass zu diesem Zeitpunkt eine Interaktion stattfinden kann, kann es sein, dass ein für sie verantwortliches Skript noch nicht geladen wurde. Es könnte jedoch Interaktionen geben, die von JavaScript abhängig sind und bereit sind, oder die Interaktivität hängt überhaupt nicht von JavaScript ab.

Die Beziehung zwischen Scripts und den Aufgaben, die sie ausführen

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

Mit dem <script>-Element geladene Scripts

Die Anzahl der Aufgaben, die zur Auswertung 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 auszuwerten, 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 Produktionsscripts zu verwalten, und haben ihn so konfiguriert, dass alles, was für die Ausführung Ihrer Seite erforderlich ist, in einem einzigen Script gebündelt wird. Wenn dies auf Ihre Website zutrifft, wird für die Auswertung dieses Scripts nur eine einzige Aufgabe gesendet. Ist das etwas Schlechtes? Nicht unbedingt, es sei denn, das Script ist riesig.

Sie können die Scriptauswertung aufteilen, indem Sie das Laden großer JavaScript-Chunks vermeiden und mithilfe zusätzlicher <script>-Elemente mehr einzelne, kleinere Scripts laden.

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

Mehrere Aufgaben mit Skriptauswertung, wie im Performance-Profiler der Chrome-Entwicklertools dargestellt. Da mehrere kleinere Scripts anstelle weniger größerer Scripts geladen werden, ist die Wahrscheinlichkeit geringer, dass Aufgaben zu langen Aufgaben werden. So kann der Hauptthread schneller auf Nutzereingaben reagieren.
Wenn mehrere <script>-Elemente im HTML-Code der Seite vorhanden sind, werden mehrere Aufgaben zur Auswertung von Skripts erzeugt. Dies ist besser, als ein großes Skript-Bundle an Nutzer zu senden, da sonst der Hauptthread mit größerer Wahrscheinlichkeit blockiert wird.

Die Aufteilung von Aufgaben für die Skriptbewertung ähnelt in etwa der Auswirkung während Ereignis-Callbacks, die während einer Interaktion ausgeführt werden. Bei der Scriptauswertung wird das von Ihnen geladene JavaScript jedoch durch den Yielding-Mechanismus in mehrere kleinere Scripts aufgeteilt, anstatt in eine kleinere Anzahl größerer Scripts, die mit größerer Wahrscheinlichkeit den Hauptthread blockieren.

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

Mit dem type=module-Attribut auf dem <script>-Element können ES-Module jetzt nativ im Browser geladen werden. Dieser Ansatz für das Script-Laden bietet einige Vorteile für Entwickler, da der Code nicht für die Produktionsnutzung transformiert werden muss – insbesondere in Kombination mit Importkarten. Beim Laden von Scripts auf diese Weise werden jedoch Aufgaben geplant, die sich von Browser zu Browser unterscheiden.

Chromium-basierte Browser

In Browsern wie Chrome oder in denen, die von Chrome abgeleitet sind, werden beim Laden von ES-Modulen mit dem Attribut type=module andere Arten von Aufgaben ausgeführt als bei Verwendung von type=module. Beispielsweise wird für jedes Modulscript eine Aufgabe ausgeführt, die die Aktivität Modul kompilieren umfasst.

Die Modulkompilierung umfasst mehrere Aufgaben, wie in den Chrome DevTools dargestellt.
Verhalten beim Laden von Modulen in Chromium-basierten Browsern. Jedes Modulscript startet einen Compile module-Aufruf, um den Inhalt 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 der Bezeichnung Modul bewerten gestartet.

Just-in-time-Bewertung eines Moduls, wie im Performance Panel der Chrome-Entwicklertools dargestellt.
Wenn Code in einem Modul ausgeführt wird, wird dieses Modul Just-in-Time ausgewertet.

Zumindest in Chrome und verwandten Browsern bedeutet das, dass die Kompilierungsschritte bei der Verwendung von ES-Modulen unterbrochen werden. Dies ist ein klarer Vorteil, was die Verwaltung langer Aufgaben angeht. Die daraus resultierende Modulbewertung bedeutet jedoch immer noch, dass Ihnen unvermeidliche Kosten entstehen. Sie sollten zwar versuchen, so wenig JavaScript wie möglich zu verwenden, aber die Verwendung von ES-Modulen bietet unabhängig vom Browser folgende Vorteile:

  • Der gesamte Modulcode wird automatisch im strikten Modus ausgeführt. Dadurch sind potenzielle Optimierungen durch JavaScript-Engines möglich, die andernfalls in einem nicht strikten Kontext nicht durchgeführt werden könnten.
  • Scripts, die mit type=module geladen werden, werden standardmäßig als verzögert behandelt. Mit dem async-Attribut in Scripts, die mit type=module geladen werden, lässt sich dieses Verhalten ändern.

Safari und Firefox

Wenn Module in Safari und Firefox geladen werden, wird jedes davon in einer separaten Aufgabe ausgewertet. Das bedeutet, dass Sie theoretisch ein einzelnes Modul auf oberster Ebene mit nur statischen import-Anweisungen in andere Module laden könnten. Für jedes geladene Modul wird eine separate Netzwerkanfrage und -aufgabe zur Auswertung ausgelöst.

Mit dynamischer import() geladene Scripts

Dynamische import() ist eine weitere Methode zum Laden von Scripts. Im Gegensatz zu statischen import-Anweisungen, die sich oben in einem ES-Modul befinden müssen, kann ein dynamischer import()-Aufruf an einer beliebigen Stelle in einem Script stehen, um einen JavaScript-Codeblock bei Bedarf zu laden. Diese Methode wird als Code-Splitting bezeichnet.

Dynamische import() hat zwei Vorteile bei der Verbesserung der INP:

  1. Module, deren Laden verschoben wird, reduzieren die Hauptthread-Konkurrenz beim Starten, da zu diesem Zeitpunkt weniger JavaScript geladen wird. Dadurch wird der Hauptthread freigegeben und kann besser auf Nutzerinteraktionen reagieren.
  2. Bei dynamischen import()-Aufrufen wird bei jedem Aufruf die Kompilierung und Auswertung jedes Moduls effektiv in eine eigene Aufgabe unterteilt. Natürlich löst ein dynamischer import(), der ein sehr großes Modul lädt, eine ziemlich große Script-Bewertungsaufgabe aus. 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, so wenig JavaScript wie möglich zu laden.

Dynamische import()-Aufrufe verhalten sich in allen gängigen Browser-Engines ähnlich: Die Anzahl der resultierenden Script-Bewertungsaufgaben entspricht der Anzahl der dynamisch importierten Module.

In einem Webworker geladene Scripts

Webworker sind ein spezieller JavaScript-Anwendungsfall. Webworker werden im Hauptthread registriert und der Code im Worker wird dann in einem eigenen Thread ausgeführt. Dies hat den Vorteil, dass der Code, der den Web Worker registriert, im Hauptthread ausgeführt wird, der Code im Web Worker jedoch nicht. Dadurch wird die Überlastung des Hauptthreads reduziert und der Hauptthread kann schneller auf Nutzerinteraktionen reagieren.

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

Vor- und Nachteile sowie Überlegungen

Wenn Sie Ihre Scripts in separate, kleinere Dateien aufteilen, können Sie lange Aufgaben besser begrenzen, als wenn Sie weniger, aber viel größere Dateien laden. Bei der Entscheidung, wie Sie Scripts aufteilen, sollten Sie jedoch einige Dinge beachten.

Kompressionseffizienz

Komprimierung ist ein Faktor, der beim Aufteilen von Scripts eine Rolle spielt. Wenn Skripts kleiner sind, wird die Komprimierung etwas weniger effizient. Größere Scripts profitieren viel stärker von der Komprimierung. Eine höhere Komprimierungseffizienz trägt zwar dazu bei, die Ladezeiten für Scripts so gering wie möglich zu halten, aber es ist ein wenig schwierig, Scripts in genügend kleinere Teile zu zerlegen, um eine bessere Interaktivität beim Start zu ermöglichen.

Bundler sind ideale Tools, um die Ausgabegröße für die Skripts zu verwalten, von denen Ihre Website abhängig ist:

  • Bei webpack kann das SplitChunksPlugin-Plug-in helfen. In der SplitChunksPlugin-Dokumentation finden Sie Optionen, mit denen Sie die Asset-Größe verwalten können.
  • Bei anderen Bundlern wie Rollup und esbuild können Sie die Größe der Skriptdateien verwalten, indem Sie dynamische import()-Aufrufe in Ihrem Code verwenden. Diese Bundler und auch Webpack trennen das dynamisch importierte Asset automatisch in eine eigene Datei, um größere anfängliche Bundle-Größen zu vermeiden.

Cache-Entwertung

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

Indem Sie Ihre Skripts aufteilen, aufteilen Sie die Skriptbewertung nicht nur auf kleinere Aufgaben, sondern erhöhen auch die Wahrscheinlichkeit, dass wiederkehrende Besucher mehr Skripts aus dem Browser-Cache statt aus dem Netzwerk abrufen. Das führt zu einer insgesamt schnelleren Seitenladezeit.

Verschachtelte Module und Ladeleistung

Wenn Sie ES-Module in der Produktion bereitstellen und 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 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 beinhaltet. Eine Möglichkeit, dies zu vermeiden, besteht darin, einen Bundler zu verwenden. Achten Sie jedoch darauf, den Bundler so zu konfigurieren, dass Scripts aufgeteilt werden, um die Scriptauswertung zu verteilen.

Wenn Sie keinen Bundler verwenden möchten, können Sie verschachtelte Modulaufrufe auch mithilfe des modulepreload-Ressourcenhinweises umgehen. Dabei werden ES-Module vorab geladen, um Netzwerkanfrageketten zu vermeiden.

Fazit

Die Bewertung von Scripts im Browser zu optimieren, ist zweifellos eine schwierige Aufgabe. Der Ansatz hängt von den Anforderungen und Einschränkungen Ihrer Website ab. Wenn Sie Scripts jedoch aufteilen, verteilen Sie die Arbeit der Scriptauswertung auf zahlreiche kleinere Aufgaben und ermöglichen dem Hauptthread so, Nutzerinteraktionen effizienter zu verarbeiten, anstatt den Hauptthread zu blockieren.

Hier sind einige Möglichkeiten, wie Sie große Script-Bewertungsaufgaben aufteilen können:

  • Wenn Sie Scripts mit dem <script>-Element ohne das type=module-Attribut laden, sollten Sie keine sehr großen Scripts laden, da diese ressourcenintensive Script-Bewertungsaufgaben auslösen, die den Hauptthread blockieren. Verteilen Sie Ihre Scripts auf mehrere <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 separate Modulscript einzelne Aufgaben zur Auswertung gestartet.
  • Mit dynamischen import()-Aufrufen können Sie die Größe Ihrer ursprünglichen Bundles reduzieren. Das funktioniert auch in Bundlern, da jedes dynamisch importierte Modul als „Trennpunkt“ behandelt wird. Dadurch wird für jedes dynamisch importierte Modul ein separates Script generiert.
  • Berücksichtigen Sie dabei auch Kompromisse wie Komprimierungseffizienz und Cache-Invalidierung. Größere Skripts werden besser komprimiert, erfordern aber mit größerer Wahrscheinlichkeit eine teurere Skriptbewertung mit weniger Aufgaben. Außerdem führt dies zu einer Entwertung des Browser-Cache, was zu einer geringeren Caching-Effizienz führt.
  • Wenn Sie ES-Module nativ ohne Bündelung verwenden, können Sie mit dem Ressourcenhinweis modulepreload das Laden der Module beim Start optimieren.
  • Wie immer gilt: Senden Sie so wenig JavaScript wie möglich.

Es ist in der Tat ein Balanceakt. Wenn Sie Scripts jedoch aufteilen und die anfänglichen Nutzlasten mit dynamischen import() reduzieren, können Sie eine bessere Startleistung erzielen und Nutzerinteraktionen in dieser wichtigen Startphase besser berücksichtigen. So können Sie bei diesem Messwert besser abschneiden und die Nutzerfreundlichkeit verbessern.