Ihnen wurde gesagt, dass Sie den Hauptthread nicht blockieren und lange Aufgaben aufteilen. Aber was bedeutet es, diese Dinge zu tun?
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.
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.
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.
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.
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.
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.
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.
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 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 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.
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.
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