Optymalizuj długie zadania

Usłyszeliśmy, że „nie należy blokować wątku głównego” i „należy podzielić długie zadania”, ale co to oznacza?

Typowe wskazówki dotyczące utrzymywania szybkości aplikacji JavaScript sprowadzają się do tych porad:

  • „Nie blokuj głównego wątku”.
  • „Podziel długie zadania na mniejsze”.

To świetna rada, ale ile pracy to wymaga? Użycie mniejszej ilości kodu JavaScript jest dobre, ale czy automatycznie oznacza to szybsze interfejsy użytkownika? Może tak, a może 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ę. Do tych zadań należy renderowanie, analizowanie kodu HTML i CSS, uruchamianie kodu JavaScript oraz inne zadania, nad którymi nie masz bezpośredniej kontroli. Spośród wszystkich tych zadań najwięcej czasu zajmuje prawdopodobnie pisanie kodu JavaScript.

Wizualizacja zadania w profilu wydajności w Narzędziach deweloperskich w Chrome Zadanie znajduje się u góry stosu, a pod nim znajdują się: przetwarzacz zdarzenia kliknięcia, wywołanie funkcji i kolejne elementy. Zadanie obejmuje też renderowanie po prawej stronie.
Zadanie rozpoczęte przez click w obejmującym profiler wydajności w Narzędziach deweloperskich w Chrome.

Zadania związane z JavaScriptem wpływają na wydajność w kilka sposoby:

  • Gdy przeglądarka pobiera plik JavaScript podczas uruchamiania, umieszcza w kolejnych zadaniach zadania analizowania i kompilowania kodu JavaScript, aby można go było później wykonać.
  • 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?

Główny wątek to miejsce, w którym wykonywane są w przeglądarce większość zadań i w którym wykonywany jest 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 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.

Podczas wykonywania zadania o dowolnej długości przeglądarka blokuje interakcje, ale użytkownik nie zauważa tego, dopóki zadania nie są wykonywane zbyt długo. Gdy użytkownik próbuje wchodzić w interakcję ze stroną, na której jest wiele długich zadań, interfejs może nie odpowiadać, a nawet nie działać, jeśli wątek główny jest zablokowany przez bardzo długi czas.

Długie zadanie w profilu wydajności w Narzędziach deweloperskich w Chrome. Część zadania, która blokuje działanie aplikacji (ponad 50 ms), jest oznaczona czerwonymi ukośnymi paskami.
Długie zadanie w profilu wydajności Chrome. Długie zadania są oznaczone czerwonym trójkątem w rogu zadania, a blokująca część zadania jest wypełniona czerwonymi paskami.

Aby wątek główny nie był zablokowany zbyt długo, możesz podzielić długie zadanie na kilka krótszych.

Jedno długie zadanie w porównaniu z tym samym zadaniem podzielonym na krótsze zadania. Długie zadanie to jeden duży prostokąt, a zadanie podzielone na fragmenty to 5 mniejszych pól, które razem mają taką samą szerokość jak długie zadanie.
Wizualizacja pojedynczego długiego zadania w porównaniu z tym samym zadaniem podzielonym na 5 krótszych zadań.

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 zapewnia, że zadania, które zostały początkowo umieszczone w kolejce, zostaną wykonane.

Ilustracja pokazująca, jak podział zadania może ułatwić interakcję z użytkownikiem. U góry długie zadanie blokuje działanie metody obsługi zdarzenia, dopóki nie zostanie ono ukończone. U dołu zadanie podzielone na fragmenty pozwala obsłudze zdarzenia działać szybciej niż normalnie.
Wizualizacja tego, co dzieje się z interakcjami, gdy zadania są zbyt długie i przeglądarka nie może na nie odpowiednio szybko reagować, w porównaniu z tym, gdy dłuższe zadania są dzielone na mniejsze.

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 strony moduł obsługi zdarzenia 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.

Potencjalny problem polega na tym, ż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.

Funkcja saveSettings w profilu wydajności Chrome Chociaż funkcja najwyższego poziomu wywołuje 5 innych funkcji, cała praca odbywa się w ramach jednego długiego zadania, które blokuje wątek główny.
1 funkcja saveSettings(), która wywołuje 5 funkcji. Praca jest wykonywana w ramach jednego długiego zadania monolitycznego.

W najlepszym przypadku nawet jedna z tych funkcji może wydłużyć czas wykonania zadania o 50 ms lub więcej. 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 tej metodzie przekazujesz funkcję do funkcji 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);
}

Nazywa się to zwracaniem i najlepiej sprawdza się w przypadku serii funkcji, które muszą być wykonywane sekwencyjnie.

Nie zawsze jednak kod 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życie tutaj funkcji setTimeout() jest problematyczne z powodu ergonomii programisty, a przetworzenie całego zbioru danych może zająć bardzo dużo czasu, nawet jeśli każda pojedyncza iteracja przebiega szybko. Wszystko się zgadza, a setTimeout() nie jest odpowiednim narzędziem do tego zadania – przynajmniej nie w taki sposób.

Używanie funkcji async/await do tworzenia punktów zwrotu

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 już wspomnieliśmy, funkcji setTimeout można użyć, aby przekazać kontrolę wątkowi głównemu. 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ą awaitw dowolnej funkcji async. Wykorzystując poprzedni przykład, możesz utworzyć tablicę funkcji do wykonania i zwracać do głównego wątku po wykonaniu 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.

Ta sama funkcja saveSettings przedstawiona w profilu wydajności Chrome, ale z funkcją yielding. W rezultacie zadanie, które wcześniej było jednolite, zostało podzielone na 5 osobnych zadań – po jednym dla każdej funkcji.
Funkcja saveSettings() wykonuje teraz swoje podfunkcje jako osobne zadania.

Dedykowany interfejs planowania

setTimeout to skuteczny sposób na dzielenie zadań, ale może mieć wadę: 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 tylko podzielić go na części lub wyraźnie umożliwić interakcje z użytkownikiem.

Obsługa przeglądarek

  • Chrome: 94.
  • Edge: 94.
  • Firefox: za pomocą flagi.
  • Safari: nieobsługiwane.

Źródło

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 zadań, dzięki czemu zadania o niskim priorytecie ustępują miejsca wątkowi głównemu. postTask() używa obietnic i akceptuje jedno z 3 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 argumentu priority.
  • 'user-blocking' w przypadku kluczowych zadań, które muszą być wykonywane z wysokim priorytetem.

Weźmy na 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.

Funkcja saveSettings przedstawiona w profilu wydajności Chrome, ale z wykorzystaniem postTask. Funkcja postTask dzieli każdą funkcję saveSettings na części i przypisuje im priorytety tak, aby interakcja użytkownika miała szansę na wykonanie bez blokowania.
Gdy funkcja saveSettings() jest uruchamiana, planuje poszczególne funkcje za pomocą funkcji postTask(). Krytyczne zadania dotyczące użytkownika są zaplanowane z wysokim priorytetem, a zadania, o których użytkownik nie wie, są zaplanowane do wykonania w tle. Dzięki temu interakcje z użytkownikiem będą przebiegać szybciej, ponieważ zadania są dzielone i odpowiednio priorytetyzowane.

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

Obsługa przeglądarek

  • Chrome: 129.
  • Edge: 129.
  • Firefox: nieobsługiwane.
  • Safari: nieobsługiwane.

Źródło

scheduler.yield() to interfejs API zaprojektowany specjalnie do uruchamiania głównego wątku w przeglądarce. Jej użycie jest podobne do funkcji yieldToMain(), która została zaprezentowana wcześniej 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().

3 diagramy przedstawiające zadania bez rezygnacji, z rezygnacją i z rezygnacją i kontynuacją. Bez stosowania Yielding występują długie zadania. W przypadku yielding jest więcej krótszych zadań, które mogą być przerywane przez inne, niezwiązane z nimi zadania. Dzięki rezygnacji i kontynuacji jest więcej krótszych zadań, ale ich kolejność wykonania jest zachowana.
Gdy używasz scheduler.yield(), wykonanie zadania rozpoczyna się od miejsca, w którym zostało przerwane, nawet po punkcie wycofania.

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 aplikacji isInputPending()

Obsługa przeglądarek

  • Chrome: 87.
  • Edge: 87.
  • Firefox: nieobsługiwane.
  • Safari: nieobsługiwane.

Źródło

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ć działanie, jeśli nie ma oczekujących danych wejściowych, zamiast przekazać kontrolę i przeskakiwać na koniec 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:

  • W niektórych okolicznościach funkcja isInputPending() może zwracać wartość false, mimo że użytkownik wszedł w interakcję.
  • 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 dla zapewnienia strony internetowej w wersji responsywnej.
  • 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()scheduler.yield().

Podsumowanie

Zarządzanie zadaniami jest trudne, ale dzięki temu Twoja strona szybciej reaguje na interakcje użytkowników. Nie ma jednej rady dotyczącej zarządzania zadaniami i ich priorytetyzacji, ale istnieje kilka różnych technik. Oto najważniejsze kwestie, które należy wziąć pod uwagę podczas zarządzania zadaniami:

  • Oddawanie głównego wątku w przypadku kluczowych zadań dotyczących użytkownika.
  • Określaj priorytety zadań za pomocą postTask().
  • Rozważ eksperymentowanie z opcją 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.

Obraz miniatury pochodzący z Unsplash, dzięki uprzejmości Amirali Mirhashemian.