Lange Aufgaben optimieren

Sie haben gehört, dass Sie den Hauptthread nicht blockieren und lange Aufgaben aufteilen sollen. Was bedeutet das?

Die gängigen Empfehlungen, wie Sie JavaScript-Anwendungen möglichst leistungsfähig halten, lassen sich auf die folgenden Punkte zusammenfassen:

  • „Blockieren Sie den Hauptthread nicht.“
  • „Lange Aufgaben in kleinere Teilaufgaben unterteilen.“

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.

Eine Visualisierung einer Aufgabe, wie sie im Leistungsprofil der Chrome-Entwicklertools dargestellt wird. Die Aufgabe befindet sich oben in einem Stack mit einem Klickereignis-Handler, einem Funktionsaufruf und weiteren Elementen darunter. Die Aufgabe umfasst auch einige Rendering-Arbeiten auf der rechten Seite.
Eine Aufgabe, die von einem click-Ereignishandler gestartet wurde und im Leistungsprofil der Chrome DevTools angezeigt wird.

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 ausgeführt wird, z. B. für Interaktionen über Ereignishandler, JavaScript-gestützte Animationen und Hintergrundaktivitäten wie die Analyseerhebung.

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.

Eine lange Aufgabe im Leistungsprofil der Chrome-Entwicklertools. Der blockierende Teil der Aufgabe (mehr als 50 Millisekunden) ist durch ein Muster aus roten diagonalen Streifen dargestellt.
Eine lange Aufgabe, wie im Leistungsprofil von Chrome dargestellt. Lange Aufgaben sind durch ein rotes Dreieck in der Ecke der Aufgabe gekennzeichnet. Der blockierende Teil der Aufgabe ist mit einem Muster aus diagonalen roten Streifen ausgefüllt.

Um zu verhindern, dass der Haupt-Thread zu lange blockiert wird, können Sie eine lange Aufgabe in mehrere kleinere aufteilen.

Eine einzelne lange Aufgabe im Vergleich zurselben Aufgabe, die in kürzere Aufgaben aufgeteilt ist. Die lange Aufgabe ist ein großes Rechteck, während die in kleinere Teile aufgeteilte Aufgabe aus fünf kleineren Rechtecken besteht, die zusammen die gleiche Breite wie die lange Aufgabe haben.
Eine Visualisierung einer einzelnen langen Aufgabe im Vergleich zu derselben Aufgabe, die in fünf kürzere Aufgaben unterteilt ist.

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.

Eine Darstellung, wie die Aufteilung einer Aufgabe die Nutzerinteraktion erleichtern kann. Oben blockiert eine lange Aufgabe die Ausführung eines Ereignis-Handlers, bis die Aufgabe abgeschlossen ist. Unten wird durch die in kleinere Teile aufgeteilte Aufgabe ermöglicht, dass der Ereignishandler früher ausgeführt wird als sonst.
Eine Visualisierung dessen, was mit Interaktionen passiert, wenn Aufgaben zu lang sind und der Browser nicht schnell genug darauf reagieren kann, im Vergleich dazu, wenn längere Aufgaben in kleinere Aufgaben unterteilt werden.

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.

Die Funktion „saveSettings“ wie im Leistungsprofil von Chrome dargestellt Die oberste Funktion ruft zwar fünf weitere Funktionen auf, aber die gesamte Arbeit wird in einer langen Aufgabe ausgeführt, die den Hauptthread blockiert.
Eine einzelne Funktion saveSettings(), die fünf Funktionen aufruft. Die Arbeit wird als Teil einer langen monolithischen Aufgabe ausgeführt.

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.

Codeausführung manuell verschieben

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. Die Verarbeitung der gesamten Datenmenge kann sehr lange dauern, auch wenn jede einzelne Iteration schnell ausgeführt wird. setTimeout() ist nicht das richtige Tool für diese Aufgabe – zumindest nicht, wenn es so verwendet wird.

Mit async/await Ertragspunkte erstellen

Damit wichtige Aufgaben für den Nutzer vor Aufgaben mit niedrigerer Priorität ausgeführt werden, können Sie dem Haupt-Thread übergeben, indem Sie die Aufgabenwarteschlange kurz unterbrechen, um dem Browser die Möglichkeit zu geben, wichtigere Aufgaben auszuführen.

Wie bereits erwähnt, kann setTimeout verwendet werden, um dem Haupt-Thread zu weichen. Der Einfachheit halber und für eine bessere Lesbarkeit können Sie setTimeout jedoch in einem Promise aufrufen und die resolve-Methode als Callback übergeben.

function yieldToMain () {
 
return new Promise(resolve => {
    setTimeout
(resolve, 0);
 
});
}

Der Vorteil der yieldToMain()-Funktion besteht darin, dass sie in jeder async-Funktion await verwendet werden kann. Aufbauend auf dem vorherigen Beispiel können Sie ein Array von Funktionen zum Ausführen erstellen und nach jeder Ausführung an den Hauptthread zurückgeben:

async function saveSettings () {
 
// Create an array of functions to run:
 
const tasks = [
    validateForm
,
    showSpinner
,
    saveToDatabase
,
    updateUI
,
    sendAnalytics
 
]

 
// Loop over the tasks:
 
while (tasks.length > 0) {
   
// Shift the first task off the tasks array:
   
const task = tasks.shift();

   
// Run the task:
    task
();

   
// Yield to the main thread:
    await yieldToMain
();
 
}
}

Das Ergebnis ist, dass die einst monolithische Aufgabe jetzt in separate Aufgaben aufgeteilt wird.

Dieselbe Funktion „saveSettings“, die im Leistungsprofil von Chrome dargestellt ist, nur mit Yielding. Die einst monolithische Aufgabe ist jetzt in fünf separate Aufgaben aufgeteilt – eine für jede Funktion.
Die saveSettings()-Funktion führt ihre untergeordneten Funktionen jetzt als separate Aufgaben aus.

Eine spezielle Scheduler API

setTimeout ist eine effektive Möglichkeit, Aufgaben aufzuteilen, kann aber einen Nachteil haben: Wenn Sie den Hauptthread durch Aussetzen von Code, der in einer nachfolgenden Aufgabe ausgeführt werden soll, aussetzen, wird diese Aufgabe am Ende der Warteschlange hinzugefügt.

Wenn Sie den gesamten Code auf Ihrer Seite verwalten, können Sie einen eigenen Scheduler erstellen, mit dem Sie Aufgaben priorisieren können. Scripts von Drittanbietern verwenden Ihren Scheduler jedoch nicht. In solchen Umgebungen können Sie Ihre Arbeit nicht priorisieren. Sie können sie nur in kleinere Abschnitte unterteilen oder explizit auf Nutzerinteraktionen reagieren.

Unterstützte Browser

  • Chrome: 94.
  • Edge: 94.
  • Firefox: hinter einer Flagge.
  • Safari: Nicht unterstützt.

Quelle

Die Scheduler API bietet die Funktion postTask(), die eine detailliertere Planung von Aufgaben ermöglicht. So kann der Browser Aufgaben priorisieren, sodass Aufgaben mit niedriger Priorität dem Hauptthread weichen. postTask() verwendet Versprechen und akzeptiert eine der drei priority-Einstellungen:

  • 'background' für Aufgaben mit der niedrigsten Priorität.
  • 'user-visible' für Aufgaben mit mittlerer Priorität. Das ist die Standardeinstellung, wenn kein priority festgelegt ist.
  • 'user-blocking' für kritische Aufgaben, die mit hoher Priorität ausgeführt werden müssen.

Im folgenden Codebeispiel wird die postTask() API verwendet, um drei Aufgaben mit der höchstmöglichen Priorität und die verbleibenden zwei Aufgaben mit der niedrigstmöglichen Priorität auszuführen.

function saveSettings () {
 
// Validate the form at high priority
  scheduler
.postTask(validateForm, {priority: 'user-blocking'});

 
// Show the spinner at high priority:
  scheduler
.postTask(showSpinner, {priority: 'user-blocking'});

 
// Update the database in the background:
  scheduler
.postTask(saveToDatabase, {priority: 'background'});

 
// Update the user interface at high priority:
  scheduler
.postTask(updateUI, {priority: 'user-blocking'});

 
// Send analytics data in the background:
  scheduler
.postTask(sendAnalytics, {priority: 'background'});
};

Hier wird die Priorität von Aufgaben so geplant, dass browserpriorisierte Aufgaben wie Nutzerinteraktionen bei Bedarf dazwischen ausgeführt werden können.

Die Funktion „saveSettings“, wie sie im Leistungsprofil von Chrome dargestellt ist, jedoch mit „postTask“. „postTask“ teilt die einzelnen Funktionen von „saveSettings“ auf und priorisiert sie so, dass eine Nutzerinteraktion ausgeführt werden kann, ohne blockiert zu werden.
Wenn saveSettings() ausgeführt wird, plant die Funktion die einzelnen Funktionen mit postTask(). Die kritischen, für den Nutzer sichtbaren Aufgaben werden mit hoher Priorität geplant, während Aufgaben, die der Nutzer nicht kennt, im Hintergrund ausgeführt werden. So können Nutzerinteraktionen schneller ausgeführt werden, da die Arbeit sowohl aufgeteilt als auch entsprechend priorisiert wird.

Dies ist ein einfaches Beispiel für die Verwendung von postTask(). Es ist möglich, verschiedene TaskController-Objekte zu instanziieren, die Prioritäten zwischen Aufgaben teilen können. Außerdem können die Prioritäten für verschiedene TaskController-Instanzen nach Bedarf geändert werden.

Integrierte Auslieferung mit Weiterleitung mit der scheduler.yield() API

Unterstützte Browser

  • Chrome: 129.
  • Edge: 129.
  • Firefox: Nicht unterstützt.
  • Safari: Nicht unterstützt.

Quelle

scheduler.yield() ist eine API, die speziell für das Übergeben an den Hauptthread im Browser entwickelt wurde. Die Verwendung ähnelt der Funktion yieldToMain(), die bereits in diesem Leitfaden vorgestellt wurde:

async function saveSettings () {
 
// Create an array of functions to run:
 
const tasks = [
    validateForm
,
    showSpinner
,
    saveToDatabase
,
    updateUI
,
    sendAnalytics
 
]

 
// Loop over the tasks:
 
while (tasks.length > 0) {
   
// Shift the first task off the tasks array:
   
const task = tasks.shift();

   
// Run the task:
    task
();

   
// Yield to the main thread with the scheduler
   
// API's own yielding mechanism:
    await scheduler
.yield();
 
}
}

Dieser Code ist weitgehend vertraut, aber anstelle von yieldToMain() wird await scheduler.yield() verwendet.

Drei Diagramme, die Aufgaben ohne Yielding, mit Yielding und mit Yielding und Fortsetzung darstellen Ohne Yielding gibt es lange Aufgaben. Bei der Yielding-Methode gibt es mehr Aufgaben, die kürzer sind, aber von anderen nicht zugehörigen Aufgaben unterbrochen werden können. Mit Yielding und Continuation gibt es mehr Aufgaben, die kürzer sind, aber ihre Ausführungsreihenfolge bleibt erhalten.
Wenn Sie scheduler.yield() verwenden, wird die Aufgabenausführung auch nach dem Yield-Punkt dort fortgesetzt, wo sie aufgehört hat.

Der Vorteil von scheduler.yield() ist die Fortsetzung. Wenn Sie also mitten in einer Reihe von Aufgaben yield ausführen, werden die anderen geplanten Aufgaben nach dem Yield-Punkt in derselben Reihenfolge fortgesetzt. So wird verhindert, dass Code von Drittanbieter-Scripts die Ausführungsreihenfolge Ihres Codes unterbricht.

isInputPending() nicht verwenden

Unterstützte Browser

  • Chrome: 87.
  • Edge: 87.
  • Firefox: Nicht unterstützt.
  • Safari: Nicht unterstützt.

Quelle

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() fälschlicherweise false 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.
  • Inzwischen wurden umfassendere APIs eingeführt, die Probleme mit der Auslieferung angehen, z. B. scheduler.postTask() und scheduler.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: Dies sind die wichtigsten Dinge, die Sie beim Verwalten von Aufgaben beachten sollten:

  • Für kritische, nutzerorientierte Aufgaben dem Hauptthread weichen.
  • Priorisieren Sie Aufgaben mit postTask().
  • Sie können scheduler.yield() ausprobieren.
  • Achten Sie darauf, dass Ihre Funktionen möglichst wenig Arbeit erfordern.

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.