Lange Aufgaben optimieren

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.

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 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.

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, sodass das sichtbare Ergebnis der Ausführung der Funktion erst sichtbar wird, wenn alle abgeschlossen sind.
Eine einzelne Funktion saveSettings(), die fünf Funktionen aufruft. Die Arbeit wird als Teil einer langen monolithischen Aufgabe ausgeführt, wobei jede visuelle Antwort blockiert wird, bis alle fünf Funktionen abgeschlossen sind.

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()

Browser Support

  • Chrome: 129.
  • Edge: 129.
  • Firefox: not supported.
  • Safari: not supported.

Source

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();
}
Die Funktion „saveSettings“, wie sie im Leistungsprofil von Chrome dargestellt ist, wurde in zwei Aufgaben aufgeteilt. Die erste Aufgabe ruft zwei Funktionen auf und gibt dann eine Yield-Anweisung aus, damit das Layout und die Paint-Arbeit ausgeführt werden und dem Nutzer eine sichtbare Antwort gegeben wird. Das Klickereignis ist daher in nur 64 Millisekunden abgeschlossen. Bei der zweiten Aufgabe werden die letzten drei Funktionen aufgerufen.
Die Ausführung der Funktion saveSettings() wird jetzt auf zwei Aufgaben aufgeteilt. So können Layout und Paint zwischen den Aufgaben ausgeführt werden, was dem Nutzer eine schnellere visuelle Reaktion bietet, was sich an der jetzt viel kürzeren Mausinteraktion messen lässt.

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.

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 Fortsetzung dort fortgesetzt, wo Sie aufgehört haben, bevor Sie mit anderen Aufgaben fortfahren.

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.

Eine Reihe von Jobfunktionen, die im Chrome DevTools-Leistungsbereich angezeigt werden und deren Ausführung auf mehrere Aufgaben aufgeteilt ist
Jobs, die in mehrere Aufgaben gruppiert sind.

isInputPending() nicht verwenden

Browser Support

  • Chrome: 87.
  • Edge: 87.
  • Firefox: not supported.
  • Safari: not supported.

Source

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ä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.
  • Es wurden inzwischen 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: 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.