Zapewne zdarzyło Ci się usłyszeć, że nie należy blokować wątku głównego i że trzeba dzielić długie zadania, ale co to właściwie oznacza?
Data publikacji: 30 września 2022 r., ostatnia aktualizacja: 19 grudnia 2024 r.
Typowe porady dotyczące utrzymywania szybkości aplikacji JavaScript sprowadzają się do tych wskazówek:
- „Nie blokuj wątku głównego”.
- „Podziel długie zadania”.
To świetna rada, ale co trzeba zrobić? Wysyłanie mniejszej ilości kodu JavaScript jest dobre, ale czy automatycznie przekłada się na bardziej responsywne interfejsy użytkownika? Może tak, może nie.
Aby dowiedzieć się, jak optymalizować zadania w języku JavaScript, musisz najpierw zrozumieć, czym są zadania i jak przeglądarka je obsługuje.
Czym jest zadanie?
Zadanie to dowolna odrębna czynność wykonywana przez przeglądarkę. Obejmuje to renderowanie, analizowanie kodu HTML i CSS, uruchamianie kodu JavaScript oraz inne rodzaje zadań, nad którymi możesz nie mieć bezpośredniej kontroli. Spośród wszystkich tych elementów kod JavaScript, który piszesz, jest prawdopodobnie największym źródłem zadań.
click, widoczne w profilerze wydajności Narzędzi deweloperskich w Chrome.
Zadania związane z kodem JavaScript wpływają na wydajność na kilka sposobów:
- Gdy przeglądarka pobiera plik JavaScript podczas uruchamiania, kolejkuje zadania, aby przeanalizować i skompilować ten kod JavaScript, aby można go było później wykonać.
- W innych momentach cyklu życia strony zadania są dodawane do kolejki, gdy JavaScript wykonuje takie czynności jak reagowanie na interakcje za pomocą modułów obsługi zdarzeń, animacje oparte na JavaScript i działania w tle, np. zbieranie danych analitycznych.
Wszystkie te elementy – z wyjątkiem procesów roboczych i podobnych interfejsów API – są wykonywane w głównym wątku.
Czym jest wątek główny?
Wątek główny to miejsce, w którym przeglądarka wykonuje większość zadań i prawie cały napisany przez Ciebie kod JavaScript.
Wątek główny może przetwarzać tylko jedno zadanie naraz. Każde zadanie, które trwa dłużej niż 50 milisekund, jest długim zadaniem. W przypadku zadań, które trwają dłużej niż 50 milisekund, łączny czas zadania pomniejszony o 50 milisekund jest nazywany okresem blokowania zadania.
Przeglądarka blokuje interakcje podczas wykonywania zadania o dowolnej długości, ale nie jest to zauważalne dla użytkownika, o ile zadania nie trwają zbyt długo. Jeśli jednak użytkownik spróbuje wejść w interakcję ze stroną, gdy jest wiele długich zadań, interfejs będzie wydawał się nie reagować, a nawet może być uszkodzony, jeśli wątek główny jest blokowany przez bardzo długi czas.
Aby zapobiec zbyt długiemu blokowaniu wątku głównego, możesz podzielić długie zadanie na kilka mniejszych.
Jest to ważne, ponieważ gdy zadania są podzielone, przeglądarka może znacznie szybciej reagować na zadania o wyższym priorytecie, w tym na interakcje użytkownika. Następnie pozostałe zadania są wykonywane do końca, co gwarantuje, że praca, która została początkowo dodana do kolejki, zostanie wykonana.
U góry poprzedniego rysunku moduł obsługi zdarzeń dodany do kolejki przez interakcję użytkownika musiał poczekać na zakończenie pojedynczego długiego zadania, zanim mógł się uruchomić. To opóźnia interakcję. W tym scenariuszu użytkownik mógł zauważyć opóźnienie. Na dole moduł obsługi zdarzeń może zacząć działać wcześniej, a interakcja może wydawać się natychmiastowa.
Teraz, gdy już wiesz, dlaczego ważne jest dzielenie zadań, możesz dowiedzieć się, jak to zrobić w języku JavaScript.
Strategie zarządzania zadaniami
Częstą poradą dotyczącą architektury oprogramowania jest dzielenie pracy na mniejsze funkcje:
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
W tym przykładzie funkcja o nazwie saveSettings() wywołuje 5 funkcji, aby zweryfikować formularz, wyświetlić wskaźnik oczekiwania, wysłać dane do backendu aplikacji, zaktualizować interfejs i wysłać dane analityczne.
Pod względem koncepcji saveSettings() jest dobrze zaprojektowany. Jeśli chcesz debugować jedną z tych funkcji, możesz przejść przez drzewo projektu, aby dowiedzieć się, co robi każda funkcja. Taki podział pracy ułatwia poruszanie się po projektach i ich utrzymanie.
Potencjalnym problemem jest jednak to, że JavaScript nie uruchamia każdej z tych funkcji jako osobnego zadania, ponieważ są one wykonywane w funkcji saveSettings(). Oznacza to, że wszystkie 5 funkcji zostanie uruchomionych jako jedno zadanie.
saveSettings(), która wywołuje 5 funkcji. Praca jest wykonywana w ramach jednego długiego, monolitycznego zadania, które blokuje wszelkie odpowiedzi wizualne do momentu ukończenia wszystkich 5 funkcji.
W najlepszym przypadku nawet jedna z tych funkcji może wydłużyć zadanie o 50 milisekund lub więcej. W najgorszym przypadku więcej takich zadań może działać znacznie dłużej, zwłaszcza na urządzeniach z ograniczonymi zasobami.
W tym przypadku funkcja saveSettings() jest wywoływana przez kliknięcie użytkownika. Ponieważ przeglądarka nie może wyświetlić odpowiedzi, dopóki nie zakończy się cała funkcja, wynikiem tego długiego zadania jest powolny i niereagujący interfejs, co zostanie zmierzone jako słaba interakcja do kolejnego wyrenderowania (INP).
Ręczne odraczanie wykonywania kodu
Aby mieć pewność, że ważne zadania dla użytkownika i odpowiedzi interfejsu będą wykonywane przed zadaniami o niższym priorytecie, możesz przekazać kontrolę wątkowi głównemu, przerywając na chwilę pracę, aby umożliwić przeglądarce wykonywanie ważniejszych zadań.
Jedną z metod, które deweloperzy stosują do dzielenia zadań na mniejsze, jest setTimeout(). W tej technice przekazujesz funkcję do setTimeout(). Spowoduje to odroczenie wykonania wywołania zwrotnego do osobnego zadania, nawet jeśli określisz czas oczekiwania na poziomie 0.
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);
}
Jest to tzw. ustępowanie, które najlepiej sprawdza się w przypadku serii funkcji, które muszą być wykonywane sekwencyjnie.
Jednak kod nie zawsze jest uporządkowany w ten sposób. Możesz na przykład mieć dużą ilość danych, które muszą zostać przetworzone w pętli, a to zadanie może zająć bardzo dużo czasu, jeśli jest wiele iteracji.
function processData () {
for (const item of largeDataArray) {
// Process the individual item here.
}
}
Użycie setTimeout() jest tutaj problematyczne ze względu na ergonomię programisty, a po 5 rundach zagnieżdżonych setTimeout() przeglądarka zacznie nakładać minimalne opóźnienie wynoszące 5 milisekund na każde dodatkowe setTimeout().
setTimeout ma też inną wadę, jeśli chodzi o przekazywanie: gdy przekazujesz do głównego wątku, odraczając kod do uruchomienia w kolejnym zadaniu za pomocą setTimeout, to zadanie jest dodawane na końcu kolejki. Jeśli są inne zadania oczekujące, zostaną one wykonane przed odroczonym kodem.
Specjalny interfejs API do przekazywania: scheduler.yield()
scheduler.yield() to interfejs API zaprojektowany specjalnie do przekazywania kontroli wątkowi głównemu w przeglądarce.
Nie jest to składnia na poziomie języka ani specjalna konstrukcja. scheduler.yield() to po prostu funkcja, która zwraca obiekt Promise, który zostanie rozwiązany w przyszłym zadaniu. Każdy kod połączony w łańcuch, który ma zostać uruchomiony po rozwiązaniu tego Promise (w łańcuchu .then() lub po await w funkcji asynchronicznej), zostanie uruchomiony w tym przyszłym zadaniu.
W praktyce: wstaw await scheduler.yield(), a funkcja wstrzyma wykonywanie w tym miejscu i przekaże sterowanie do wątku głównego. Wykonanie pozostałej części funkcji, czyli jej kontynuacji, zostanie zaplanowane w nowym zadaniu pętli zdarzeń. Gdy to zadanie się rozpocznie, oczekiwana obietnica zostanie spełniona, a funkcja będzie kontynuować wykonywanie od miejsca, w którym zostało przerwane.
async function saveSettings () {
// Do critical work that is user-visible:
validateForm();
showSpinner();
updateUI();
// Yield to the main thread:
await scheduler.yield()
// Work that isn't user-visible, continued in a separate task:
saveToDatabase();
sendAnalytics();
}
saveSettings() jest teraz podzielone na 2 zadania. Dzięki temu układ i renderowanie mogą być wykonywane między zadaniami, co zapewnia użytkownikowi szybszą odpowiedź wizualną, mierzoną przez znacznie krótszą interakcję ze wskaźnikiem.
Jednak prawdziwą zaletą funkcji scheduler.yield() w porównaniu z innymi metodami przekazywania jest to, że jej kontynuacja ma priorytet. Oznacza to, że jeśli przekażesz zadanie w jego trakcie, kontynuacja bieżącego zadania zostanie uruchomiona przed rozpoczęciem innych podobnych zadań.
Dzięki temu kod z innych źródeł zadań, np. skryptów innych firm, nie będzie zakłócać kolejności wykonywania kodu.
scheduler.yield(), kontynuacja zaczyna się od miejsca, w którym została przerwana, zanim przejdziesz do innych zadań.
Obsługa różnych przeglądarek
Właściwość scheduler.yield() nie jest jeszcze obsługiwana we wszystkich przeglądarkach, dlatego potrzebna jest wartość zastępcza.
Jednym z rozwiązań jest dodanie scheduler-polyfill do kompilacji, a następnie bezpośrednie użycie scheduler.yield(). Polyfill będzie obsługiwać przełączanie się na inne funkcje planowania zadań, dzięki czemu będzie działać podobnie w różnych przeglądarkach.
Można też napisać mniej zaawansowaną wersję w kilku wierszach, używając tylko setTimeout opakowanego w Promise jako kreacji zastępczej, jeśli scheduler.yield() jest niedostępny.
function yieldToMain () {
if (globalThis.scheduler?.yield) {
return scheduler.yield();
}
// Fall back to yielding with setTimeout.
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
Przeglądarki, które nie obsługują scheduler.yield(), nie będą korzystać z priorytetowej kontynuacji, ale będą nadal reagować na działania użytkownika.
Wreszcie mogą wystąpić przypadki, w których kod nie może przekazać kontroli wątkowi głównemu, jeśli jego kontynuacja nie ma priorytetu (np. znana strona o dużym obciążeniu, na której przekazanie kontroli grozi nieukończeniem pracy przez pewien czas). W takim przypadku scheduler.yield() można traktować jako rodzaj progresywnego ulepszania: zwracać dane w przeglądarkach, w których scheduler.yield() jest dostępny, a w przeciwnym razie kontynuować.
Można to zrobić, wykrywając funkcję i czekając na pojedyncze mikrozadanie w jednej linijce:
// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();
Dzielenie długotrwałych zadań za pomocą scheduler.yield()
Zaletą korzystania z tych metod używania funkcji scheduler.yield() jest to, że możesz ją await w dowolnej funkcji async.
Jeśli na przykład masz tablicę zadań do wykonania, które często składają się na długie zadanie, możesz wstawić yield, aby je podzielić.
async function runJobs(jobQueue) {
for (const job of jobQueue) {
// Run the job:
job();
// Yield to the main thread:
await yieldToMain();
}
}
Kontynuacja runJobs() będzie traktowana priorytetowo, ale nadal będzie możliwe wykonywanie zadań o wyższym priorytecie, takich jak wizualne reagowanie na dane wejściowe użytkownika, bez konieczności czekania na zakończenie potencjalnie długiej listy zadań.
Nie jest to jednak efektywne wykorzystanie przekazywania. scheduler.yield() jest szybki i wydajny, ale wiąże się z pewnymi kosztami. Jeśli niektóre zadania w jobQueue są bardzo krótkie, narzut może szybko się zwiększyć, przez co więcej czasu będzie poświęcane na oddawanie i wznawianie niż na wykonywanie rzeczywistej pracy.
Jednym z podejść jest grupowanie zadań i przekazywanie ich tylko wtedy, gdy od ostatniego przekazania minęło wystarczająco dużo czasu. Typowy termin to 50 milisekund, aby zapobiec przekształcaniu się zadań w długie zadania, ale można go dostosować, aby znaleźć kompromis między czasem reakcji a czasem potrzebnym na ukończenie kolejki zadań.
async function runJobs(jobQueue, deadline=50) {
let lastYield = performance.now();
for (const job of jobQueue) {
// Run the job:
job();
// If it's been longer than the deadline, yield to the main thread:
if (performance.now() - lastYield > deadline) {
await yieldToMain();
lastYield = performance.now();
}
}
}
W rezultacie zadania są dzielone tak, aby nigdy nie trwały zbyt długo, ale moduł uruchamiający przekazuje sterowanie do wątku głównego tylko co około 50 milisekund.
Nie używaj aplikacji isInputPending()
Interfejs API isInputPending() umożliwia sprawdzenie, czy użytkownik próbował wejść w interakcję ze stroną, i przekazanie kontroli tylko wtedy, gdy dane wejściowe oczekują na przetworzenie.
Dzięki temu JavaScript może kontynuować działanie, jeśli nie ma oczekujących danych wejściowych, zamiast przekazywać sterowanie i trafiać na koniec kolejki zadań. Może to prowadzić do znacznego zwiększenia wydajności, jak opisano w Intent to Ship, w przypadku witryn, które w przeciwnym razie nie przekazywałyby danych z powrotem do głównego wątku.
Jednak od czasu wprowadzenia tego interfejsu API nasza wiedza na temat oddawania kontroli wzrosła, zwłaszcza po wprowadzeniu INP. Nie zalecamy już używania tego interfejsu API. Zamiast tego zalecamy przekazywanie danych niezależnie od tego, czy dane wejściowe oczekują na przetworzenie, z kilku powodów:
- W niektórych okolicznościach funkcja
isInputPending()może nieprawidłowo zwracać wartośćfalse, mimo że użytkownik wszedł w interakcję. - Dane wejściowe nie są jedynym przypadkiem, w którym zadania powinny się poddawać. Animacje i inne regularne aktualizacje interfejsu mogą być równie ważne dla zapewnienia responsywności strony internetowej.
- Od tego czasu wprowadzono bardziej kompleksowe interfejsy API, które rozwiązują problemy z oddawaniem zasobów, takie jak
scheduler.postTask()ischeduler.yield().
Podsumowanie
Zarządzanie zadaniami jest trudne, ale dzięki temu strona szybciej reaguje na interakcje użytkowników. Nie ma jednej rady dotyczącej zarządzania zadaniami i ustalania ich priorytetów, ale istnieje wiele różnych technik. Podsumowując, oto najważniejsze kwestie, które należy wziąć pod uwagę podczas zarządzania zadaniami:
- Przekaż kontrolę głównemu wątkowi w przypadku zadań krytycznych dla użytkownika.
- Używaj
scheduler.yield()(z funkcją zastępczą w różnych przeglądarkach), aby ergonomicznie przekazywać i uzyskiwać priorytetowe kontynuacje - Na koniec: ogranicz do minimum liczbę operacji wykonywanych przez funkcje.
Więcej informacji o scheduler.yield(), powiązanym z nim interfejsie scheduler.postTask() do jawnego planowania zadań oraz o priorytetyzacji zadań znajdziesz w dokumentacji interfejsu Prioritized Task Scheduling API.
Za pomocą co najmniej jednego z tych narzędzi możesz ustrukturyzować pracę w aplikacji, tak aby priorytetowo traktowała potrzeby użytkownika, a jednocześnie zapewniała wykonywanie mniej krytycznych zadań. Dzięki temu użytkownicy będą mogli wygodniej korzystać z tych stron.
Szczególne podziękowania dla Philipa Waltona za sprawdzenie tego przewodnika pod kątem technicznym.