Sie haben wahrscheinlich schon gehört, dass Sie den Hauptthread nicht blockieren und lange Aufgaben aufteilen sollten. Aber was bedeutet das eigentlich?
Veröffentlicht: 30. September 2022, letzte Aktualisierung: 19. Dezember 2024
Die gängigen Ratschläge, wie JavaScript-Anwendungen schnell bleiben, lassen sich so zusammenfassen:
- „Blockieren Sie nicht den Hauptthread.“
- „Teile lange Aufgaben auf.“
Das ist ein guter Rat, aber was ist dafür zu tun? Weniger JavaScript zu senden ist gut, aber führt das automatisch zu reaktionsschnelleren Benutzeroberflächen? Vielleicht, vielleicht aber auch nicht.
Um zu verstehen, wie Sie Aufgaben in JavaScript optimieren, müssen Sie zuerst wissen, was Aufgaben sind und wie der Browser sie verarbeitet.
Was ist eine Aufgabe?
Eine Aufgabe ist jede eigenständige Arbeit, die der Browser ausführt. Dazu gehören das Rendern, das Parsen von HTML und CSS, das Ausführen von JavaScript und andere Aufgaben, die Sie möglicherweise nicht direkt steuern können. Von all diesen Aufgaben ist das von Ihnen geschriebene JavaScript vielleicht die größte Quelle für Aufgaben.
click-Event-Handler gestartet wurde, wird im Leistungsprofiler der Chrome-Entwicklertools angezeigt.
Mit JavaScript verbundene Aufgaben wirken sich auf verschiedene Weise auf die Leistung aus:
- Wenn ein Browser beim Start eine JavaScript-Datei herunterlädt, werden Aufgaben zum Parsen und Kompilieren dieses JavaScripts in die Warteschlange gestellt, damit es später ausgeführt werden kann.
- Zu anderen Zeiten während der Lebensdauer der Seite werden Aufgaben in die Warteschlange gestellt, wenn JavaScript Aufgaben ausführt, z. B. auf Interaktionen über Event-Handler reagiert, JavaScript-gesteuerte Animationen ausführt und Hintergrundaktivitäten wie die Erfassung von Analysedaten ausführt.
All diese Dinge – mit Ausnahme von Web Workern und ähnlichen APIs – finden im Hauptthread statt.
Was ist der Hauptthread?
Im Hauptthread werden die meisten Aufgaben im Browser ausgeführt und fast der gesamte JavaScript-Code, den Sie schreiben, wird ausgeführt.
Der Hauptthread kann jeweils nur eine Aufgabe verarbeiten. Jede Aufgabe, die länger als 50 Millisekunden dauert, ist eine Long Task. Bei Aufgaben, die länger als 50 Millisekunden dauern, wird die Gesamtzeit der Aufgabe abzüglich 50 Millisekunden als Blockierungszeitraum der Aufgabe bezeichnet.
Der Browser blockiert Interaktionen, während eine Aufgabe beliebiger Länge ausgeführt wird. Dies ist für den Nutzer jedoch nicht wahrnehmbar, solange die Aufgaben nicht zu lange dauern. Wenn ein Nutzer jedoch versucht, mit einer Seite zu interagieren, auf der viele Long Tasks ausgeführt werden, reagiert die Benutzeroberfläche nicht und ist möglicherweise sogar fehlerhaft, wenn der Hauptthread sehr lange blockiert ist.
Damit der Hauptthread nicht zu lange blockiert wird, können Sie einen Long Task in mehrere kleinere Aufgaben aufteilen.
Das ist wichtig, weil der Browser bei aufgeteilten Aufgaben viel schneller auf Aufgaben mit höherer Priorität reagieren kann, einschließlich Nutzerinteraktionen. Anschließend werden die verbleibenden Aufgaben bis zum Ende ausgeführt, damit die ursprünglich in die Warteschlange gestellten Aufgaben erledigt werden.
Oben in der vorherigen Abbildung musste ein Ereignis-Handler, der durch eine Nutzerinteraktion in die Warteschlange gestellt wurde, auf einen einzelnen Long Task warten, bevor er beginnen konnte. Dadurch wird die Interaktion verzögert. In diesem Szenario hat der Nutzer möglicherweise eine Verzögerung bemerkt. Unten kann der Event-Handler früher ausgeführt werden und die Interaktion hat sich möglicherweise sofort angefühlt.
Nachdem Sie nun wissen, warum es wichtig ist, Aufgaben aufzuteilen, können Sie sich ansehen, wie das in JavaScript funktioniert.
Strategien für das Aufgabenmanagement
Ein gängiger Ratschlag in der Softwarearchitektur ist, die Arbeit in kleinere Funktionen aufzuteilen:
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
In diesem Beispiel gibt es eine Funktion namens saveSettings(), die fünf Funktionen aufruft, um ein Formular zu validieren, einen Spinner anzuzeigen, Daten an das Anwendungs-Backend zu senden, die Benutzeroberfläche zu aktualisieren und Analysedaten zu senden.
Konzeptionell ist saveSettings() gut aufgebaut. Wenn Sie eine dieser Funktionen debuggen müssen, können Sie den Projektbaum durchgehen, um herauszufinden, was die einzelnen Funktionen tun. Wenn Sie die Arbeit so aufteilen, lassen sich Projekte leichter verwalten und pflegen.
Ein potenzielles Problem ist hier jedoch, dass JavaScript diese Funktionen nicht als separate Aufgaben ausführt, da sie innerhalb der Funktion saveSettings() ausgeführt werden. Das bedeutet, dass alle fünf Funktionen als eine Aufgabe ausgeführt werden.
saveSettings(), die fünf Funktionen aufruft. Die Arbeit wird als Teil einer langen monolithischen Aufgabe ausgeführt, die jede visuelle Antwort blockiert, bis alle fünf Funktionen abgeschlossen sind.
Im besten Fall kann schon eine dieser Funktionen 50 Millisekunden oder mehr zur Gesamtlänge der Aufgabe beitragen. Im schlimmsten Fall können mehr dieser Aufgaben viel länger dauern, insbesondere auf Geräten mit eingeschränkten Ressourcen.
In diesem Fall wird saveSettings() durch einen Nutzerklick ausgelöst. Da der Browser erst dann eine Antwort anzeigen kann, wenn die gesamte Funktion ausgeführt wurde, führt dieser Long Task zu einer langsamen und nicht reagierenden Benutzeroberfläche. Dies wird als schlechter Interaction to Next Paint (INP)-Wert gemessen.
Codeausführung manuell zurückstellen
Damit wichtige für Nutzer sichtbare Aufgaben und UI-Antworten vor Aufgaben mit niedrigerer Priorität ausgeführt werden, können Sie die Kontrolle an den Hauptthread übergeben, indem Sie Ihre Arbeit kurz unterbrechen, damit der Browser wichtigere Aufgaben ausführen kann.
Eine Methode, mit der Entwickler Aufgaben in kleinere Aufgaben aufteilen, ist setTimeout(). Bei dieser Methode übergeben Sie die Funktion an setTimeout(). Dadurch wird die Ausführung des Callbacks in eine separate Aufgabe verschoben, auch wenn Sie ein Zeitlimit von 0 angeben.
function saveSettings () {
// Do critical work that is user-visible:
validateForm();
showSpinner();
updateUI();
// Defer work that isn't user-visible to a separate task:
setTimeout(() => {
saveToDatabase();
sendAnalytics();
}, 0);
}
Dies wird als Yielding bezeichnet und eignet sich am besten für eine Reihe von Funktionen, die nacheinander ausgeführt werden müssen.
Ihr Code ist jedoch möglicherweise nicht immer so organisiert. Beispielsweise könnten Sie eine große Menge an Daten haben, die in einer Schleife verarbeitet werden müssen. Diese Aufgabe könnte sehr lange dauern, wenn es viele Iterationen gibt.
function processData () {
for (const item of largeDataArray) {
// Process the individual item here.
}
}
Die Verwendung von setTimeout() ist hier aufgrund der Entwicklerergonomie problematisch. Nach fünf Runden verschachtelter setTimeout()s erzwingt der Browser eine Verzögerung von mindestens 5 Millisekunden für jedes zusätzliche setTimeout().
setTimeout hat auch einen weiteren Nachteil, wenn es um das Yielding geht: Wenn Sie die Kontrolle an den Hauptthread übergeben, indem Sie Code mit setTimeout in einer nachfolgenden Aufgabe ausführen, wird diese Aufgabe am Ende der Warteschlange hinzugefügt. Wenn andere Aufgaben warten, werden sie vor dem zurückgestellten Code ausgeführt.
Eine dedizierte Yielding API: scheduler.yield()
scheduler.yield() ist eine API, die speziell für die Übergabe an den Hauptthread im Browser entwickelt wurde.
Es handelt sich nicht um eine Syntax auf Sprachebene oder ein spezielles Konstrukt. scheduler.yield() ist nur eine Funktion, die ein Promise zurückgibt, das in einer zukünftigen Aufgabe aufgelöst wird. Jeder Code, der nach der Auflösung dieses Promise-Objekts ausgeführt werden soll (entweder in einer expliziten .then()-Kette oder nach dem await-Aufruf in einer asynchronen Funktion), wird dann in dieser zukünftigen Aufgabe ausgeführt.
In der Praxis: Fügen Sie ein await scheduler.yield() ein. Die Funktion pausiert dann die Ausführung an dieser Stelle und gibt die Kontrolle an den Hauptthread ab. Die Ausführung des restlichen Teils der Funktion, die sogenannte Fortsetzung der Funktion, wird für die Ausführung in einer neuen Ereignisschleifenaufgabe geplant. Wenn diese Aufgabe gestartet wird, wird das erwartete Promise aufgelöst und die Funktion wird an der Stelle fortgesetzt, an der sie unterbrochen wurde.
async function saveSettings () {
// Do critical work that is user-visible:
validateForm();
showSpinner();
updateUI();
// Yield to the main thread:
await scheduler.yield()
// Work that isn't user-visible, continued in a separate task:
saveToDatabase();
sendAnalytics();
}
saveSettings() ist jetzt auf zwei Aufgaben aufgeteilt. Dadurch können Layout und Paint zwischen den Aufgaben ausgeführt werden, was dem Nutzer eine schnellere visuelle Antwort liefert, wie die jetzt viel kürzere Zeigerinteraktion zeigt.
Der eigentliche Vorteil von scheduler.yield() gegenüber anderen Yielding-Ansätzen besteht jedoch darin, dass die Fortsetzung priorisiert wird. Wenn Sie also mitten in einer Aufgabe ein Yielding durchführen, wird die Fortsetzung der aktuellen Aufgabe vor allen anderen ähnlichen Aufgaben ausgeführt.
Dadurch wird verhindert, dass Code aus anderen Aufgabenquellen die Reihenfolge der Codeausführung unterbricht, z. B. Aufgaben aus Drittanbieterscripts.
scheduler.yield() verwenden, wird die Fortsetzung dort fortgesetzt, wo sie aufgehört hat, bevor andere Aufgaben ausgeführt werden.
Unterstützung für verschiedene Browser
scheduler.yield() wird noch nicht in allen Browsern unterstützt, daher ist ein Fallback erforderlich.
Eine Lösung besteht darin, scheduler-polyfill in Ihren Build einzufügen. Dann kann scheduler.yield() direkt verwendet werden. Das Polyfill übernimmt den Fallback auf andere Funktionen zur Aufgabenplanung, sodass es in allen Browsern ähnlich funktioniert.
Alternativ kann eine weniger komplexe Version in wenigen Zeilen geschrieben werden, wobei nur setTimeout in einem Promise als Fallback verwendet wird, wenn scheduler.yield() nicht verfügbar ist.
function yieldToMain () {
if (globalThis.scheduler?.yield) {
return scheduler.yield();
}
// Fall back to yielding with setTimeout.
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
Browser ohne scheduler.yield()-Unterstützung erhalten zwar keine priorisierte Fortsetzung, geben aber dennoch nach, damit der Browser reaktionsfähig bleibt.
Schließlich kann es Fälle geben, in denen Ihr Code es sich nicht leisten kann, an den Hauptthread zu übergeben, wenn die Fortsetzung nicht priorisiert wird (z. B. eine bekanntermaßen ausgelastete Seite, auf der die Übergabe das Risiko birgt, dass die Arbeit für einige Zeit nicht abgeschlossen wird). In diesem Fall könnte scheduler.yield() als eine Art progressive Verbesserung behandelt werden: Yield in Browsern, in denen scheduler.yield() verfügbar ist, andernfalls fortfahren.
Dazu können Sie sowohl die Feature-Erkennung als auch den Fallback auf das Warten auf einen einzelnen Mikrotask in einem praktischen Einzeiler verwenden:
// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();
Lang andauernde Arbeit mit scheduler.yield() unterbrechen
Der Vorteil dieser Methoden zur Verwendung von scheduler.yield() besteht darin, dass Sie es in jeder async-Funktion await können.
Wenn Sie beispielsweise eine Reihe von Aufgaben ausführen müssen, die sich oft zu einer langen Aufgabe summieren, können Sie Yields einfügen, um die Aufgabe aufzuteilen.
async function runJobs(jobQueue) {
for (const job of jobQueue) {
// Run the job:
job();
// Yield to the main thread:
await yieldToMain();
}
}
Die Fortsetzung von runJobs() wird priorisiert, aber es können weiterhin Aufgaben mit höherer Priorität ausgeführt werden, z. B. die visuelle Reaktion auf Nutzereingaben. Es muss nicht gewartet werden, bis die potenziell lange Liste von Aufgaben abgeschlossen ist.
Dies ist jedoch keine effiziente Nutzung von Yielding. scheduler.yield() ist schnell und effizient, verursacht aber einen gewissen Aufwand. Wenn einige der Jobs in jobQueue sehr kurz sind, kann der Overhead schnell dazu führen, dass mehr Zeit für das Aussetzen und Fortsetzen als für die eigentliche Arbeit aufgewendet wird.
Ein Ansatz besteht darin, die Aufgaben in Batches zusammenzufassen und nur dann zwischen ihnen zu wechseln, wenn seit dem letzten Wechsel genügend Zeit vergangen ist. Eine gängige Frist ist 50 Millisekunden, um zu verhindern, dass Aufgaben zu Long Tasks werden. Sie kann jedoch angepasst werden, um einen Kompromiss zwischen Reaktionsfähigkeit und Zeit bis zum Abschluss der Jobwarteschlange zu finden.
async function runJobs(jobQueue, deadline=50) {
let lastYield = performance.now();
for (const job of jobQueue) {
// Run the job:
job();
// If it's been longer than the deadline, yield to the main thread:
if (performance.now() - lastYield > deadline) {
await yieldToMain();
lastYield = performance.now();
}
}
}
Das Ergebnis ist, dass die Jobs so aufgeteilt werden, dass sie nie zu lange dauern, aber der Runner nur etwa alle 50 Millisekunden an den Hauptthread übergibt.
isInputPending() nicht verwenden
Mit der isInputPending() API lässt sich prüfen, ob ein Nutzer versucht hat, mit einer Seite zu interagieren. Die Ausführung wird nur dann unterbrochen, wenn eine Eingabe aussteht.
So kann JavaScript fortgesetzt werden, wenn keine Eingaben ausstehen, anstatt die Ausführung zu unterbrechen und am Ende der Aufgabenwarteschlange zu landen. Dies kann zu beeindruckenden Leistungsverbesserungen führen, wie im Intent to Ship beschrieben, für Websites, die sonst möglicherweise nicht zum Hauptthread zurückkehren.
Seit der Einführung dieser API hat sich unser Verständnis von Yielding jedoch verbessert, insbesondere durch die Einführung von INP. Wir empfehlen die Verwendung dieser API nicht mehr und raten stattdessen aus mehreren Gründen dazu, unabhängig davon, ob Eingaben ausstehen oder nicht, die Kontrolle abzugeben:
isInputPending()gibt unter bestimmten Umständen fälschlicherweisefalsezurück, obwohl ein Nutzer interagiert hat.- Eingaben sind nicht der einzige Fall, in dem Aufgaben zurückgestellt werden sollten. Animationen und andere regelmäßige Aktualisierungen der Benutzeroberfläche können ebenso wichtig sein, um eine responsive Webseite bereitzustellen.
- Inzwischen wurden umfassendere APIs eingeführt, die sich mit dem Problem des Yieldings befassen, z. B.
scheduler.postTask()undscheduler.yield().
Fazit
Das Verwalten von Aufgaben ist eine Herausforderung, sorgt aber dafür, dass Ihre Seite schneller auf Nutzerinteraktionen reagiert. Es gibt nicht den einen Ratschlag für die Verwaltung und Priorisierung von Aufgaben, sondern eine Reihe verschiedener Techniken. Hier noch einmal die wichtigsten Punkte, die Sie bei der Verwaltung von Aufgaben beachten sollten:
- Geben Sie den Hauptthread für kritische, für Nutzer sichtbare Aufgaben frei.
- Verwenden Sie
scheduler.yield()(mit einem browserübergreifenden Fallback), um ergonomisch zu pausieren und priorisierte Fortsetzungen zu erhalten. - Führen Sie in Ihren Funktionen so wenig Arbeit wie möglich aus.
Weitere Informationen zu scheduler.yield(), der expliziten Aufgabenplanung scheduler.postTask() und der Aufgabenpriorisierung finden Sie in der Dokumentation zur Prioritized Task Scheduling API.
Mit einem oder mehreren dieser Tools sollten Sie in der Lage sein, die Arbeit in Ihrer Anwendung so zu strukturieren, dass die Anforderungen des Nutzers priorisiert werden, während weniger wichtige Aufgaben trotzdem erledigt werden. Das sorgt für eine bessere Nutzererfahrung, die reaktionsschneller und angenehmer ist.
Besonderer Dank gilt Philip Walton für die technische Überprüfung dieses Leitfadens.