Lange Aufgaben optimieren

Ihnen wurde gesagt, dass Sie den Hauptthread nicht blockieren und lange Aufgaben aufteilen. Aber was bedeutet es, diese Dinge zu tun?

Wagner
Jeremy Wagner

Wenn Sie viel über die Webleistung lesen, sollten Sie einige der folgenden Punkte beachten, damit Ihre JavaScript-Apps schnell bleiben:

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

Was bedeutet das? Die Bereitstellung von weniger JavaScript-Code ist gut, aber entspricht das automatisch einer reibungsloseren Benutzeroberfläche während des gesamten Lebenszyklus einer Seite? Vielleicht, aber vielleicht nicht.

Um zu verstehen, warum es wichtig ist, Aufgaben in JavaScript zu optimieren, müssen Sie verstehen, welche Rolle die Aufgaben spielen und wie der Browser sie verarbeitet. Das beginnt damit, zu verstehen, was eine Aufgabe ist.

Was ist eine Aufgabe?

Eine Aufgabe ist jede einzelne Aufgabe, die der Browser ausführt. Aufgaben umfassen Aufgaben wie das Rendern, das Parsen von HTML- und CSS-Code, das Ausführen des von Ihnen geschriebenen JavaScript-Codes und andere Dinge, über die Sie möglicherweise keine direkte Kontrolle haben. Dabei ist das JavaScript, das Sie schreiben und im Web bereitstellen, eine der wichtigsten Aufgaben.

Screenshot einer Aufgabe im Leistungsspektrum der Chrome-Entwicklertools. Die Aufgabe befindet sich an oberster Stelle in einem Stapel. Darunter befinden sich ein Klickereignis-Handler, ein Funktionsaufruf und weitere Elemente. Die Aufgabe umfasst auch einige Renderingarbeiten auf der rechten Seite.
Darstellung einer Aufgabe, die von einem click-Event-Handler im Leistungsprofiler in den Chrome-Entwicklertools gestartet wurde.

Aufgaben wirken sich in vielerlei Hinsicht auf die Leistung aus. Wenn ein Browser beispielsweise beim Start eine JavaScript-Datei herunterlädt, stellt er Aufgaben zum Parsen und Kompilieren dieses JavaScript-Codes in die Warteschlange, damit er ausgeführt werden kann. Später im Lebenszyklus der Seite werden Aufgaben gestartet, wenn Ihr JavaScript funktioniert. Beispielsweise werden Interaktionen über Event-Handler, JavaScript-gesteuerte Animationen und Hintergrundaktivitäten wie Analytics-Sammlungen gefördert. All das geschieht – mit Ausnahme von Web Workern und ähnlichen APIs – im Hauptthread.

Was ist der Hauptthread?

Im Hauptthread werden die meisten Aufgaben im Browser ausgeführt. Er wird aus einem bestimmten Grund als Hauptthread bezeichnet: Er ist der eine Thread, in dem fast das gesamte JavaScript, das Sie schreiben, seine Funktion ausführt.

Der Hauptthread kann jeweils nur eine Aufgabe verarbeiten. Aufgaben, die über einen bestimmten Punkt (50 Millisekunden, um genau zu sein) hinausgehen, werden als lange Aufgaben eingestuft. Wenn der Nutzer versucht, mit der Seite zu interagieren, während eine lange Aufgabe ausgeführt wird oder wenn ein wichtiges Rendering-Update erforderlich ist, verzögert sich die Verarbeitung dieser Arbeit durch den Browser. Dies führt zu einer Interaktions- oder Rendering-Latenz.

Eine lange Aufgabe im Leistungsprofil-Tool der Chrome-Entwicklertools. Der blockierende Teil der Aufgabe (über 50 Millisekunden) wird mit einem Muster roter diagonaler 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, wobei der blockierende Teil der Aufgabe mit einem Muster diagonaler roter Streifen ausgefüllt ist.

Sie müssen Aufgaben aufteilen. Das bedeutet, eine einzelne lange Aufgabe in kleinere Aufgaben aufzuteilen, die weniger Zeit für die individuelle Ausführung benötigen.

Eine einzelne lange Aufgabe oder dieselbe Aufgabe, die in kürzere Aufgaben aufgeteilt wird. Die lange Aufgabe ist ein großes Rechteck, während die aufgeteilte Aufgabe fünf kleinere Felder umfasst, die zusammen die gleiche Breite wie die lange Aufgabe haben.
Eine Visualisierung einer einzelnen langen Aufgabe im Vergleich zur gleichen Aufgabe in fünf kürzere Aufgaben.

Dies ist wichtig, denn wenn Aufgaben aufgeteilt werden, hat der Browser mehr Möglichkeiten, auf Aufgaben mit höherer Priorität zu reagieren – und dazu gehören auch Nutzerinteraktionen.

Darstellung, wie das Aufteilen einer Aufgabe die Interaktion der Nutzenden erleichtern kann. Oben wird durch eine lange Aufgabe verhindert, dass ein Event-Handler ausgeführt wird, bis die Aufgabe abgeschlossen ist. Die aufgeteilte Aufgabe unten ermöglicht es dem Event-Handler, früher als sonst auszuführen.
Eine Visualisierung dessen, was mit Interaktionen passiert, wenn Aufgaben zu lang sind und der Browser nicht schnell genug auf Interaktionen reagieren kann, im Vergleich zur Aufteilung längerer Aufgaben in kleinere Aufgaben.

Oben in der obigen Abbildung musste ein Event-Handler, der von einer Nutzerinteraktion in die Warteschlange gestellt wurde, eine einzelne lange Aufgabe warten, bevor er ausgeführt werden konnte. Dadurch wird die Interaktion verzögert. Unten kann der Event-Handler früher ausgeführt werden. Da der Event-Handler zwischen kleineren Aufgaben ausgeführt werden konnte, wird er früher ausgeführt, als wenn er auf die Fertigstellung einer langen Aufgabe warten müsste. Im Beispiel oben hat der Nutzer möglicherweise eine Verzögerung bemerkt. Unten könnte sich die Interaktion sofort angefühlt haben.

Das Problem ist jedoch, dass der Tipp „Unterteilen Sie Ihre langen Aufgaben“ und „Den Hauptthread nicht blockieren“ nicht spezifisch genug, es sei denn, Sie wissen bereits, wie Sie diese Dinge erledigen können. Darum geht es in diesem Leitfaden.

Strategien für die Aufgabenverwaltung

Ein häufiger Rat in der Softwarearchitektur besteht darin, Ihre Arbeit in kleinere Funktionen aufzuteilen. Dies bietet Ihnen die Vorteile einer besseren Lesbarkeit von Code und einer besseren Verwaltbarkeit von Projekten. Dies erleichtert auch das Schreiben von Tests.

function saveSettings () {
  validateForm();
  showSpinner();
  saveToDatabase();
  updateUI();
  sendAnalytics();
}

In diesem Beispiel gibt es eine Funktion namens saveSettings(), die fünf Funktionen zur Ausführung der Arbeit aufruft, z. B. Validierung eines Formulars, Einblenden eines Kreises, Senden von Daten usw. Das Konzept ist gut durchdacht. Wenn Sie eine dieser Funktionen debuggen müssen, können Sie die Projektstruktur durchsuchen, um herauszufinden, was die einzelnen Funktionen bewirken.

Das Problem besteht jedoch darin, dass JavaScript nicht jede dieser Funktionen als separate Aufgaben ausführt, da 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 Leistungsprofilr von Chrome dargestellt Während die Funktion der obersten Ebene fünf weitere Funktionen aufruft, findet die gesamte Arbeit in einer langen Aufgabe statt, 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 selbst nur eine dieser Funktionen 50 Millisekunden oder mehr zur Gesamtlänge der Aufgabe beitragen. Im schlimmsten Fall können mehr dieser Aufgaben etwas länger ausgeführt werden – insbesondere auf ressourcenbeschränkten Geräten. Im Folgenden finden Sie eine Reihe von Strategien, mit denen Sie Aufgaben aufteilen und priorisieren können.

Codeausführung manuell aufschieben

Eine Methode, mit der Entwickler Aufgaben in kleinere Aufgaben aufgeteilt haben, ist setTimeout(). Mit dieser Technik ü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);
}

Dies funktioniert gut, wenn Sie eine Reihe von Funktionen haben, die sequenziell ausgeführt werden müssen, Ihr Code jedoch nicht immer auf diese Weise organisiert ist. Sie könnten beispielsweise eine große Datenmenge haben, die in einer Schleife verarbeitet werden muss, und diese Aufgabe könnte sehr lange dauern, wenn Sie Millionen von Elementen haben.

function processData () {
  for (const item of largeDataArray) {
    // Process the individual item here.
  }
}

Die Verwendung von setTimeout() ist in diesem Fall problematisch, da die Implementierung der Ergonomie erschwert und die Verarbeitung des gesamten Datenarrays sehr lange dauern kann, auch wenn jedes Element sehr schnell verarbeitet werden kann. Alles summiert sich und setTimeout() ist nicht das richtige Tool für diese Aufgabe – zumindest nicht, wenn es auf diese Weise verwendet wird.

Neben setTimeout() gibt es einige andere APIs, mit denen Sie die Codeausführung auf eine nachfolgende Aufgabe verschieben können. Bei einer werden postMessage() für schnellere Zeitüberschreitungen verwendet. Sie können Ihre Arbeit auch mit requestIdleCallback() trennen. Aber Vorsicht: requestIdleCallback() plant Aufgaben mit der niedrigsten Priorität und nur dann, wenn der Browser inaktiv ist. Wenn der Hauptthread überlastet ist, werden mit requestIdleCallback() geplante Aufgaben möglicherweise nie ausgeführt.

Mit async/await Ertragspunkte erstellen

Im weiteren Verlauf dieses Leitfadens wird immer wieder die Redewendung „yield to the main Thread“ verwendet. Aber was bedeutet das? Warum sollten Sie das tun? Wann sollte ich vorgehen?

Wenn Aufgaben aufgeteilt werden, können andere Aufgaben durch das interne Priorisierungsschema des Browsers besser priorisiert werden. Eine Möglichkeit, Daten an den Hauptthread zu liefern, besteht darin, eine Kombination aus Promise zu verwenden, die mit einem Aufruf von setTimeout() aufgelöst wird:

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

In der saveSettings()-Funktion können Sie nach jeder Arbeit an den Hauptthread übergeben, wenn Sie nach jedem Funktionsaufruf await die yieldToMain()-Funktion ausführen:

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 monolithische Aufgabe jetzt in separate Aufgaben aufgeteilt wird.

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

Der Vorteil einer versprechenbasierten Strategie gegenüber der manuellen Verwendung von setTimeout() ist eine bessere Ergonomie. Ertragsgruppen werden deklarativ und daher einfacher zu schreiben, zu lesen und zu verstehen.

Nur bei Bedarf liefern

Was ist, wenn Sie eine Reihe von Aufgaben haben, aber nur dann Ergebnisse erzielen möchten, wenn der Nutzer versucht, mit der Seite zu interagieren? Dafür wurde isInputPending() entwickelt.

isInputPending() ist eine Funktion, die Sie jederzeit ausführen können, um festzustellen, ob der Nutzer versucht, mit einem Seitenelement zu interagieren: Ein Aufruf von isInputPending() gibt true zurück. Andernfalls wird false zurückgegeben.

Angenommen, Sie haben eine Warteschlange mit Aufgaben, die Sie ausführen müssen, aber Sie möchten keine Eingaben stören. Dieser Code, der sowohl isInputPending() als auch unsere benutzerdefinierte yieldToMain()-Funktion verwendet, sorgt dafür, dass die Eingabe nicht verzögert wird, während der Nutzer versucht, mit der Seite zu interagieren:

async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  while (tasks.length > 0) {
    // Yield to a pending user input:
    if (navigator.scheduling.isInputPending()) {
      // There's a pending user input. Yield here:
      await yieldToMain();
    } else {
      // Shift the task out of the queue:
      const task = tasks.shift();

      // Run the task:
      task();
    }
  }
}

Während der Ausführung von saveSettings() werden die Aufgaben in der Warteschlange in einer Schleife durchlaufen. Wenn isInputPending() während der Schleife true zurückgibt, ruft saveSettings() yieldToMain() auf, damit die Nutzereingabe verarbeitet werden kann. Andernfalls wird die nächste Aufgabe aus der Warteschlange entfernt und kontinuierlich ausgeführt. Das passiert, bis keine Aufgaben mehr übrig sind.

Darstellung der Funktion „saveSettings“, die im Leistungsprofilr von Chrome ausgeführt wird Die resultierende Aufgabe blockiert den Hauptthread, bis isInputPending „true“ zurückgibt. Zu diesem Zeitpunkt liefert die Aufgabe den Hauptthread.
saveSettings() führt eine Aufgabenwarteschlange für fünf Aufgaben aus. Während die zweite Arbeitsaufgabe ausgeführt wurde, hat der Nutzer jedoch auf einen Klick zum Öffnen eines Menüs geklickt. isInputPending() liefert an den Hauptthread, um die Interaktion zu verarbeiten und die Ausführung der restlichen Aufgaben fortzusetzen.

Die Verwendung von isInputPending() in Kombination mit einem Ertragsmechanismus ist eine großartige Möglichkeit, um den Browser dazu zu bringen, die Verarbeitung von Aufgaben zu stoppen, damit er auf wichtige Nutzerinteraktionen reagieren kann. Dies kann dazu beitragen, dass Ihre Seite in vielen Situationen, in denen viele Aufgaben ausgeführt werden, besser auf die Nutzer reagieren kann.

Eine weitere Möglichkeit zur Verwendung von isInputPending() ist die Verwendung eines zeitbasierten Ansatzes in Verbindung mit dem optionalen Verkettungsoperator:

async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  let deadline = performance.now() + 50;

  while (tasks.length > 0) {
    // Optional chaining operator used here helps to avoid
    // errors in browsers that don't support `isInputPending`:
    if (navigator.scheduling?.isInputPending() || performance.now() >= deadline) {
      // There's a pending user input, or the
      // deadline has been reached. Yield here:
      await yieldToMain();

      // Extend the deadline:
      deadline = performance.now() + 50;

      // Stop the execution of the current loop and
      // move onto the next iteration:
      continue;
    }

    // Shift the task out of the queue:
    const task = tasks.shift();

    // Run the task:
    task();
  }
}

Bei diesem Ansatz erhalten Sie ein Fallback für Browser, die isInputPending() nicht unterstützen. Dazu wird ein zeitbasierter Ansatz verwendet, bei dem eine Frist verwendet (und angepasst) wird, sodass die Arbeit bei Bedarf aufgeteilt wird, sei es durch Nutzereingaben oder zu einem bestimmten Zeitpunkt.

Lücken in aktuellen APIs

Die bisher erwähnten APIs können Ihnen dabei helfen, Aufgaben aufzuteilen, haben aber einen erheblichen Nachteil: Wenn Sie den Hauptthread zurückstellen, indem Sie Code in einer nachfolgenden Aufgabe ausführen, wird dieser Code ganz am Ende der Aufgabenwarteschlange hinzugefügt.

Wenn Sie den gesamten Code auf Ihrer Seite kontrollieren, können Sie Ihren eigenen Planer erstellen, der in der Lage ist, Aufgaben zu priorisieren. Skripts von Drittanbietern verwenden Ihren Planer jedoch nicht. Sie können die Arbeit in solchen Umgebungen eigentlich nicht priorisieren. Sie können sie nur aufteilen oder explizit auf Nutzerinteraktionen ausrichten.

Glücklicherweise gibt es eine spezielle Planer-API, die sich derzeit in der Entwicklung befindet, um diese Probleme zu lösen.

Eine dedizierte Planungs-API

Die Scheduler API bietet derzeit die Funktion postTask(), die zum Zeitpunkt der Erstellung dieses Dokuments in Chromium-Browsern und in Firefox hinter einer Markierung verfügbar ist. postTask() ermöglicht eine präzisere Zeitplanung von Aufgaben und ist eine Möglichkeit, den Browser bei der Priorisierung von Arbeiten zu unterstützen, damit Aufgaben mit niedriger Priorität an den Hauptthread gesendet werden. postTask() verwendet Promise und akzeptiert eine priority-Einstellung.

Die postTask() API hat drei Prioritäten, die Sie verwenden können:

  • 'background' für Aufgaben mit der niedrigsten Priorität.
  • 'user-visible' für Aufgaben mit mittlerer Priorität. Dies 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 beiden 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 ist die Priorität von Aufgaben so geplant, dass browserpriorisierte Aufgaben – wie z. B. Nutzerinteraktionen – ihren Weg einarbeiten können.

Die Funktion „saveSettings“ wie im Leistungsprofiler von Chrome dargestellt, jedoch mit postTask. postTask unterteilt jede Funktion in „saveSettings“ und priorisiert sie so, dass eine Nutzerinteraktion ohne Blockierung ausgeführt werden kann.
Wenn saveSettings() ausgeführt wird, plant die Funktion die einzelnen Funktionen mit postTask(). Die wichtigen nutzerseitigen Arbeiten werden mit hoher Priorität geplant, während Aufgaben, von denen der Nutzer nicht weiß, im Hintergrund ausgeführt werden soll. So können Nutzerinteraktionen schneller ausgeführt werden, da die Arbeit aufgeteilt und entsprechend priorisiert wird.

Dies ist ein vereinfachtes Beispiel für die Verwendung von postTask(). Es ist möglich, verschiedene TaskController-Objekte zu instanziieren, die Prioritäten unter Aufgaben teilen können. Dies schließt die Möglichkeit ein, die Prioritäten für verschiedene TaskController-Instanzen nach Bedarf zu ändern.

Integrierte Rendite mit Fortsetzung über scheduler.yield

Ein vorgeschlagener Teil der Scheduler API ist scheduler.yield, eine API, die speziell für die Ausgabe an den Hauptthread im Browser entwickelt wurde und derzeit zum Testen als Ursprungstest verfügbar ist. Ihre Verwendung ähnelt der Funktion yieldToMain(), die weiter oben in diesem Artikel 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();
  }
}

Wie Sie sehen, ist der obige Code weitgehend vertraut. Statt yieldToMain() zu verwenden, rufen Sie stattdessen auf und await scheduler.yield() auf.

Drei Diagramme, die Aufgaben ohne Ertrag, Ergebnis und mit Ertrag und Fortsetzung darstellen. Ohne nachzuvollziehen, gibt es lange Aufgaben. Wenn Sie nach dem Ertrag suchen, gibt es mehr Aufgaben, die kürzer sind, aber möglicherweise durch andere irrelevante Aufgaben unterbrochen werden. Bei Ertrags und Fortsetzung gibt es mehr Aufgaben, die kürzer sind, aber ihre Ausführungsreihenfolge beibehalten wird.
Visualisierung der Aufgabenausführung ohne Ertrag, mit Ertrag sowie mit Ertrag und Fortsetzung. Wenn scheduler.yield() verwendet wird, wird die Aufgabenausführung auch nach dem Ertragspunkt an der Stelle fortgesetzt, an der sie unterbrochen wurde.

Der Vorteil von scheduler.yield() ist die Fortsetzung. Wenn Sie also eine Reihe von Aufgaben ausführen, werden die anderen geplanten Aufgaben nach dem Ertragspunkt in derselben Reihenfolge fortgesetzt. So wird verhindert, dass Code von Drittanbieterskripts die Reihenfolge der Ausführung Ihres Codes übernimmt.

Fazit

Das Verwalten von Aufgaben ist eine Herausforderung, aber damit kann Ihre Seite schneller auf Nutzerinteraktionen reagieren. Es gibt nicht nur einen Rat für die Verwaltung und Priorisierung von Aufgaben. Es handelt sich vielmehr um eine Reihe verschiedener Techniken. Hier noch einmal die wichtigsten Punkte, die Sie bei der Verwaltung von Aufgaben berücksichtigen sollten:

  • Gibt den Hauptthread für wichtige, nutzerseitige Aufgaben zurück.
  • Mit isInputPending() können Sie an den Hauptthread weiterleiten, wenn der Nutzer versucht, mit der Seite zu interagieren.
  • Aufgaben mit postTask() priorisieren.
  • Und schließlich verarbeiten Sie so wenig Arbeit wie möglich mit Ihren Funktionen.

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 wichtige Arbeiten weiterhin ausgeführt werden. Dadurch wird eine bessere User Experience geschaffen, die schneller reagiert und angenehmer zu bedienen ist.

Wir danken Philip Walton für die technische Überprüfung dieses Artikels.

Hero-Image von Unsplash, mit freundlicher Genehmigung von Amirali Mirhashemian