Lange Aufgaben optimieren

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

Veröffentlicht: 30. September 2022, Letzte Aktualisierung: 19. Dezember 2024

Die gängigen Empfehlungen, um JavaScript-Apps schnell zu halten, lassen sich in der Regel auf Folgendes zusammenfassen:

  • „Blockieren Sie den Hauptthread nicht.“
  • „Teilen Sie lange Aufgaben auf.“

Das ist ein guter Ratschlag, aber was muss ich dafür tun? Weniger JavaScript zu senden ist gut, aber führt das automatisch zu reaktionsschnelleren Benutzeroberflächen? Vielleicht, aber vielleicht auch nicht.

Damit Sie wissen, wie Sie Aufgaben in JavaScript optimieren können, müssen Sie zuerst wissen, was Aufgaben sind und wie der Browser sie verarbeitet.

Was ist eine Aufgabe?

Eine Aufgabe ist eine einzelne Arbeitseinheit, die vom Browser ausgeführt wird. Dazu gehören das Rendern, das Parsen von HTML und CSS, das Ausführen von JavaScript und andere Vorgänge, die Sie möglicherweise nicht direkt steuern können. Das von Ihnen geschriebene JavaScript ist vielleicht die größte Quelle für Aufgaben.

Visualisierung einer Aufgabe im Leistungsprofiler der Chrome-Entwicklertools. Die Aufgabe befindet sich oben in einem Stapel mit einem Click-Event-Handler, einem Funktionsaufruf und weiteren Elementen darunter. Die Aufgabe umfasst auch einige Rendering-Vorgänge auf der rechten Seite.
Eine Aufgabe, die von einem click-Event-Handler gestartet wurde und im Leistungsprofiler der Chrome-Entwicklertools angezeigt wird.

Aufgaben, die mit JavaScript verknüpft sind, 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 JavaScript in die Warteschlange gestellt, damit es später ausgeführt werden kann.
  • Zu anderen Zeiten während des Lebenszyklus der Seite werden Aufgaben in die Warteschlange gestellt, wenn JavaScript Aufgaben ausführt, z. B. auf Interaktionen über Ereignishandler reagiert, JavaScript-basierte Animationen ausführt und Hintergrundaktivitäten wie die Erfassung von Analysedaten durchführt.

All das 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 das gesamte JavaScript, das Sie schreiben, wird dort ausgeführt.

Der Hauptthread kann jeweils nur eine Aufgabe verarbeiten. Jede Aufgabe, die länger als 50 Millisekunden dauert, ist eine lange Aufgabe. 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. Das ist für den Nutzer jedoch nicht wahrnehmbar, solange Aufgaben nicht zu lange ausgeführt werden. Wenn ein Nutzer versucht, mit einer Seite zu interagieren, auf der viele lange Aufgaben ausgeführt werden, reagiert die Benutzeroberfläche nicht. Wenn der Hauptthread sehr lange blockiert ist, kann es sogar sein, dass die Seite nicht mehr richtig funktioniert.

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

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

Eine einzelne lange Aufgabe im Vergleich zur selben Aufgabe, die in kürzere Aufgaben unterteilt ist. Die lange Aufgabe ist ein großes Rechteck, während die in Blöcke unterteilte Aufgabe fünf kleinere Rechtecke umfasst, die zusammen dieselbe Breite wie die lange Aufgabe haben.
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 aufgeteilten Aufgaben viel schneller auf Aufgaben mit höherer Priorität reagieren kann, einschließlich Nutzerinteraktionen. Anschließend werden die verbleibenden Aufgaben ausgeführt, sodass die Arbeit, die Sie ursprünglich in die Warteschlange gestellt haben, erledigt wird.

Eine Darstellung, wie das Aufteilen einer Aufgabe die Nutzerinteraktion erleichtern kann. Oben blockiert eine lange Aufgabe die Ausführung eines Ereignis-Handlers, bis die Aufgabe abgeschlossen ist. Unten ermöglicht die aufgeteilte Aufgabe, dass der Event-Handler früher ausgeführt wird als sonst.
Eine Visualisierung der Auswirkungen von zu langen Aufgaben auf Interaktionen, wenn der Browser nicht schnell genug auf Interaktionen reagieren kann, im Vergleich dazu, wenn längere Aufgaben in kleinere Aufgaben unterteilt werden.

Im oberen Teil des vorherigen Bildes musste ein durch eine Nutzerinteraktion in die Warteschlange gestellter Event-Handler auf eine einzelne lange Aufgabe warten, bevor er beginnen konnte. Dadurch verzögert sich die Interaktion. In diesem Fall 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 lernen, wie Sie das in JavaScript tun.

Strategien für das Aufgabenmanagement

Ein häufiger 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 Analysen zu senden.

Konzeptionell ist saveSettings() gut durchdacht. Wenn Sie eine dieser Funktionen debuggen müssen, können Sie den Projektbaum durchlaufen, um herauszufinden, was die einzelnen Funktionen tun. Wenn Sie die Arbeit auf diese Weise aufteilen, lassen sich Projekte leichter verwalten und pflegen.

Ein potenzielles Problem ist jedoch, dass JavaScript diese Funktionen nicht als separate Aufgaben 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“ im Chrome-Leistungsprofiler. Die Funktion der obersten Ebene ruft fünf andere Funktionen auf. Die gesamte Arbeit wird jedoch in einer langen Aufgabe erledigt. Das für den Nutzer sichtbare Ergebnis der Ausführung der Funktion ist erst sichtbar, wenn alle Aufgaben abgeschlossen sind.
Eine einzelne Funktion saveSettings(), die fünf Funktionen aufruft. Die Aufgabe wird als eine lange monolithische Aufgabe ausgeführt, wodurch alle visuellen Antworten blockiert werden, bis alle fünf Funktionen abgeschlossen sind.

Im besten Fall kann schon eine dieser Funktionen 50 Millisekunden oder mehr zur Gesamtdauer der Aufgabe beitragen. Im schlimmsten Fall können mehr dieser Aufgaben viel länger dauern, insbesondere auf Geräten mit begrenzten 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 diese lange Aufgabe zu einer langsamen und nicht reagierenden Benutzeroberfläche. Dies wird als schlechter Interaction to Next Paint (INP)-Wert gemessen.

Codeausführung manuell verzögern

Damit wichtige nutzerorientierte Aufgaben und UI-Antworten vor Aufgaben mit niedrigerer Priorität ausgeführt werden, können Sie den Hauptthread freigeben, indem Sie Ihre Arbeit kurz unterbrechen, damit der Browser wichtigere Aufgaben ausführen kann.

Eine Methode, mit der Entwickler Aufgaben in kleinere Aufgaben aufteilen, umfasst 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);
}

Das wird als Yielding bezeichnet und eignet sich 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. Sie haben beispielsweise 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 Entwicklerfreundlichkeit problematisch. Nach fünf verschachtelten setTimeout()-Aufrufen erzwingt der Browser eine Mindestverzögerung von 5 Millisekunden für jedes zusätzliche setTimeout().

setTimeout hat auch einen weiteren Nachteil beim Yielding: Wenn Sie den Hauptthread durch Verzögern von Code, der in einer nachfolgenden Aufgabe mit setTimeout ausgeführt werden soll, unterbrechen, wird diese Aufgabe am Ende der Warteschlange hinzugefügt. Wenn andere Aufgaben anstehen, werden sie vor dem verzögerten Code ausgeführt.

Eine spezielle Yielding-API: scheduler.yield()

Browser Support

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

Source

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 lediglich eine Funktion, die ein Promise zurückgibt, das in einer zukünftigen Aufgabe aufgelöst wird. Jeder Code, der verkettet ist, um nach der Auflösung von Promise ausgeführt zu werden (entweder in einer expliziten .then()-Kette oder nach dem await 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 Ausführung der Funktion wird an dieser Stelle angehalten und an den Hauptthread übergeben. Die Ausführung des Rests der Funktion, der als Fortsetzung der Funktion bezeichnet wird, wird für die Ausführung in einer neuen Event-Loop-Aufgabe geplant. Wenn diese Aufgabe beginnt, 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();
}
Die Funktion „saveSettings“ im Chrome-Leistungsprofiler, die jetzt in zwei Aufgaben unterteilt ist. Bei der ersten Aufgabe werden zwei Funktionen aufgerufen und dann wird die Ausführung unterbrochen, damit Layout- und Renderingvorgänge ausgeführt werden können und der Nutzer eine sichtbare Antwort erhält. Das Klickereignis wird dadurch in nur 64 Millisekunden abgeschlossen. Bei der zweiten Aufgabe werden die letzten drei Funktionen aufgerufen.
Die Ausführung der Funktion saveSettings() ist jetzt in zwei Aufgaben unterteilt. Layout und Rendering können also zwischen den Aufgaben ausgeführt werden, sodass der Nutzer schneller eine visuelle Reaktion erhält, was sich in der nun viel kürzeren 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 Yielding ausführen, wird die Fortsetzung der aktuellen Aufgabe vor dem Start anderer ähnlicher Aufgaben ausgeführt.

So wird verhindert, dass Code aus anderen Aufgabenquellen die Reihenfolge der Ausführung 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. Beim Yielding gibt es mehr Aufgaben, die kürzer sind, aber durch andere, nicht zusammenhängende Aufgaben unterbrochen werden können. Beim Yielding und bei der Fortsetzung gibt es mehr Aufgaben, die kürzer sind, aber ihre Ausführungsreihenfolge wird beibehalten.
Wenn Sie scheduler.yield() verwenden, wird die Fortsetzung an der Stelle fortgesetzt, an der sie unterbrochen wurde, bevor mit anderen Aufgaben fortgefahren wird.

Browserübergreifende Unterstützung

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 sorgt dafür, dass auf andere Funktionen zur Aufgabenplanung zurückgegriffen wird, sodass es in allen Browsern ähnlich funktioniert.

Alternativ kann eine weniger anspruchsvolle 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 nicht die priorisierte Fortsetzung, aber sie geben trotzdem nach, damit der Browser reaktionsfähig bleibt.

Schließlich kann es Fälle geben, in denen Ihr Code es sich nicht leisten kann, den Hauptthread zu blockieren, wenn seine Fortsetzung nicht priorisiert wird. Das kann beispielsweise auf einer Seite mit hoher Aktivität der Fall sein, auf der das Blockieren des Hauptthreads dazu führen kann, dass die Arbeit für einige Zeit nicht abgeschlossen wird. In diesem Fall könnte scheduler.yield() als eine Art progressive Verbesserung behandelt werden: Die Ausführung wird in Browsern fortgesetzt, in denen scheduler.yield() verfügbar ist. Andernfalls wird die Ausführung fortgesetzt.

Dies kann sowohl durch die Erkennung von Funktionen als auch durch das Warten auf einen einzelnen Mikrotask in einer praktischen Einzeiler erfolgen:

// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();

Lang andauernde Aufgaben mit scheduler.yield() aufteilen

Der Vorteil der Verwendung einer dieser Methoden für scheduler.yield() besteht darin, dass Sie await in jeder async-Funktion verwenden können.

Wenn Sie beispielsweise ein Array von Jobs haben, die oft zu einer langen Aufgabe führen, 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. Sie müssen nicht warten, bis die möglicherweise lange Liste von Aufgaben abgeschlossen ist.

Dies ist jedoch keine effiziente Verwendung von Yielding. scheduler.yield() ist schnell und effizient, verursacht 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 Yielding und Fortsetzen als für die eigentliche Arbeit aufgewendet wird.

Eine Möglichkeit besteht darin, die Jobs in Batches zu verarbeiten und nur zwischen ihnen zu wechseln, wenn seit dem letzten Wechsel genügend Zeit vergangen ist. Eine gängige Frist sind 50 Millisekunden, um zu verhindern, dass Aufgaben zu langen Aufgaben werden. Sie kann jedoch als Kompromiss zwischen Reaktionsfähigkeit und Zeit zum Abschließen der Aufgabenwarteschlange 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();
    }
  }
}

Das Ergebnis ist, dass die Jobs so aufgeteilt werden, dass die Ausführung nie zu lange dauert. Der Runner gibt den Hauptthread jedoch nur etwa alle 50 Millisekunden frei.

Eine Reihe von Jobfunktionen, die im Leistungsbereich der Chrome-Entwicklertools angezeigt werden und deren Ausführung in mehrere Aufgaben unterteilt ist
Jobs, die in mehreren Aufgaben zusammengefasst wurden.

isInputPending() nicht verwenden

Browser Support

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

Source

Die isInputPending() API bietet eine Möglichkeit, zu prüfen, ob ein Nutzer versucht hat, mit einer Seite zu interagieren, und nur dann zu rendern, 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 Leistungssteigerungen führen, wie im Intent to Ship beschrieben. Das gilt insbesondere für Websites, die sonst nicht zum Hauptthread zurückkehren.

Seit der Einführung dieser API haben wir jedoch mehr über die Rendite erfahren, insbesondere durch die Einführung von INP. Wir empfehlen nicht mehr, diese API zu verwenden, und empfehlen stattdessen, unabhängig davon, ob Eingaben ausstehen oder nicht, aus folgenden Gründen:

  • isInputPending() kann unter Umständen fälschlicherweise false zurückgeben, obwohl ein Nutzer interagiert hat.
  • Die Eingabe ist nicht der einzige Fall, in dem Aufgaben nachgeben sollten. Animationen und andere regelmäßige Aktualisierungen der Benutzeroberfläche können für eine reaktionsschnelle Webseite ebenso wichtig sein.
  • Inzwischen wurden umfassendere Yield-APIs eingeführt, die Yield-Bedenken ausräumen, z. B. scheduler.postTask() und scheduler.yield().

Fazit

Die Verwaltung von Aufgaben ist zwar eine Herausforderung, sorgt aber dafür, dass Ihre Seite schneller auf Nutzerinteraktionen reagiert. Es gibt nicht die eine richtige Methode, um Aufgaben zu verwalten und zu priorisieren, sondern eine Reihe verschiedener Techniken. Zusammenfassend sind dies die wichtigsten Punkte, die Sie bei der Verwaltung von Aufgaben beachten sollten:

  • Geben Sie den Hauptthread für kritische, nutzerorientierte Aufgaben frei.
  • Verwenden Sie scheduler.yield() (mit einem browserübergreifenden Fallback), um ergonomisch zu yielden 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 relativ zu scheduler.postTask() und der Priorisierung von Aufgaben finden Sie in der Dokumentation zur Prioritized Task Scheduling API.

Mit einem oder mehreren dieser Tools können Sie die Arbeit in Ihrer Anwendung so strukturieren, dass die Bedürfnisse des Nutzers priorisiert werden und gleichzeitig weniger wichtige Aufgaben erledigt werden. Das führt zu einer besseren Nutzerfreundlichkeit, da die App reaktionsschneller ist und sich angenehmer bedienen lässt.

Besonderer Dank an Philip Walton für die technische Überprüfung dieser Anleitung.

Das Thumbnail-Bild stammt von Unsplash und wurde von Amirali Mirhashemian zur Verfügung gestellt.