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.
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.
Um zu verhindern, dass der Haupt-Thread zu lange blockiert wird, können Sie eine lange Aufgabe in mehrere kleinere aufteilen.
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.
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.
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.
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.
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 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 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.
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
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.
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.
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älschlicherweisefalse
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()
undscheduler.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.