Optymalizuj długie zadania

Poproszono Cię o „nie blokuj wątku głównego” i „podziel długie zadania”, ale co to znaczy robić te rzeczy?

Typowe porady dotyczące szybkiego działania aplikacji JavaScript sprowadzają się do następujących wskazówek:

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

To dobra wskazówka, ale jak wygląda praca w niej? 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 języku JavaScript, musisz wiedzieć, czym są zadania i jak przeglądarka je obsługuje.

Co to jest zadanie?

Zadanie to dowolne pojedyncze zadanie wykonywane 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 elementów napisany przez Ciebie JavaScript jest prawdopodobnie największym źródłem zadań.

Wizualizacja zadania na przykład w narzędziu do zarządzania wydajnością w Narzędziach deweloperskich w Chrome. Zadanie znajduje się u góry stosu. Zawiera moduł obsługi zdarzeń kliknięcia, wywołanie funkcji i inne elementy poniżej. Zadanie obejmuje też zadania renderowania po prawej stronie.
Zadanie uruchomione przez moduł obsługi zdarzeń click w narzędziu profilowym w Narzędziach deweloperskich w Chrome.

Zadania powiązane z JavaScriptem wpływają na wydajność na kilka sposobów:

  • 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.
  • Czasami po otwarciu strony zadania są umieszczane w kolejce, gdy działa JavaScript. Dotyczy to na przykład wywoływania interakcji za pomocą modułów obsługi zdarzeń, animacji opartych na języku JavaScript i aktywności w tle, np. zbierania danych analitycznych.

Wszystkie te rzeczy – z wyjątkiem instancji roboczych i podobnych interfejsów API – odbywają się w wątku głównym.

Jaki 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 1 zadanie naraz. Każde zadanie, które trwa dłużej niż 50 milisekund, jest długim zadaniem. W przypadku zadań przekraczających 50 milisekund łączny czas działania 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, 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.

To długie zadanie w narzędziu profilowania wydajności w Narzędziach deweloperskich w Chrome. Blokująca część zadania (dłuższa niż 50 milisekund) jest przedstawiona za pomocą czerwonych ukośnych pasów.
Długie zadanie przedstawione w narzędziu do profilowania wydajności Chrome. Długie zadania są oznaczone w rogu czerwonym trójkątem, a blokująca część zadania jest wypełniona ukośnymi czerwonymi pasami.

Aby uniknąć zbyt długiego blokowania wątku głównego, możesz podzielić długie zadanie na kilka mniejszych.

Jedno długie zadanie i to samo zadanie podzielone na krótsze zadania. Długie zadanie to jeden duży prostokąt, a zadanie podzielone na fragmenty to pięć mniejszych pól o takiej samej szerokości jak długie zadanie.
Wizualizacja 1 długiego zadania w porównaniu z tym samym zadaniem w podziale na 5 krótszych zadań.

Ma to znaczenie, ponieważ gdy zadania są podzielone, przeglądarka może reagować na działania o wyższym priorytecie znacznie szybciej – także na interakcje użytkowników. Później pozostałe zadania są wykonywane do końca, dzięki czemu zadania umieszczone na początku kolejki zostały wykonane.

Ilustracja pokazująca, jak podzielenie zadania może ułatwić użytkownikowi interakcję. U góry długie zadanie blokuje działanie modułu obsługi zdarzeń do momentu jego ukończenia. Zadanie posegmentowane na dole umożliwia działanie modułu obsługi zdarzeń szybciej, niż byłoby to w Twoim przypadku.
Wizualizacja tego, co dzieje się z interakcjami, gdy zadania są zbyt długie, a przeglądarka nie reaguje wystarczająco szybko na interakcje, a dłuższe zadania dzielą się na mniejsze.

U góry powyższej liczby widać, że moduł obsługi zdarzeń umieszczony w kolejce przez interakcję użytkownika musiał czekać na rozpoczęcie długiego zadania. Powoduje to opóźnienia w realizacji interakcji. W takim przypadku użytkownik mógł zauważyć opóźnienie. U dołu moduł obsługi zdarzeń może zacząć działać wcześniej, a interakcja może wyglądać natychmiast.

Skoro już wiesz, dlaczego dzielenie zadań jest tak ważne, dowiedz się, jak to robić w języku JavaScript.

Strategie zarządzania zadaniami

W architekturze oprogramowania zaleca się, aby podzielić swoją pracę na mniejsze obszary:

function saveSettings () {
  validateForm();
  showSpinner();
  saveToDatabase();
  updateUI();
  sendAnalytics();
}

W tym przykładzie jest to funkcja o nazwie saveSettings(), która wywołuje 5 funkcji w celu weryfikacji formularza, wyświetlenia wskaźnika postępu, wysyłania danych do backendu aplikacji, aktualizacji interfejsu użytkownika i wysyłania statystyk.

Zasadniczo saveSettings() to dobrze architektura. Jeśli musisz debugować jedną z tych funkcji, możesz przejrzeć drzewo projektu, aby dowiedzieć się, jak działa każda z nich. Taki podział ułatwia nawigowanie w projektach i ich obsługę.

Potencjalny problem może jednak polegać na tym, że JavaScript nie uruchamia każdej z tych funkcji jako osobnych zadań, ponieważ są one wykonywane w ramach funkcji saveSettings(). Oznacza to, że wszystkie 5 funkcji będzie działać jako jedno zadanie.

funkcja saveSettings przedstawiona w narzędziu do profilowania wydajności Chrome. Podczas gdy 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.
Pojedyncza funkcja saveSettings(), która wywołuje 5 funkcji. Zadanie jest uruchamiane w ramach jednego długiego zadania monolitycznego.

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ększość z tych zadań może działać znacznie dłużej – zwłaszcza na urządzeniach z ograniczonymi zasobami.

Ręczne odroczenie wykonania kodu

Jedna z metod używanych przez deweloperów do dzielenia zadań na mniejsze wymaga setTimeout(). W przypadku tej metody przekazujesz funkcję do setTimeout(). Opóźnia to wykonanie wywołania zwrotnego do osobnego zadania, nawet jeśli ustawisz 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. zysk i najlepiej sprawdza się w przypadku serii funkcji, które muszą działać sekwencyjnie.

Pamiętaj jednak, że kod nie zawsze jest zorganizowany w ten sposób. Możliwe na przykład, że masz do przetworzenia dużą ilość danych w pętli, a to zadanie może zająć bardzo dużo czasu w przypadku wielu iteracji.

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

Używanie narzędzia setTimeout() w tym przypadku jest problematyczne ze względu na ergonomię programistów, a przetworzenie całej tablicy danych może zająć bardzo dużo czasu, nawet jeśli każda iteracja przebiega szybko. Wszystko się sumuje, a setTimeout() nie jest odpowiednim narzędziem do tego celu – przynajmniej w takim przypadku.

Aby utworzyć punkty zysku, użyj wartości async/await

Aby mieć pewność, że ważne zadania, które widzi użytkownik, będą wykonywane przed zadaniami o niższym priorytecie, możesz przekazać wątek główny do chwilowego przerwania kolejki zadań, co da przeglądarce możliwość uruchomienia ważniejszych zadań.

Jak wyjaśniliśmy wcześniej, uprawnienia setTimeout można wykorzystać, aby przejść do wątku głównego. Dla wygody i poprawia czytelności możesz jednak wywołać funkcję setTimeout w elemencie Promise i przekazać jej metodę resolve jako wywołanie zwrotne.

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 rezultacie zadanie, które dotychczas było monolityczne, zostało podzielone na osobne zadania.

Ta sama funkcja SaveSettings, która jest dostępna w narzędziu do profilowania wydajności Chrome, tylko z uzyskaniem wyników. W efekcie dotychczasowe zadanie monolityczne zostało podzielone na 5 zadań – po jednym dla każdej funkcji.
Funkcja saveSettings() wykonuje teraz swoje funkcje podrzędne jako osobne zadania.

Dedykowany interfejs API algorytmu szeregowania

Funkcja setTimeout to skuteczny sposób dzielenia zadań, ale ma też wadę: gdy przejdziesz do wątku głównego przez odroczenie kodu do wykonania w kolejnym zadaniu, to zadanie zostanie dodane na koniec kolejki.

Jeśli kontrolujesz cały kod na stronie, możesz utworzyć własny algorytm szeregowania z możliwością ustalania priorytetów zadań, ale skrypty innych firm nie będą z niego korzystać. W takiej sytuacji nie możesz ustalać priorytetów pracy w takich środowiskach. Możesz je tylko dzielić na fragmenty lub bezpośrednio odpowiadać interakcjom użytkownika.

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 jest jednym ze sposobów na nadanie przeglądarce priorytetu prac, tak aby zadania o niskim priorytecie trafiały do wątku głównego. postTask() korzysta z obietnic i akceptuje jedno z 3 ustawień priority:

  • 'background' dla zadań o najniższym priorytecie.
  • 'user-visible' w przypadku zadań o średnim priorytecie. Jest to wartość domyślna, jeśli nie ustawiono żadnego elementu priority.
  • 'user-blocking' w przypadku krytycznych zadań, które muszą być uruchamiane z wysokim priorytetem.

Oto przykładowy kod, w którym interfejs API postTask() służy do uruchamiania 3 zadań o najwyższym możliwym priorytecie, a pozostałe 2 zadania 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 przypadku priorytety zadań są planowane w taki sposób, że zadania priorytetowe w przeglądarce (np. interakcje z użytkownikiem) mogą być realizowane w miarę potrzeb.

Funkcja SaveSettings jest przedstawiona w narzędziu do profilowania wydajności Chrome, ale przy użyciu postTask. postTask dzieli każdą funkcję saveSettings i nadaje jej priorytety tak, aby interakcja użytkownika była możliwa bez zablokowania.
Po uruchomieniu saveSettings() funkcja planuje poszczególne funkcje za pomocą postTask(). Kluczowe zadania wykonywane przez użytkowników są zaplanowane z wysokim priorytetem, natomiast zadania, o których użytkownik nie wie, są zaplanowane w tle. Dzięki temu interakcje z użytkownikiem mogą być realizowane szybciej, ponieważ praca jest podzielona i nadająca odpowiedni priorytet.

To tylko uproszczony przykład użycia pola postTask(). Można tworzyć wystąpienia różnych obiektów TaskController, które mogą mieć wspólne priorytety między zadaniami. Obejmuje to możliwość zmiany priorytetów różnych instancji TaskController w razie potrzeby.

Wbudowany zysk z kontynuacją przy użyciu nadchodzącego interfejsu API scheduler.yield()

Jedną z proponowanych nowości do interfejsu API algorytmu szeregowania jest scheduler.yield() – interfejs API zaprojektowany specjalnie w celu generowania zysków do głównego wątku w przeglądarce. Przypomina funkcję yieldToMain() omówioną 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 dobrze znany, ale zamiast kodu yieldToMain() używa się await scheduler.yield().

Trzy diagramy przedstawiające zadania bez generowania, zysku oraz zysku i kontynuacji. W przeciwnym razie trzeba czekać na długie zadania. Dzięki generowaniu wyników jest więcej zadań, które są krótsze, ale mogą je zakłócać inne, niepowiązane zadania. Przy generowaniu i kontynuacji jest więcej zadań, które są krótsze, ale kolejność ich wykonywania jest zachowywana.
Gdy używasz metody scheduler.yield(), wykonanie zadania zaczyna się w miejscu, w którym zostało przerwane, nawet po punkcie zysku.

Zaletą funkcji scheduler.yield() jest kontynuacja, co oznacza, że jeśli wykonasz zadanie w środku zestawu zadań, pozostałe zaplanowane zadania będą kontynuowane w tej samej kolejności po punkcie zysku. Dzięki temu kod skryptów zewnętrznych nie zakłóca kolejności wykonywania kodu.

Użycie polecenia scheduler.postTask() w połączeniu z zasadą priority: 'user-blocking' również wiąże się z wysokim prawdopodobieństwem dotyczącym kontynuacji ze względu na wysoki priorytet user-blocking, więc tymczasem możesz używać tego rozwiązania.

Użycie zasady setTimeout() (lub scheduler.postTask() z zasadą priority: 'user-visibile' albo brak jawnie zdefiniowanej zasady priority) powoduje zaplanowanie zadania z tyłu kolejki, dzięki czemu umożliwia uruchomienie innych oczekujących zadań przed kontynuacją.

Nie używaj 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 uzyskuje go tylko w przypadku oczekiwania na odpowiedź.

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 znacząco poprawić wydajność, zgodnie z opisem w zamianie wysyłki, w przypadku stron, które w przeciwnym razie nie wróciłyby do głównego wątku.

Jednak od czasu wprowadzenia tego interfejsu API nasza wiedza na temat zysków wzrosła, zwłaszcza po wprowadzeniu INP. Nie zalecamy już korzystania z tego interfejsu API. Zamiast tego zalecamy rekomendowanie niezależnie od tego, czy dane wejściowe oczekują, czy nie. Istnieje kilka 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 przynieść wyniki. Animacje i inne regularne aktualizacje interfejsu użytkownika mogą być równie ważne jak w przypadku elastycznych stron internetowych.
  • Od tego czasu wprowadziliśmy bardziej wszechstronne interfejsy API do generowania przychodów, które rozwiązują problemy związane z pozyskiwaniem zasobów, takie jak scheduler.postTask() i scheduler.yield().

Podsumowanie

Zarządzanie zadaniami nie jest łatwe, ale dzięki temu strona szybciej reaguje na interakcje użytkowników. Nie istnieje jedna konkretna rada dotycząca zarządzania zadaniami i określania ich priorytetów, a jedynie szereg różnych technik. Przypomnijmy: podczas zarządzania zadaniami warto wziąć pod uwagę główne kwestie:

  • 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 wykonuj jak najwięcej pracy w funkcjach.

Co najmniej jedno z tych narzędzi pozwala uporządkować pracę w aplikacji w taki sposób, aby traktowała priorytetowo potrzeby użytkownika, a jednocześnie dawała pewność, że mniej ważna praca jest wykonywana. Zwiększy to wygodę użytkowników, którzy będą mogli szybciej i wygodniej korzystać z reklam.

Specjalne podziękowania za kontrolę techniczną tego przewodnika Philipowi Waltonowi.

Miniatura pochodząca z kanału Unsplash, który udostępnił(a) Amirali Mirhashemian.