Poproszono Cię o „nie blokuj wątku głównego” i „podziel długie zadania”, ale co to znaczy robić te rzeczy?
Typowe wskazówki dotyczące utrzymywania wysokiej szybkości aplikacji JavaScript sprowadzają się do tych zaleceń:
- „Nie blokuj głównego wątku”.
- „Podziel długie zadania”.
To świetna rada, ale ile pracy to wymaga? JavaScript bez dostawy jest dobry, ale czy to automatycznie przekłada się na bardziej elastyczne interfejsy użytkownika? Być może, ale nie.
Aby zrozumieć, jak optymalizować zadania w JavaScript, musisz najpierw wiedzieć, czym są zadania i jak przeglądarka je obsługuje.
Co to jest zadanie?
Zadanie to dowolna część pracy wykonywana przez przeglądarkę. Obejmuje to renderowanie, analizowanie kodu HTML i CSS, uruchamianie JavaScriptu oraz inne działania, nad którymi możesz nie mieć bezpośredniej kontroli. Spośród wszystkich tych zadań najwięcej czasu zajmuje prawdopodobnie pisanie kodu JavaScript.
Zadania związane z JavaScriptem wpływają na wydajność w kilka sposoby:
- Gdy przeglądarka pobiera plik JavaScript podczas uruchamiania, umieszcza w kolejce zadania do przeanalizowania i skompilowania, by możliwe było jego późniejsze wykonanie.
- W innych momentach w cyklu życia strony zadania są umieszczane w kolejce, gdy działa JavaScript, np. w przypadku interakcji z użyciem modułów obsługi zdarzeń, animacji obsługiwanych przez JavaScript oraz aktywności w tle, takiej jak zbieranie danych analitycznych.
Wszystkie te czynności (z wyjątkiem instancji roboczych i podobnych interfejsów API) odbywają się w głównym wątku.
Czym jest główny wątek?
Wątek główny to miejsce, w którym większość zadań jest uruchamiana w przeglądarce i w którym wykonuje się prawie cały napisany przez Ciebie JavaScript.
Wątek główny może przetwarzać tylko jedno zadanie naraz. Każde zadanie, które trwa dłużej niż 50 ms, jest długim zadaniem. W przypadku zadań, które trwają dłużej niż 50 ms, łączny czas trwania zadania pomniejszony o 50 ms jest nazywany okresem blokowania.
Przeglądarka blokuje interakcje podczas wykonywania zadania o dowolnej długości, ale nie jest to zauważalne dla użytkownika, dopóki zadania nie trwają zbyt długo. Jednak gdy użytkownik będzie próbował wejść w interakcję z wieloma długimi zadaniami, interfejs nie odpowiada, a nawet może przestać działać, jeśli wątek główny będzie zablokowany na dłuższy czas.
Aby wątek główny nie był zablokowany zbyt długo, możesz podzielić długie zadanie na kilka krótszych.
Jest to ważne, ponieważ po podzieleniu zadań 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 zadania, które zostały początkowo umieszczone w kolejce, zostaną wykonane.
U góry poniższego rysunku widać, że przetwarzanie zdarzenia wywołane przez interakcję z użytkownikiem musiało poczekać na zakończenie jednego długiego zadania, zanim mogło się rozpocząć. W efekcie interakcja została opóźniona. W tym przypadku użytkownik mógł zauważyć opóźnienie. W dolnej części interfejs może zacząć działać wcześniej, a interakcja może wydawać się natychmiastowa.
Teraz, gdy już wiesz, dlaczego warto dzielić zadania na mniejsze części, możesz się dowiedzieć, jak zrobić to w JavaScript.
Strategie zarządzania zadaniami
W architekturze oprogramowania często zaleca się dzielenie pracy na mniejsze funkcje:
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
W tym przykładzie występuje funkcja o nazwie saveSettings()
, która wywołuje 5 funkcji służących do sprawdzania poprawności formularza, wyświetlania Spinnera, wysyłania danych do aplikacji serwerowej, aktualizowania interfejsu użytkownika i wysyłania danych do Analytics.
Pod względem koncepcji saveSettings()
ma dobrze zaprojektowaną architekturę. Jeśli chcesz debugować jedną z tych funkcji, możesz przejść przez drzewo projektu, aby dowiedzieć się, do czego służy każda z nich. Taki podział pracy ułatwia poruszanie się po projektach i ich utrzymywanie.
Potencjalnym problemem jest jednak to, że JavaScript nie uruchamia każdej z tych funkcji jako osobnego zadania, ponieważ są one wykonywane w ramach funkcji saveSettings()
. Oznacza to, że wszystkie 5 funkcji będzie wykonywanych jako jedno zadanie.
W najlepszym przypadku nawet jedna z tych funkcji może wydłużyć czas trwania zadania o co najmniej 50 milisekund. W najgorszym przypadku więcej zadań może działać znacznie dłużej, zwłaszcza na urządzeniach o ograniczonych zasobach.
Ręczne opóźnianie wykonywania kodu
Jedną z metod, której deweloperzy używają do dzielenia zadań na mniejsze, jest setTimeout()
. W przypadku tej metody przekazujesz funkcję do setTimeout()
. To opóźnia wykonanie wywołania zwrotnego w ramach osobnego zadania, nawet jeśli określisz limit czasu 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. zwracanie i 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 wymagają przetworzenia 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żywanie funkcji setTimeout()
w tym przypadku jest problematyczne ze względu na ergonomię programistów, a przetworzenie całej tablicy danych może zająć dużo czasu, nawet jeśli każda iteracja przebiega szybko. Wszystko się sumuje, a setTimeout()
nie jest odpowiednim narzędziem do tego zadania – przynajmniej w przypadku takiego użycia.
Używanie funkcji async
/await
do tworzenia punktów zwrotu z inwestycji
Aby ważne zadania dotyczące użytkownika były wykonywane przed zadaniami o mniejszym priorytecie, możesz przekazać kontrolę głównemu wątkowi, przerywając na chwilę kolejkę zadań, aby dać przeglądarce możliwość wykonania ważniejszych zadań.
Jak wyjaśniliśmy wcześniej, uprawnienia setTimeout
można wykorzystać, aby wyświetlić się w wątku głównym. Dla wygody i czytelności możesz jednak wywołać funkcję setTimeout
w ramach funkcji Promise
i przekazać jej metodę resolve
jako funkcję wywołania zwrotnego.
function yieldToMain () {
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
Zaletą funkcji yieldToMain()
jest to, że możesz ją await
w dowolnej funkcji async
. Nawiązując do poprzedniego przykładu, możesz utworzyć tablicę funkcji do uruchomienia i uzyskać dostęp do wątku głównego po każdej z nich:
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();
}
}
W efekcie zadanie, które było monolityczne, jest teraz podzielone na osobne zadania.
Dedykowany interfejs planowania
setTimeout
to skuteczny sposób na dzielenie zadań, ale może mieć wady: gdy oddasz kontrolę głównemu wątkowi, opóźniając kod do wykonania w kolejnych zadaniach, to zadanie zostanie dodane do końca kolejki.
Jeśli masz kontrolę nad całym kodem na stronie, możesz utworzyć własny harmonogram z możliwością nadawania priorytetów zadaniom, ale skrypty innych firm nie będą z niego korzystać. W efekcie nie możesz ustalać priorytetów w takich środowiskach. Możesz je tylko dzielić na fragmenty lub bezpośrednio odpowiadać interakcjom użytkownika.
Interfejs Scheduler API udostępnia funkcję postTask()
, która umożliwia bardziej szczegółowe planowanie zadań. Jest to jeden ze sposobów na pomoc przeglądarce w ustalaniu priorytetów, dzięki czemu zadania o niskim priorytecie ustępują miejsca wątkowi głównemu. postTask()
używa obietnic i akceptuje jeden z 3 tych ustawień priority
:
'background'
do zadań o najniższym priorytecie.'user-visible'
do zadań o średnim priorytecie. Jest to ustawienie domyślne, jeśli nie ustawiono argumentupriority
.'user-blocking'
do wykonywania kluczowych zadań o wysokim priorytecie.
Weźmy za przykład kod, w którym interfejs API postTask()
jest używany do wykonywania 3 zadań o najwyższym priorytecie i 2 zadań o najniższym priorytecie.
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'});
};
Priorytety zadań są tu zaplanowane w taki sposób, aby zadania o wyższym priorytecie w przeglądarce (np. interakcje z użytkownikiem) mogły być wykonywane w miarę potrzeby.
To uproszczony przykład użycia funkcji postTask()
. Możesz tworzyć instancje różnych obiektów TaskController
, które mogą udostępniać priorytety między zadaniami, w tym zmieniać priorytety różnych instancji TaskController
w razie potrzeby.
Zintegrowana stopa zwrotu z kontynuacją za pomocą interfejsu scheduler.yield()
API
scheduler.yield()
to interfejs API zaprojektowany specjalnie do korzystania z głównego wątku w przeglądarce. Jej użycie jest podobne do funkcji yieldToMain()
, która została wcześniej omówiona w tym przewodniku:
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();
}
}
Ten kod jest w dużej mierze znajomy, ale zamiast yieldToMain()
używa await scheduler.yield()
.
Zaletą scheduler.yield()
jest kontynuacja, co oznacza, że jeśli zrezygnujesz w środku zestawu zadań, pozostałe zaplanowane zadania będą kontynuowane w tej samej kolejności po punkcie rezygnacji. Dzięki temu kod skryptów innych firm nie będzie zakłócać kolejności wykonywania Twojego kodu.
Nie używaj isInputPending()
Interfejs API isInputPending()
umożliwia sprawdzenie, czy użytkownik próbował wchodzić w interakcję ze stroną, i zwraca dane tylko wtedy, gdy dane wejściowe są oczekujące.
Dzięki temu JavaScript może kontynuować, gdy żadne dane wejściowe nie oczekują na działanie, zamiast generować i kończyć na końcu kolejki zadań. Może to przynieść znaczne zwiększenie wydajności, jak szczegółowo opisano w intencji wysyłki w przypadku witryn, które w innym przypadku mogłyby nie wrócić do głównego wątku.
Jednak od czasu uruchomienia tego interfejsu API nasze zrozumienie yielding wzrosło, zwłaszcza dzięki wprowadzeniu INP. Nie zalecamy już używania tego interfejsu API. Zamiast tego zalecamy zwracanie wartości niezależnie od tego, czy dane wejściowe są oczekujące czy nie, z kilku powodów:
isInputPending()
może nieprawidłowo zwrócić wartośćfalse
, mimo że użytkownik w pewnych okolicznościach wchodził w interakcję z reklamą.- Dane wejściowe to nie jedyny przypadek, w którym zadania powinny się poddawać. Animacje i inne regularne aktualizacje interfejsu użytkownika mogą być równie ważne jak w przypadku elastycznych stron internetowych.
- Od tego czasu udostępniliśmy bardziej kompleksowe interfejsy API do generowania danych, które rozwiązują problemy związane z generowaniem danych, takie jak
scheduler.postTask()
ischeduler.yield()
.
Podsumowanie
Zarządzanie zadaniami jest trudne, ale dzięki temu Twoja strona szybciej reaguje na interakcje użytkowników. Nie ma jednej rady, jak zarządzać zadaniami i przypisywać im priorytety, ale istnieje kilka różnych technik. Oto najważniejsze kwestie, które warto wziąć pod uwagę podczas zarządzania zadaniami:
- Oddać wątek główny w przypadku kluczowych zadań dla użytkowników.
- Nadawaj priorytet zadaniom za pomocą funkcji
postTask()
. - Poeksperymentuj z usługą
scheduler.yield()
. - Na koniec zrób jak najmniej w swojej funkcji.
Dzięki tym narzędziom możesz skonfigurować pracę w aplikacji tak, aby priorytetowo traktować potrzeby użytkownika, a jednocześnie zapewnić, że mniej istotne zadania też będą wykonywane. Dzięki temu użytkownicy będą mogli wygodniej korzystać z Twojej witryny.
Specjalne podziękowania dla Philipa Waltona za sprawdzenie techniczne tego przewodnika.
Miniatura pochodząca z kanału Unsplash, który udostępnił(a) Amirali Mirhashemian.