Sie haben gehört, dass Sie den Hauptthread nicht blockieren und lange Aufgaben aufteilen sollen. Was bedeutet das?
Die gängigen Empfehlungen, wie Sie JavaScript-Anwendungen möglichst leistungsfähig halten, lassen sich auf die folgenden Punkte zusammenfassen:
- „Blockieren Sie den Hauptthread nicht.“
- „Lange Aufgaben in kleinere Teilaufgaben unterteilen.“
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.
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 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.
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.
Um zu verhindern, dass der Haupt-Thread zu lange blockiert wird, können Sie eine lange Aufgabe in mehrere kleinere aufteilen.
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.
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.
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.
Codeausführung manuell verschieben
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. 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 Aufgaben für den Nutzer 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.
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 solchen Umgebungen können Sie Ihre Arbeit nicht priorisieren. Sie können sie nur in kleinere Abschnitte unterteilen oder explizit auf Nutzerinteraktionen reagieren.
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 Versprechen und akzeptiert eine der drei priority
-Einstellungen:
'background'
für Aufgaben mit der niedrigsten Priorität.'user-visible'
für Aufgaben mit mittlerer Priorität. Das ist die Standardeinstellung, wenn keinpriority
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 niedrigstmöglichen 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 wie Nutzerinteraktionen bei Bedarf dazwischen ausgeführt werden können.
Dies ist ein einfaches Beispiel für die Verwendung von postTask()
. Es ist möglich, verschiedene TaskController
-Objekte zu instanziieren, die Prioritäten zwischen Aufgaben teilen können. Außerdem können die Prioritäten für verschiedene TaskController
-Instanzen nach Bedarf geändert werden.
Integrierte Auslieferung mit Weiterleitung mit der scheduler.yield()
API
scheduler.yield()
ist eine API, die speziell für das Übergeben an den Hauptthread im Browser entwickelt wurde. Die Verwendung ähnelt der Funktion yieldToMain()
, die bereits in diesem Leitfaden vorgestellt 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.
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
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()
fälschlicherweisefalse
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.
- Inzwischen wurden umfassendere APIs eingeführt, die Probleme mit der Auslieferung angehen, z. B.
scheduler.postTask()
undscheduler.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: Dies sind die wichtigsten Dinge, die Sie beim Verwalten von Aufgaben beachten sollten:
- Für kritische, nutzerorientierte Aufgaben dem Hauptthread weichen.
- Priorisieren Sie Aufgaben mit
postTask()
. - Sie können
scheduler.yield()
ausprobieren. - Achten Sie darauf, dass Ihre Funktionen möglichst wenig Arbeit erfordern.
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.