Lange Aufgaben optimieren

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

Um die Geschwindigkeit von JavaScript-Anwendungen zu optimieren, sind folgende Tipps am wichtigsten:

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

Das ist ein guter Rat, aber welche Arbeit beinhaltet er? Es ist gut, weniger JavaScript zu verwenden. Aber bedeutet das automatisch, dass die Benutzeroberflächen reaktionsschneller sind? 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. Zu diesen Aufgaben gehören Rendering, das Parsen von HTML und CSS, das Ausführen von JavaScript und andere Arten von Arbeiten, über die Sie möglicherweise keine direkte Kontrolle 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 an der Spitze eines Stapels und enthält einen Click-Event-Handler, einen Funktionsaufruf und weitere Elemente 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.

Mit JavaScript verknüpfte Aufgaben wirken sich auf mehrere Arten auf die Leistung aus:

  • Wenn ein Browser beim Start eine JavaScript-Datei herunterlädt, stellt er Aufgaben in die Warteschlange, um das JavaScript zu parsen und zu kompilieren, 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.

Wie lautet der Hauptthread?

Im Hauptthread werden die meisten Aufgaben im Browser und fast das gesamte von Ihnen geschriebene JavaScript ausgeführt.

Der Hauptthread kann jeweils nur eine Aufgabe verarbeiten. 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 der Ausführung einer Aufgabe beliebiger Länge. Dies ist für den Nutzer jedoch nicht wahrnehmbar, solange die Aufgaben nicht zu lange ausgeführt werden. 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 langwierige Aufgabe im Performance-Profiler der Chrome-Entwicklertools. Der blockierende Teil der Aufgabe (über 50 Millisekunden) wird durch ein Muster aus roten diagonalen Streifen dargestellt.
Eine lange Aufgabe, wie sie im Leistungsprofiler von Chrome angezeigt wird. 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 insgesamt 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.

Wenn Aufgaben aufgeteilt werden, kann der Browser viel früher auf Aufgaben mit höherer Priorität reagieren – 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 Ereignis-Handler, 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 für die 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, ein rotierendes Ladesymbol 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 besten Fall kann auch nur 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, die Entwickler verwendet haben, um Aufgaben in kleinere Aufgaben aufzuteilen, verwendet setTimeout(). Mit diesem Verfahren übergeben Sie die Funktion an setTimeout(). Dadurch wird die Ausführung des Callbacks auf eine separate Aufgabe verschoben, selbst 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 nutzerorientierte Aufgaben 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. Das Ergebnis ist, dass die ehemals monolithische Aufgabe nun in fünf separate Aufgaben aufgeteilt ist – 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 diesem Fall können Sie die Arbeit in solchen Umgebungen 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 Promis und akzeptiert eine von drei priority-Einstellungen:

  • 'background' für Aufgaben mit der niedrigsten Priorität.
  • 'user-visible' für Aufgaben mit mittlerer Priorität. Das ist der Standardwert, 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 niedrigsten 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, z. B. Nutzerinteraktionen, nach Bedarf zwischen diesen beiden Aufgaben ausgeführt werden können.

Die Funktion „saveSettings“, wie im Performance-Profiler von Chrome dargestellt, unter Verwendung von postTask. postTask teilt jede Ausführung von „saveSettings“ auf und priorisiert sie so, dass eine Nutzerinteraktion die Möglichkeit hat, ausgeführt zu werden, ohne blockiert zu werden.
Wenn saveSettings() ausgeführt wird, plant die Funktion die einzelnen Funktionen mit postTask(). Die kritischen nutzerorientierten 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 gemeinsame Prioritäten zwischen Aufgaben haben können, einschließlich der Möglichkeit, Prioritäten für verschiedene TaskController-Instanzen nach Bedarf zu ändern.

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. Ihre Verwendung ähnelt der yieldToMain()-Funktion, die weiter oben in diesem Leitfaden gezeigt 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. Es gibt mehr Aufgaben, die kürzer sind, aber möglicherweise durch andere Aufgaben unterbrochen werden, die nichts mit dem Projekt zu tun haben. 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: wird 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.

Auf diese Weise kann JavaScript fortfahren, wenn keine Eingaben ausstehen, anstatt nachzugeben und am Ende der Aufgabenwarteschlange zu landen. 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 Auslieferung gelernt, insbesondere durch die Einführung von INP. Wir empfehlen die Verwendung dieser API nicht mehr, sondern aus verschiedenen Gründen, unabhängig davon, ob Eingaben ausstehen:

  • 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 Aktualisierungen der Benutzeroberfläche können für eine responsive Webseite genauso 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. Bei der Verwaltung und Priorisierung von Aufgaben gibt es keinen einzigen Rat, sondern eine Reihe unterschiedlicher Techniken. Noch einmal: Dies sind die wichtigsten Dinge, die Sie beim Verwalten von Aufgaben beachten sollten:

  • Für kritische, nutzerorientierte Aufgaben dem Hauptthread weichen.
  • Aufgaben mit postTask() priorisieren.
  • Sie können scheduler.yield() ausprobieren.
  • Zu guter Letzt gilt: Arbeiten Sie so wenig wie möglich in Ihren Funktionen vor.

Mit einem oder mehreren dieser Tools sollten Sie in der Lage sein, die Arbeit in Ihrer Anwendung so zu strukturieren, dass die Anforderungen der Nutzenden priorisiert werden, während gleichzeitig sichergestellt wird, dass weniger kritische Arbeiten ausgeführt 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.

Miniaturansicht stammt von Unsplash, mit freundlicher Genehmigung von Amirali Mirhashemian.