Lange Aufgaben optimieren

Dir wurde gesagt, dass du den Hauptthread nicht blockierst und lange Aufgaben aufbricht. Aber was genau bedeutet das?

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

  • „Blockiere nicht den Hauptthread.“
  • „Teile deine langen Aufgaben auf.“

Das ist ein guter Rat, aber welche Arbeit beinhaltet er? JavaScript mit weniger Versand ist gut, aber entspricht das automatisch einer reaktionsschnelleren Benutzeroberfläche? Vielleicht, aber nicht.

Um zu verstehen, wie Aufgaben in JavaScript optimiert werden können, müssen Sie zunächst wissen, was Aufgaben sind und wie der Browser sie verarbeitet.

Was ist eine Aufgabe?

Eine Aufgabe ist jede eigenständige 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 dem ist das von Ihnen geschriebene JavaScript wahrscheinlich die größte Quelle von Aufgaben.

Eine Visaulisierung einer Aufgabe, wie im Leistungsprofil der DevTools von Chrome dargestellt. Die Aufgabe befindet sich an der Spitze eines Stapels und enthält einen Click-Event-Handler, einen Funktionsaufruf und weitere Elemente darunter. Die Aufgabe beinhaltet auch einige Rendering-Arbeiten auf der rechten Seite.
Eine Aufgabe, die von einem click-Event-Handler gestartet wurde und im Leistungsprofiler der Chrome-Entwicklertools angezeigt wird.

Mit JavaScript verknüpfte Aufgaben haben mehrere Auswirkungen auf die Leistung:

  • Wenn ein Browser beim Start eine JavaScript-Datei herunterlädt, stellt er Aufgaben in die Warteschlange, um dieses 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 funktioniert, wie z. B. das Weiterleiten von Interaktionen über Event-Handler, JavaScript-gesteuerte Animationen und Hintergrundaktivitäten wie die Erfassung von Analysedaten.

Mit Ausnahme von Web Workern und ähnlichen APIs geschieht das alles im Hauptthread.

Was ist 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 länger als 50 Millisekunden sind, wird die Gesamtzeit der Aufgabe minus 50 Millisekunden als Blockierzeitraum bezeichnet.

Der Browser blockiert Interaktionen, während eine Aufgabe beliebiger Länge ausgeführt wird. Dies ist für den Nutzer jedoch nicht wahrnehmbar, solange die Aufgaben nicht zu lange ausgeführt werden. Wenn ein Nutzer versucht, mit einer Seite zu interagieren, obwohl es viele lange Aufgaben gibt, reagiert die Benutzeroberfläche nicht mehr und ist möglicherweise sogar fehlerhaft, wenn der Hauptthread sehr lange 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 Hauptthread zu lange blockiert wird, können Sie eine lange Aufgabe in mehrere kleinere Aufgaben aufteilen.

Eine einzelne lange Aufgabe und dieselbe Aufgabe, die in kürzere Aufgaben aufgeteilt wird. Die lange Aufgabe besteht aus einem großen Rechteck, während die aufgeteilte Aufgabe aus fünf kleineren Feldern besteht, die zusammen dieselbe Breite wie die lange Aufgabe haben.
Eine Visualisierung, bei der eine einzelne lange Aufgabe im Vergleich zur selben Aufgabe mit fünf kürzeren Aufgaben dargestellt wird.

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 Abschluss ausgeführt. So wird sichergestellt, dass die Arbeit, die Sie anfangs in die Warteschlange gestellt haben, erledigt wird.

Eine Darstellung, wie das Aufteilen einer Aufgabe die Interaktion der Nutzenden erleichtern kann. Oben wird durch eine lange Aufgabe die Ausführung eines Event-Handlers blockiert, bis die Aufgabe abgeschlossen ist. Im unteren Bereich ermöglicht die aufgeteilte Aufgabe, dass der Ereignis-Handler früher ausgeführt werden kann, als dies sonst der Fall wäre.
Eine Visualisierung dessen, was mit Interaktionen passiert, wenn Aufgaben zu lang sind und der Browser nicht schnell genug auf Interaktionen reagieren kann, oder was mit längeren Aufgaben in kleinere Aufgaben aufgeteilt wird.

Oben in der vorherigen Abbildung musste ein Event-Handler, der von einer Nutzerinteraktion in die Warteschlange gestellt wurde, auf eine einzelne lange Aufgabe warten, bevor sie beginnen konnte. Dadurch wird die Interaktion verzögert. In diesem Szenario hat der Nutzer möglicherweise eine Verzögerung bemerkt. Unten kann die Ausführung des Event-Handlers früher beginnen, sodass sich die Interaktion möglicherweise sofort angefühlt hat.

Jetzt wissen Sie, warum es wichtig ist, Aufgaben aufzuteilen. Als Nächstes erfahren Sie, wie Sie dies in JavaScript tun.

Strategien zur Aufgabenverwaltung

Ein häufiger Rat in der Softwarearchitektur besteht darin, Ihre Arbeit in kleinere Funktionen zu unterteilen:

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.

saveSettings() ist konzeptionell gut strukturiert. Wenn Sie eine dieser Funktionen debuggen müssen, können Sie den Projektbaum durchlaufen, um herauszufinden, was die einzelnen Funktionen bewirken. Das Aufteilen von Aufgaben wie diese erleichtert das Navigieren und Verwalten von Projekten.

Ein potenzielles Problem ist hier jedoch, dass JavaScript nicht jede dieser Funktionen als separate Aufgaben ausführt, weil sie innerhalb der Funktion saveSettings() ausgeführt werden. Das bedeutet, dass alle fünf Funktionen als eine Aufgabe ausgeführt werden.

Die Funktion „saveSettings“, wie im Leistungsprofil-Tool von Chrome dargestellt. Während die Funktion der obersten Ebene fünf weitere Funktionen aufruft, erfolgt die gesamte Arbeit in einer langen Aufgabe, die den Hauptthread blockiert.
Eine einzelne Funktion saveSettings(), die fünf Funktionen aufruft. Die Arbeit wird im Rahmen 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 Aufgaben deutlich länger ausgeführt werden, insbesondere auf Geräten mit beschränkten Ressourcen.

Codeausführung manuell aufschieben

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 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 auf diese Weise organisiert. Sie könnten beispielsweise eine große Menge an Daten haben, die in einer Schleife verarbeitet werden muss, und diese Aufgabe könnte 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 der Entwickler problematisch. Die Verarbeitung des gesamten Datenarrays könnte sehr lange dauern, selbst wenn jede einzelne Iteration schnell ausgeführt wird. Alles summiert sich, und setTimeout() ist nicht das richtige Tool für diese Aufgabe – zumindest nicht, wenn es so verwendet wird.

async/await verwenden, um Ertragspartner zu generieren

Damit wichtige, an den Nutzer gerichtete Aufgaben vor Aufgaben mit niedrigerer Priorität stattfinden, können Sie zum Hauptthread wechseln. Unterbrechen Sie dazu die Aufgabenwarteschlange kurz, damit der Browser wichtigere Aufgaben ausführen kann.

Wie bereits erläutert, kann setTimeout verwendet werden, um dem Hauptthread nachzugeben. Zur besseren Lesbarkeit können Sie setTimeout innerhalb einer Promise aufrufen und die zugehörige resolve-Methode als Callback übergeben.

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

Der Vorteil der yieldToMain()-Funktion besteht darin, dass Sie sie in jeder async-Funktion mit await versehen können. Basierend auf dem vorherigen Beispiel könnten Sie ein Array mit Funktionen erstellen, die ausgeführt werden sollen, und den Hauptthread nach jeder Ausführung 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 führt dazu, dass die ehemals monolithische Aufgabe in separate Aufgaben aufgeteilt wird.

Dieselbe Funktion „saveSettings“ wird im Leistungsprofil-Tool von Chrome angezeigt, nur mit „Nachgeben“. Das Ergebnis ist, dass die ehemals monolithische Aufgabe nun in fünf separate Aufgaben aufgeteilt ist – eine für jede Funktion.
Die Funktion saveSettings() führt jetzt ihre untergeordneten Funktionen als separate Aufgaben aus.

Eine dedizierte Planer-API

setTimeout ist eine effektive Methode zum Aufteilen von Aufgaben, kann jedoch einen Nachteil mit sich bringen: Wenn Sie dem Hauptthread nachlassen, indem Sie den Code in einer nachfolgenden Aufgabe verschieben, wird diese Aufgabe zum Ende der Warteschlange hinzugefügt.

Wenn Sie den gesamten Code auf Ihrer Seite steuern, können Sie Ihren eigenen Planer mit der Möglichkeit zur Priorisierung von Aufgaben erstellen. Skripts von Drittanbietern verwenden Ihren Planer jedoch nicht. In diesem Fall können Sie die Arbeit in solchen Umgebungen nicht priorisieren. Sie können sie nur aufteilen oder explizit Nutzerinteraktionen nachgeben.

Unterstützte Browser

  • 94
  • 94
  • x

Quelle

Die Planer-API bietet die Funktion postTask(), die eine feinere Planung von Aufgaben ermöglicht und eine Möglichkeit, den Browser bei der Priorisierung von Aufgaben zu unterstützen, sodass Aufgaben mit niedriger Priorität zum Hauptthread werden. 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. Dies ist die Standardeinstellung, wenn keine 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 nutzerbezogenen Arbeiten werden mit hoher Priorität geplant, während Arbeiten, von denen der Nutzer nichts weiß, im Hintergrund ausgeführt werden. So können Interaktionen der Nutzer schneller ausgeführt werden, da die Arbeit aufgeteilt und richtig priorisiert wird.

Dies ist ein vereinfachtes 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.

Integrierter Ertrag bei Fortsetzung mit der kommenden scheduler.yield() API

Eine vorgeschlagene Ergänzung der Planer-API ist scheduler.yield(), eine API, die speziell für die Ausgabe des Hauptthreads 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 Nachgeben, Nachgeben und mit Nachgeben und Fortsetzung darstellen. Ohne nachzugeben, 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. Beim Nachgeben und Fortsetzen gibt es mehr Aufgaben, die kürzer sind, aber ihre Ausführungsreihenfolge bleibt erhalten.
Wenn Sie scheduler.yield() verwenden, wird die Aufgabenausführung an der Stelle fortgesetzt, an der sie unterbrochen wurde, auch nach dem Ertragspunkt.

Der Vorteil von scheduler.yield() ist die Fortsetzung. Wenn Sie mitten in einer Reihe von Aufgaben nachgeben, werden die anderen geplanten Aufgaben nach dem Ertragspunkt in derselben Reihenfolge fortgesetzt. So wird verhindert, dass Code aus Drittanbieterskripts die Ausführung Ihres Codes unterbricht.

Wenn scheduler.postTask() mit priority: 'user-blocking' verwendet wird, ist die Wahrscheinlichkeit einer Fortsetzung ebenfalls aufgrund der hohen Priorität user-blocking hoch. Daher könnte dieser Ansatz in der Zwischenzeit als Alternative verwendet werden.

Mit setTimeout() (oder scheduler.postTask() mit priority: 'user-visibile' oder ohne explizites priority) wird die Aufgabe am Ende der Warteschlange geplant. Dadurch werden andere ausstehende Aufgaben vor der Fortsetzung ausgeführt.

isInputPending() nicht verwenden

Unterstützte Browser

  • 87
  • 87
  • x
  • x

Mit der isInputPending() API lässt sich prüfen, ob ein Nutzer versucht hat, mit einer Seite zu interagieren, und nur Ergebnisse erzielen, wenn eine Eingabe aussteht.

Auf diese Weise kann JavaScript fortfahren, wenn keine Eingaben ausstehen, anstatt nachzugeben und am Ende der Aufgabenwarteschlange zu landen. Wie im Intent to Ship beschrieben, kann dies zu beeindruckenden Leistungsverbesserungen für Websites führen, die andernfalls nicht zum Hauptthread zurückgeführt werden könnten.

Seit der Einführung dieser API haben wir jedoch mehr über die Erträge erfahren, insbesondere seit der Einführung von INP. Wir empfehlen die Verwendung dieser API nicht mehr, sondern aus verschiedenen Gründen, unabhängig davon, ob Eingaben ausstehen:

  • isInputPending() gibt unter Umständen fälschlicherweise false zurück, obwohl ein Nutzer interagiert hat.
  • Eingabe ist nicht der einzige Fall, bei dem Aufgaben etwas ausmachen sollten. Animationen und andere regelmäßige Aktualisierungen der Benutzeroberfläche können für eine responsive Webseite genauso wichtig sein.
  • Inzwischen wurden umfassendere APIs zum Ertrag eingeführt, die Probleme mit dem Ertrag lösen, wie z. B. scheduler.postTask() und scheduler.yield().

Fazit

Das Verwalten von Aufgaben ist eine Herausforderung, aber dadurch wird sichergestellt, dass Ihre Seite schneller auf Nutzerinteraktionen reagiert. Bei der Verwaltung und Priorisierung von Aufgaben gibt es keinen einzigen Rat, sondern eine Reihe unterschiedlicher Techniken. Zur Erinnerung: Dies sind die wichtigsten Punkte, die Sie bei der Verwaltung von Aufgaben berücksichtigen sollten:

  • Begeben Sie sich dem Hauptthread für kritische, an die Nutzer gerichtete Aufgaben.
  • Aufgaben mit postTask() priorisieren.
  • Du kannst mit scheduler.yield() experimentieren.
  • 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. Auf diese Weise wird die Nutzererfahrung verbessert, die responsiver und angenehmer 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.