Optymalizuj długie zadania

Powszechnie dostępne porady dotyczące przyspieszenia działania aplikacji JavaScript to między innymi „Nie blokuj wątku głównego” i „Nie blokuj długich zadań”. Na tej stronie wyjaśniamy, co oznaczają te porady i dlaczego optymalizacja zadań w języku JavaScript jest ważna.

Co to jest zadanie?

Zadanie to osobna praca wykonywana przez przeglądarkę. Obejmuje to renderowanie, analizowanie kodu HTML i CSS, uruchamianie napisanego przez Ciebie kodu JavaScript oraz inne działania, nad którymi możesz nie mieć bezpośredniej kontroli. Kod JavaScript na Twoich stronach to główne źródło zadań przeglądarki.

Zrzut ekranu z zadaniami w prośbie o wydajność Narzędzi deweloperskich w Chrome. Zadanie znajduje się u góry stosu, a pod nim znajduje się moduł obsługi zdarzeń kliknięcia, wywołanie funkcji i inne elementy. Po prawej stronie znajdziesz też trochę pracy z renderowaniem.
Zadanie uruchomione przez moduł obsługi zdarzeń click w narzędziu do profilowania wydajności Narzędzi deweloperskich w Chrome.

Zadania wpływają na wydajność na kilka sposobów. Jeśli np. przeglądarka pobiera plik JavaScript podczas uruchamiania, umieszcza zadania w kolejce do przeanalizowania i skompilowania, tak aby można było go wykonać. Na późniejszym etapie cyklu życia strony inne zadania rozpoczynają się, gdy już działa JavaScript, np. kierowanie interakcji z użyciem modułów obsługi zdarzeń, animacje z JavaScriptu i działania w tle, np. zbieranie danych analitycznych. Wszystko to z wyjątkiem instancji roboczych i podobnych interfejsów API odbywa się w wątku głównym.

Jaki jest wątek główny?

Wątek główny to miejsce, w którym większość zadań jest uruchamiana w przeglądarce i wykonywana jest prawie cały kod JavaScript.

Wątek główny może przetwarzać tylko 1 zadanie naraz. Każde zadanie, które trwa dłużej niż 50 milisekund, jest liczone jako długie zadanie. Jeśli użytkownik spróbuje wejść w interakcję ze stroną podczas długiego zadania lub aktualizacji renderowania, przeglądarka musi poczekać na obsługę tej interakcji, co spowoduje opóźnienie.

Długie zadanie w narzędziu profilującym wydajność Narzędzi deweloperskich w Chrome. Część blokująca zadania (dłuższa niż 50 milisekund) jest oznaczona ukośnymi czerwonymi paskami.
Długie zadanie widoczne w programie profilu wydajności Chrome. Długie zadania są oznaczone czerwonym trójkątem w rogu zadania, a część blokująca jest wypełniona ukośnymi czerwonymi pasami.

Aby temu zapobiec, podziel każde długie zadanie na mniejsze zadania, których wykonanie trwa krócej. Jest to tzw. podzielenie długich zadań.

1 długie zadanie a to samo zadanie podzielone na krótsze. Długie zadanie to 1 duży prostokąt, a zadanie fragmentowe to 5 mniejszych pól, których długość odpowiada długości długiego zadania.
Wizualizacja 1 długiego zadania z tym samym zadaniem podzielonym na 5 krótszych zadań.

Podział zadań daje przeglądarce więcej możliwości reagowania na zadania o wyższym priorytecie, w tym na interakcje użytkowników między innymi zadaniami. Dzięki temu interakcje będą przebiegać znacznie szybciej, a użytkownik mógł zauważyć opóźnienie w oczekiwaniu na zakończenie długiego zadania.

Podział zadania może ułatwić interakcję użytkownika. U góry długie zadanie blokuje uruchamianie modułu obsługi zdarzeń do momentu jego zakończenia. U dołu zadania fragmentowane zadanie umożliwia modułowi obsługi zdarzeń szybsze działanie, niż miałoby to miejsce.
Gdy zadania są zbyt długie, przeglądarka nie może reagować wystarczająco szybko na interakcje. Podział zadań umożliwia szybsze wykonywanie tych interakcji.

Strategie zarządzania zadaniami

JavaScript traktuje każdą funkcję jako jedno zadanie, ponieważ wykorzystuje model wykonywania zadań od uruchomienia do zakończenia. Oznacza to, że funkcja wywołująca wiele innych funkcji (np. z przykładu poniżej) musi działać, dopóki nie zostaną one ukończone, co spowalnia przeglądarkę:

function saveSettings () { //This is a long task.
  validateForm();
  showSpinner();
  saveToDatabase();
  updateUI();
  sendAnalytics();
}
Funkcja saveSettings widoczna w programie profilu wydajności Chrome. Funkcja najwyższego poziomu wywołuje 5 innych funkcji, ale cała praca odbywa się w ramach jednego długiego zadania, które blokuje wątek główny.
Pojedyncza funkcja saveSettings(), która wywołuje 5 funkcji. To zadanie jest częścią jednego długiego zadania monolitycznego.

Jeśli Twój kod zawiera funkcje, które wywołują wiele metod, podziel go na kilka funkcji. Dzięki temu przeglądarka nie tylko ma więcej możliwości reagowania na interakcje, ale też ułatwia odczytywanie, obsługę i pisanie testów na potrzeby testów. W sekcjach poniżej omawiamy niektóre strategie podziału długich funkcji i nadawania priorytetów zadaniom, które się z nimi składają.

Ręczne opóźnienie wykonania kodu

Możesz opóźnić wykonanie niektórych zadań, przekazując odpowiednią funkcję do interfejsu setTimeout(). Będzie to działać nawet wtedy, gdy określisz limit czasu wynoszący 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);
}

Najlepiej sprawdza się to w przypadku szeregu funkcji, które trzeba uruchomić w określonej kolejności. Inaczej jest ustrukturyzowany kod. Następnym przykładem jest funkcja, która przetwarza dużą ilość danych za pomocą pętli. Im większy zbiór danych, tym dłużej potrwa ten proces. Niekoniecznie trzeba umieścić w pętli odpowiednie miejsce na setTimeout():

function processData () {
  for (const item of largeDataArray) {
    // Process the individual item here.
  }
}

Na szczęście jest kilka innych interfejsów API, które pozwalają odłożyć wykonanie kodu do późniejszego zadania. Aby skrócić czas oczekiwania, zalecamy używanie postMessage().

Możesz też przerwać pracę za pomocą funkcji requestIdleCallback(), ale zadania planowane są o najniższym priorytecie i tylko podczas bezczynności przeglądarki. Oznacza to, że jeśli wątek główny jest szczególnie zajęty, zadania zaplanowane w requestIdleCallback() mogą nigdy nie zostać uruchomione.

Użyj async/await do utworzenia punktów zysku

Aby mieć pewność, że ważne zadania dla użytkowników będą wykonywane przed zadaniami o niższym priorytecie, zacznij korzystać z wątku głównego przez krótkie przerwanie kolejki zadań, aby przeglądarka mogła uruchomić ważniejsze zadania.

Najprostszy sposób to Promise, który kończy się wywołaniem funkcji setTimeout():

function yieldToMain () {
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

W funkcji saveSettings() możesz przejść do wątku głównego po każdym kroku, jeśli await wywołasz funkcję yieldToMain() po każdym jej wywołaniu. W ten sposób możesz podzielić długie zadanie na kilka zadań:

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();
  }
}

Co ważne, nie musisz wykonywać każdego wywołania funkcji. Jeśli np. uruchamiasz 2 funkcje, które wymagają krytycznych aktualizacji interfejsu, prawdopodobnie lepiej nie korzystać z nich. Jeśli to możliwe, uruchom tę pracę najpierw, a potem rozważ przejście na funkcje wykonywane w tle lub mniej ważne zadania, których użytkownik nie widzi.

Ta sama funkcja saveSettings jest też dostępna w narzędziu do profilowania wydajności Chrome, teraz z obciążeniem.
    Zadanie zostało podzielone na 5 osobnych zadań – po 1 na każdą funkcję.
Funkcja saveSettings() wykonuje teraz swoje funkcje podrzędne jako osobne zadania.

Specjalny interfejs API algorytmu szeregowania

Wymienione dotychczas interfejsy API mogą pomóc w rozbiciu zadań, ale mają one istotną wadę: gdy uruchamiasz wątek główny przez odroczenie kodu do uruchomienia w późniejszym zadaniu, kod jest dodawany na końcu kolejki zadań.

Jeśli kontrolujesz cały kod na swojej stronie, możesz utworzyć własny algorytm szeregowania, aby nadać priorytet zadaniom. Skrypty innych firm nie będą jednak używać Twojego algorytmu szeregowania, więc w takim przypadku nie możesz nadawać priorytetów pracy. Możesz je rozdzielić lub pozwolić tylko na interakcje użytkowników.

Obsługa przeglądarek

  • 94
  • 94
  • x

Źródło

Interfejs API algorytmu szeregowania udostępnia funkcję postTask(), która umożliwia bardziej precyzyjne planowanie zadań i pomaga przeglądarce nadawać priorytety pracy, tak aby zadania o niskim priorytecie były wykonywane w wątku głównym. postTask() korzysta z obietnic i akceptuje ustawienie priority.

Interfejs postTask() API ma 3 priorytety:

  • 'background' dla zadań o najniższym priorytecie.
  • 'user-visible' dla zadań o średnim priorytecie. Jest to ustawienie domyślne, jeśli nie ustawiono żadnej funkcji priority.
  • 'user-blocking' na potrzeby krytycznych zadań, które muszą być wykonywane z wysokim priorytetem.

Ten przykładowy kod korzysta z interfejsu API postTask() do uruchamiania 3 zadań o najwyższym możliwym priorytecie, a pozostałe 2 o najniższym możliwym 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'});
};

W tym miejscu priorytety zadań są planowane tak, aby zadania priorytetowe w przeglądarce, takie jak interakcje z użytkownikami, mogły działać poprawnie.

Funkcja saveSettings widoczna w programie profilu wydajności Chrome, ale z użyciem postTask. PostTask dzieli każdą z uruchomionych funkcji i nadaje jej priorytet, tak aby interakcja użytkownika mogła być uruchamiana bez blokowania.
Po uruchomieniu saveSettings() funkcja planuje poszczególne wywołania funkcji za pomocą postTask(). Kluczowe zadania dla użytkowników są planowane z wysokim priorytetem, a zadania, o których użytkownik nie wie, mają być uruchamiane w tle. Przyspiesza to wykonywanie interakcji użytkowników, ponieważ zadania są podzielone i mają odpowiedni priorytet.

Możesz też tworzyć wystąpienia różnych obiektów TaskController, które mają wspólne priorytety między zadaniami, w tym możliwość zmiany priorytetów różnych instancji TaskController w razie potrzeby.

Wbudowany zysk z możliwością kontynuacji dzięki nowemu interfejsowi API scheduler.yield()

Co ważne, aby dowiedzieć się więcej o funkcji scheduler.yield(), poczytaj o jej badaniu dotyczącym origin (od momentu zakończenia) oraz o jego wyjaśnieniu.

Jednym z proponowanych sposobów dodania do interfejsu API algorytmu szeregowania jest scheduler.yield(), interfejs API zaprojektowany specjalnie pod kątem generowania ruchu do głównego wątku w przeglądarce. Sposób użycia przypomina funkcję yieldToMain() przedstawioną wcześniej na tej stronie:

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 znany, ale zamiast yieldToMain() stosuje się w nim await scheduler.yield().

Trzy diagramy przedstawiające zadania bez wywoływania, z uzyskiwaniem oraz z uzyskaniem i kontynuacją. Zadania bez uciążliwości są bardzo długie. Dzięki temu istnieje więcej krótszych zadań, ale mogą one zostać przerwane przez inne, niepowiązane zadania. Przy generowaniu i kontynuowaniu zadań kolejność wykonywania zadań jest zachowywana.
Gdy używasz scheduler.yield(), wykonanie zadania jest wznawiane od miejsca, w którym zostało przerwane, nawet po osiągnięciu punktu zysku.

Zaletą funkcji scheduler.yield() jest kontynuacja, co oznacza, że jeśli wykonasz zadanie w trakcie zestawu zadań, po osiągnięciu punktu zysku pozostałe zaplanowane zadania będą kontynuowane w tej samej kolejności. Dzięki temu skrypty innych firm nie przejmą kontroli nad kolejnością wykonywania kodu.

Używanie scheduler.postTask() w połączeniu z priority: 'user-blocking' ma duże prawdopodobieństwo kontynuacji ze względu na wysoki priorytet user-blocking. Możesz więc używać tej alternatywy, dopóki scheduler.yield() nie stanie się szerzej dostępny.

Użycie funkcji setTimeout() (lub scheduler.postTask() z priority: 'user-visible' lub bez jawnego priority) planuje zadanie z tyłu kolejki, umożliwiając uruchamianie innych oczekujących zadań przed kontynuacją.

Zysk na podstawie danych wejściowych z elementem isInputPending()

Obsługa przeglądarek

  • 87
  • 87
  • x
  • x

Interfejs API isInputPending() umożliwia sprawdzenie, czy użytkownik próbował wejść w interakcję ze stroną, i zyskuje tylko wtedy, gdy dane wejściowe oczekują na odpowiedź.

Dzięki temu JavaScript będzie kontynuował, jeśli żadne dane wejściowe nie będą oczekujące, zamiast znikać i kierować się na koniec kolejki zadań. Może to znacznie zwiększyć skuteczność (zgodnie z opisem w zamiarze wysyłki) w przypadku witryn, które w przeciwnym razie nie zwracałyby się do wątku głównego.

Jednak od czasu wprowadzenia tego interfejsu API nasza wiedza na temat zysków wzrosła, zwłaszcza po wprowadzeniu INP. Nie zalecamy już używania tego interfejsu API. Zamiast tego zalecamy generowanie niezależnie od tego, czy dane wejściowe są oczekujące, czy nie. Ta zmiana w rekomendacjach ma kilka przyczyn:

  • W niektórych przypadkach, gdy użytkownik wszedł w interakcję z reklamą, interfejs API może nieprawidłowo zwrócić wartość false.
  • Dane wejściowe nie muszą być jedynym przypadkiem, w którym powinny się pojawić zadania. Animacje i inne regularne aktualizacje interfejsu mogą być równie ważne dla stworzenia elastycznej strony internetowej.
  • Od tego czasu wprowadzono bardziej wszechstronne interfejsy API, takie jak scheduler.postTask() i scheduler.yield(), aby rozwiązać problemy z zyskami.

Podsumowanie

Zarządzanie zadaniami jest trudne, ale dzięki temu Twoja strona będzie szybciej reagować na interakcje użytkowników. Jest wiele technik zarządzania zadaniami i określania ich priorytetów w zależności od przypadku użycia. Przypomnę, że zarządzając zadaniami, musisz pamiętać o tych kwestiach:

  • Udzielanie wątkom głównym zadań o znaczeniu krytycznym, które są widoczne dla użytkownika.
  • Rozważ eksperymentowanie z: scheduler.yield().
  • Ustal priorytety zadań za pomocą aplikacji postTask().
  • I wreszcie, rób jak najmniejszą pracę nad funkcjami.

Co najmniej 1 z tych narzędzi pozwoli Ci uporządkować zadania w aplikacji w taki sposób, aby priorytetowo traktowały potrzeby użytkownika, a jednocześnie zapewniły możliwość wykonania mniej istotnych czynności. Zwiększa to wygodę użytkowników, dzięki czemu jest bardziej responsywny i wygodniejszy w użyciu.

Specjalne podziękowania dla Philipa Waltona za sprawdzenie techniczne tego dokumentu.

Miniatura pochodzi ze strony UnsplashAmirali Mirhashemian.