Sie haben gehört, dass Sie den Hauptthread nicht blockieren und lange Aufgaben aufteilen sollten. Was bedeutet das?
Veröffentlicht: 30. September 2022, letzte Aktualisierung: 19. Dezember 2024
Die gängigen Ratschläge, wie Sie JavaScript-Anwendungen möglichst leistungsfähig halten, lassen sich auf die folgenden Punkte zusammenfassen:
- „Blockieren Sie nicht den Haupt-Thread.“
- „Lange Aufgaben in kleinere Teilaufgaben aufteilen.“
Das ist ein guter Rat, aber was bedeutet das konkret? Es ist gut, weniger JavaScript zu verwenden. Aber bedeutet das automatisch, dass die Benutzeroberfläche reaktionsschneller ist? Vielleicht, aber vielleicht auch nicht.
Um zu verstehen, wie Sie Aufgaben in JavaScript optimieren, müssen Sie zuerst wissen, was Aufgaben sind und wie der Browser damit umgeht.
Was ist eine Aufgabe?
Eine Aufgabe ist eine einzelne Arbeit, die der Browser ausführt. Dazu gehören das Rendern, Parsen von HTML und CSS, das Ausführen von JavaScript und andere Arten von Aufgaben, auf die Sie möglicherweise keinen direkten Einfluss haben. Von all diesen Aufgaben ist das von Ihnen geschriebene JavaScript die größte Quelle.
Aufgaben, die mit JavaScript verbunden sind, wirken sich auf unterschiedliche Weise auf die Leistung aus:
- Wenn ein Browser beim Starten eine JavaScript-Datei herunterlädt, werden Aufgaben zur Auswertung und Kompilierung 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 funktioniert, z. B. wenn auf Interaktionen über Ereignishandler reagiert wird, JavaScript-gestützte Animationen ausgeführt werden und Hintergrundaktivitäten wie die Analysedatenerhebung stattfinden.
All dies geschieht mit Ausnahme von Webworkern und ähnlichen APIs im Haupt-Thread.
Was ist der Hauptthread?
Im Hauptthread werden die meisten Aufgaben im Browser ausgeführt und fast alle von Ihnen geschriebenen JavaScript-Codeblöcke.
Der Hauptthread kann jeweils nur eine Aufgabe verarbeiten. Alle Aufgaben, die länger als 50 Millisekunden dauern, sind lange Aufgaben. Bei Aufgaben, die 50 Millisekunden überschreiten, 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. Das ist für den Nutzer jedoch nicht wahrnehmbar, solange die Aufgaben nicht zu lange laufen. Wenn ein Nutzer jedoch versucht, mit einer Seite zu interagieren, während viele lange Aufgaben ausgeführt werden, reagiert die Benutzeroberfläche nicht und ist möglicherweise sogar defekt, wenn der Hauptthread für sehr lange Zeit blockiert ist.
Um zu verhindern, dass der Haupt-Thread zu lange blockiert wird, können Sie eine lange Aufgabe in mehrere kleinere aufteilen.
Das ist wichtig, weil der Browser bei Aufteilung von 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 gestellte Arbeit erledigt wird.
Oben in der Abbildung musste ein Ereignishandler, der durch eine Nutzerinteraktion in die Warteschlange gestellt wurde, auf eine einzelne lange Aufgabe warten, bevor er beginnen konnte. Dadurch wird die Interaktion verzögert. In diesem Fall hat der Nutzer möglicherweise eine Verzögerung bemerkt. Unten kann der Ereignishandler früher ausgeführt werden und die Interaktion wirkt sofort.
Nachdem Sie nun wissen, warum es wichtig ist, Aufgaben aufzuteilen, können Sie lernen, wie Sie dies in JavaScript tun.
Strategien zur Aufgabenverwaltung
Ein häufiger Ratschlag in der Softwarearchitektur besteht darin, 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 Ladebalken anzuzeigen, Daten an das Anwendungs-Backend zu senden, die Benutzeroberfläche zu aktualisieren und Analysen zu senden.
Konzeptionell ist saveSettings()
gut konzipiert. Wenn Sie eine dieser Funktionen debuggen möchten, 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 besteht jedoch darin, dass JavaScript jede dieser Funktionen nicht als separate Aufgabe ausführt, da sie innerhalb der saveSettings()
-Funktion ausgeführt werden. Das bedeutet, dass alle fünf Funktionen als eine Aufgabe ausgeführt werden.
Im Bestfall 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 laufen – insbesondere auf Geräten mit begrenzten Ressourcen.
In diesem Fall wird saveSettings()
durch einen Nutzerklick ausgelöst. Da der Browser erst eine Antwort anzeigen kann, wenn die gesamte Funktion ausgeführt wurde, führt diese lange Aufgabe zu einer langsamen und nicht reagierenden Benutzeroberfläche. Dies wird als schlechte Interaktion bis zur nächsten Zeichnen-Aktion (Interaction to Next Paint, INP) gemessen.
Codeausführung manuell verschieben
Damit wichtige Aufgaben für Nutzer und UI-Antworten vor Aufgaben mit niedrigerer Priorität ausgeführt werden, können Sie dem Hauptthread weichen, indem Sie Ihre Arbeit kurz unterbrechen, um dem Browser die Möglichkeit zu geben, wichtigere Aufgaben auszuführen.
Eine Methode, mit der Entwickler Aufgaben in kleinere unterteilen, ist setTimeout()
. Dabei übergeben Sie die Funktion an setTimeout()
. Dadurch wird die Ausführung des Rückrufs 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 funktioniert am besten für eine Reihe von Funktionen, die sequenziell ausgeführt werden müssen.
Ihr Code ist jedoch möglicherweise nicht immer so organisiert. Angenommen, Sie haben eine große Menge an Daten, die in einer Schleife verarbeitet werden müssen. Diese Aufgabe kann 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 Ergonomie für Entwickler problematisch. Nach fünf verschachtelten setTimeout()
s erfordert der Browser für jede weitere setTimeout()
eine Verzögerung von mindestens 5 Millisekunden.
setTimeout
hat noch einen weiteren Nachteil, wenn es um das Ausführen geht: Wenn Sie den Hauptthread durch Aussetzen von Code, der in einer nachfolgenden Aufgabe mit setTimeout
ausgeführt werden soll, aussetzen, wird diese Aufgabe am Ende der Warteschlange hinzugefügt. Wenn andere Aufgaben warten, werden diese vor Ihrem verzögerten Code ausgeführt.
Eine spezielle API für die Auslieferung: scheduler.yield()
scheduler.yield()
ist eine API, die speziell für das Übergeben 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 eine Promise
zurückgibt, die in einer zukünftigen Aufgabe aufgelöst wird. Code, der so verkettet ist, dass er nach der Auflösung von Promise
ausgeführt wird (entweder in einer expliziten .then()
-Kette oder nach der await
-Ausführung in einer asynchronen Funktion), wird dann in dieser zukünftigen Aufgabe ausgeführt.
In der Praxis: Wenn Sie await scheduler.yield()
einfügen, wird die Ausführung der Funktion an dieser Stelle pausiert und der Hauptthread wird fortgesetzt. Die Ausführung des Rests der Funktion, die Fortsetzung der Funktion, wird in einem neuen Ereignisschleifen-Task geplant. Wenn diese Aufgabe gestartet wird, wird das erwartete Versprechen aufgelöst und die Funktion wird dort fortgesetzt, wo 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();
}
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 Yield ausführen, wird die Fortsetzung der aktuellen Aufgabe bevor andere ähnliche Aufgaben gestartet werden.
So wird verhindert, dass Code aus anderen Aufgabenquellen die Ausführungsreihenfolge Ihres Codes unterbricht, z. B. Aufgaben aus Drittanbieter-Scripts.
Browserübergreifende Unterstützung
scheduler.yield()
wird noch nicht von allen Browsern unterstützt. Daher ist ein Fallback erforderlich.
Eine Lösung besteht darin, scheduler-polyfill
in Ihren Build einzufügen. Anschließend kann scheduler.yield()
direkt verwendet werden. Die Polyfill übernimmt den Rückfall auf andere Funktionen zur Aufgabenplanung, sodass die Funktion in allen Browsern ähnlich funktioniert.
Alternativ kann eine weniger ausgefeilte 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);
});
}
In Browsern ohne scheduler.yield()
-Unterstützung wird die priorisierte Fortsetzung nicht verwendet. Sie sorgen jedoch dafür, dass der Browser weiterhin reaktionsschnell bleibt.
Schließlich kann es Fälle geben, in denen Ihr Code nicht dem Hauptthread weichen kann, wenn die Fortsetzung nicht priorisiert wird (z. B. eine bekanntermaßen stark ausgelastete Seite, bei der das Ausweichen das Risiko birgt, dass die Arbeit einige Zeit lang nicht abgeschlossen wird). In diesem Fall könnte scheduler.yield()
als eine Art progressive Verbesserung behandelt werden: In Browsern, in denen scheduler.yield()
verfügbar ist, wird die Funktion genutzt, andernfalls wird fortgefahren.
Das kann sowohl durch Feature-Erkennung als auch durch das Warten auf eine einzelne Mikroaufgabe in einem praktischen One-Liner erfolgen:
// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();
Lang andauernde Arbeit mit scheduler.yield()
aufteilen
Der Vorteil dieser Methoden besteht darin, dass Sie scheduler.yield()
in jeder async
-Funktion await
können.
Wenn Sie beispielsweise eine Reihe von Jobs ausführen müssen, die zusammengenommen oft eine lange Aufgabe ergeben, können Sie Erträge 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 wird weiterhin möglich sein, Aufgaben mit höherer Priorität auszuführen, z. B. die visuelle Reaktion auf Nutzereingaben, ohne auf das Ende der potenziell langen Liste von Jobs warten zu müssen.
Dies ist jedoch keine effiziente Nutzung der Yield-Funktion. scheduler.yield()
ist schnell und effizient, hat aber einen gewissen Overhead. 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 Ausführung aufgewendet wird.
Eine Möglichkeit besteht darin, die Jobs zu einem Batch zusammenzufassen und nur dann eine Auslieferung vorzunehmen, wenn seit der letzten Auslieferung genügend Zeit vergangen ist. Ein gängiger Termin ist 50 Millisekunden, um zu verhindern, dass Aufgaben zu lang werden. Er kann jedoch als Kompromiss zwischen Reaktionsfähigkeit und Zeit für die Ausführung der Jobwarteschlange angepasst werden.
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();
}
}
}
Die Jobs werden so aufgeteilt, dass sie nie zu lange dauern, aber der Runner gibt den Hauptthread nur etwa alle 50 Millisekunden frei.
isInputPending()
nicht verwenden
Mit der isInputPending()
API können Sie prüfen, ob ein Nutzer versucht hat, mit einer Seite zu interagieren. Die API liefert nur dann eine Antwort, wenn eine Eingabe ausstehend ist.
So kann JavaScript fortgesetzt werden, wenn keine Eingaben ausstehend sind, anstatt zu pausieren und ans Ende der Aufgabenwarteschlange zu gelangen. Dies kann zu beeindruckenden Leistungsverbesserungen führen, wie im Abschnitt Intent to Ship beschrieben, für Websites, die sonst nicht zum Hauptthread zurückkehren würden.
Seit der Einführung dieser API haben wir jedoch mehr über die Leistung erfahren, insbesondere durch die Einführung von INP. Wir empfehlen nicht mehr, diese API zu verwenden. Stattdessen sollten Sie aus mehreren Gründen unabhängig davon, ob Eingaben ausstehend sind oder nicht, eine Ausgabe zurückgeben:
- In einigen Fällen gibt
isInputPending()
möglicherweise fälschlicherweisefalse
zurück, obwohl ein Nutzer interagiert hat. - Aufgaben sollten nicht nur bei Eingaben ein Ergebnis liefern. Animationen und andere regelmäßige Updates der Benutzeroberfläche können für eine responsive Webseite ebenso wichtig sein.
- Es wurden inzwischen umfassendere APIs eingeführt, die Probleme mit der Auslieferung angehen, z. B.
scheduler.postTask()
undscheduler.yield()
.
Fazit
Die Verwaltung von Aufgaben ist eine Herausforderung, aber so reagiert Ihre Seite schneller auf Nutzerinteraktionen. Es gibt nicht den einen Ratschlag für die Verwaltung und Priorisierung von Aufgaben, sondern eine Reihe verschiedener Techniken. Noch einmal: Das sind die wichtigsten Dinge, die Sie beim Verwalten von Aufgaben beachten sollten:
- Für kritische, nutzerorientierte Aufgaben dem Hauptthread weichen.
- Verwenden Sie
scheduler.yield()
(mit einem browserübergreifenden Fallback), um ergonomisch zu weichen und priorisierte Fortsetzungen zu erhalten. - Achten Sie darauf, dass Ihre Funktionen möglichst wenig Arbeit erfordern.
Weitere Informationen zu scheduler.yield()
, der relativen scheduler.postTask()
für die explizite Aufgabenplanung und zur Aufgabenpriorisierung finden Sie in der API-Dokumentation für die priorisierte Aufgabenplanung.
Mit einem oder mehreren dieser Tools sollten Sie in der Lage sein, die Arbeit in Ihrer Anwendung so zu strukturieren, dass die Anforderungen der Nutzer priorisiert werden, während gleichzeitig dafür gesorgt wird, dass weniger kritische Aufgaben erledigt werden. Das sorgt für eine bessere Nutzerfreundlichkeit, da die App reaktionsschneller und nutzerfreundlicher ist.
Ein besonderer Dank geht an Philip Walton für die technische Überprüfung dieses Leitfadens.
Miniaturansichtsbild von Unsplash, mit freundlicher Genehmigung von Amirali Mirhashemian.