Usłyszałeś(-aś), „nie blokuj głównego wątku” i „podziel dłuższe zadania”. Ale co to oznacza?
Jeśli czytasz dużo informacji o wydajności w internecie, warto podpowiedzieć, jak szybko korzystać z aplikacji JavaScript:
- „Nie blokuj głównego wątku”.
- „Podziel swoje długie zadania”.
Co to oznacza? Używanie mniej kodu JavaScript jest przydatne, ale czy to oznacza, że interfejs jest bardziej atrakcyjny w całym cyklu życia strony? Być może, ale niekoniecznie.
Aby zrozumieć, dlaczego tak ważne jest optymalizowanie zadań w języku JavaScript, musisz zrozumieć rolę zadań i sposób, w jaki je obsługuje przeglądarka. Najpierw musisz zrozumieć, czym jest zadanie.
Co to jest zadanie?
Zadanie to dowolna praca wykonywana przez przeglądarkę. Zadania obejmują wykonywanie takich czynności jak renderowanie, analizowanie kodu HTML i CSS, uruchamianie pisanego kodu JavaScript oraz wykonywanie innych zadań, nad którymi nie masz bezpośredniej kontroli. Przede wszystkim to kod JavaScript, który tworzysz i wdrażasz w internecie, jest głównym źródłem zadań.
Zadania wpływają na wydajność na kilka sposobów. Jeśli na przykład przeglądarka pobiera plik JavaScript podczas uruchamiania, dodaje zadania do analizy i skompilowania kodu JavaScript, aby umożliwić ich wykonanie. Na późniejszym etapie cyklu życia strony zadania są uruchamiane, gdy JavaScript zaczyna działać, np. generuje interakcje za pomocą modułów obsługi zdarzeń, animacji JavaScript i działań w tle, takich jak zbieranie danych analitycznych. Wszystko to z wyjątkiem instancji roboczych i podobnych interfejsów API odbywa 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. Nie bez powodu nosi on nazwę głównego wątku: jest to jeden wątek, w którym niemal cały napisany kod JavaScript wykonuje swoje zadanie.
Wątek główny może przetworzyć tylko 1 zadanie naraz. Zadania, które przekraczają określony czas (dokładnie 50 milisekund), są klasyfikowane jako długie zadania. Jeśli użytkownik będzie próbować wejść w interakcję ze stroną podczas długiego działania lub jeśli konieczna będzie ważna aktualizacja renderowania, przeglądarka będzie z tym opóźniona. Powoduje to opóźnienie interakcji lub renderowania.
Musisz podzielić zadania. Oznacza to, że trzeba będzie zająć się jednym długim zadaniem i podzielić je na mniejsze zadania, których każde uruchomienie jest czasochłonne.
Ma to znaczenie, ponieważ podział zadań daje przeglądarce więcej możliwości reakcji na zadania o wyższym priorytecie, co obejmuje też interakcje użytkowników.
Na górze poprzedniej ilustracji moduł obsługi zdarzeń ustawiony w kolejce przez interakcję użytkownika musiał czekać na jedno długie zadanie, zanim mogło zostać uruchomione. Powoduje to opóźnienia interakcji. U dołu moduł obsługi zdarzeń może uruchomić się szybciej. Moduł obsługi zdarzeń mógł działać między mniejszymi zadaniami, więc działa szybciej, niż gdyby musiał czekać na zakończenie długiego zadania. W pierwszym przykładzie użytkownik mógł zauważyć opóźnienie, a w dolnej części interakcja może być natychmiastowa.
Problem polega jednak na tym, że rada „podziel długie zadania” i „nie blokuj głównego wątku” nie jest wystarczająco szczegółowa, chyba że wiesz, jak to zrobić. Wyjaśniamy to w tym przewodniku.
Strategie zarządzania zadaniami
W przypadku architektury oprogramowania często warto podzielić pracę na mniejsze funkcje. Zyskujesz dzięki temu większą czytelność kodu i łatwość utrzymania projektu. Ułatwia to też pisanie testów.
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
W tym przykładzie znajduje się funkcja o nazwie saveSettings()
, która wywołuje 5 funkcji, aby wykonać zadanie, np. zweryfikować formularz, wyświetlić wskaźnik postępu lub wysłać dane. Z założenia jest to dobrze zaprojektowana architektura. Jeśli musisz debugować jedną z tych funkcji, możesz przejrzeć drzewo projektu, aby dowiedzieć się, co robi każda z nich.
Problem polega jednak na tym, że JavaScript nie uruchamia każdej z tych funkcji jako osobnych zadań, ponieważ są one wykonywane w obrębie funkcji saveSettings()
. Oznacza to, że wszystkie 5 funkcji działa jako jedno zadanie.
W najlepszym przypadku nawet jedna z tych funkcji może wydłużyć czas działania o co najmniej 50 milisekund. W przeciwnym razie więcej zadań może trwać dłużej – zwłaszcza w przypadku urządzeń z ograniczonymi zasobami. Poniżej przedstawiamy zestaw strategii, których możesz użyć do podziału zadań i ustalania ich priorytetów.
Ręczne odraczanie wykonania kodu
Jedna z metod używanych przez deweloperów do dzielenia zadań na mniejsze dotyczy: setTimeout()
. W ten sposób przekazujesz funkcję do funkcji setTimeout()
. Przełoży to wykonanie wywołania zwrotnego do osobnego zadania, nawet jeśli określisz czas oczekiwania 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);
}
Jest to dobre rozwiązanie, jeśli masz serię funkcji, które muszą być uruchamiane po kolei, ale kod nie zawsze jest zorganizowany w ten sposób. Możesz na przykład mieć dużą ilość danych, które muszą być przetwarzane w pętli, a to zadanie może zająć dużo czasu, jeśli masz miliony elementów.
function processData () {
for (const item of largeDataArray) {
// Process the individual item here.
}
}
Korzystanie z metody setTimeout()
jest w tym przypadku problematyczne, ponieważ ergonomia tej metody utrudnia wdrożenie, a przetwarzanie całej tablicy danych może zająć bardzo dużo czasu, nawet jeśli każdy element można przetworzyć bardzo szybko. Wszystko się zgadza, a setTimeout()
nie jest odpowiednim narzędziem do tych zadań – a przynajmniej nie wtedy, gdy jest używany w ten sposób.
Oprócz setTimeout()
jest jeszcze kilka innych interfejsów API, które pozwalają odroczyć wykonanie kodu do kolejnego zadania. Jedna dotyczy użycia postMessage()
w celu szybszego przekroczenia limitu czasu. Zadania możesz też przerywać przy użyciu requestIdleCallback()
, ale zachowaj ostrożność: requestIdleCallback()
planuje zadania przy najniższym możliwym priorytecie i tylko w czasie bezczynności przeglądarki. Gdy wątek główny jest przeciążony, zadania zaplanowane w requestIdleCallback()
mogą nie zostać uruchomione.
Użyj async
/await
, aby utworzyć punkty zysku
W dalszej części tego przewodnika pojawi się komunikat „uzyskanie korzyści z wątku głównego”. Ale co to znaczy? Dlaczego warto to zrobić? Kiedy należy to zrobić?
Gdy zadania są podzielone, wewnętrzny schemat priorytetu przeglądarki może nadać priorytet innym zadaniom. Jednym ze sposobów przekazania wątku głównego jest użycie kombinacji atrybutu Promise
, który kończy się wywołaniem metody setTimeout()
:
function yieldToMain () {
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
W funkcji saveSettings()
możesz przejść do wątku głównego po każdym bitzie pracy, jeśli await
funkcja yieldToMain()
po każdym wywołaniu funkcji:
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 jednorazowe zadanie zostało podzielone na osobne zadania.
Zaletą stosowania metody opartej na obietnicach zamiast ręcznego stosowania setTimeout()
jest większa ergonomia. Punkty zysku stają się deklaracyjne, dzięki czemu łatwiej je pisać, odczytywać i rozumieć.
Uzyskuj zyski tylko wtedy, gdy jest to konieczne
Co zrobić, jeśli masz kilka zadań, ale ich wynik chcesz uzyskać tylko wtedy, gdy użytkownik spróbuje wejść w interakcję ze stroną? Po to właśnie powstała aplikacja isInputPending()
.
isInputPending()
to funkcja, którą możesz uruchomić w dowolnym momencie, by sprawdzić, czy użytkownik próbuje wejść w interakcję z elementem strony. Wywołanie isInputPending()
zwróci wartość true
. W przeciwnym razie zwraca wartość false
.
Załóżmy, że masz kolejkę zadań, które musisz uruchomić, ale nie chcesz przeszkadzać w korzystaniu z danych wejściowych. Ten kod, który wykorzystuje zarówno funkcję isInputPending()
, jak i naszą niestandardową funkcję yieldToMain()
, zapewnia, że dane wejściowe nie będą opóźnione, gdy użytkownik spróbuje wejść w interakcję ze stroną:
async function saveSettings () {
// A task queue of functions
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
];
while (tasks.length > 0) {
// Yield to a pending user input:
if (navigator.scheduling.isInputPending()) {
// There's a pending user input. Yield here:
await yieldToMain();
} else {
// Shift the task out of the queue:
const task = tasks.shift();
// Run the task:
task();
}
}
}
Podczas uruchamiania saveSettings()
będzie zapętlać się nad zadaniami w kolejce. Jeśli isInputPending()
zwróci wartość true
podczas pętli, saveSettings()
wywoła metodę yieldToMain()
, aby umożliwić obsługę danych wejściowych użytkownika. W przeciwnym razie przesunie kolejne zadanie poza kolejkę i będzie je uruchamiać w sposób ciągły. Będzie to wykonywane do momentu, gdy nie będzie więcej zadań.
Używanie funkcji isInputPending()
w połączeniu z mechanizmem generowania plików to świetny sposób na umożliwienie przeglądarce zatrzymania przetwarzania wszystkich zadań i reagowania na kluczowe interakcje napotykane przez użytkownika. Dzięki temu w wielu sytuacjach, gdy masz zaplanowanych wiele zadań, możesz poprawić zdolność strony do reagowania na użytkownika.
Innym sposobem korzystania z metody isInputPending()
– zwłaszcza jeśli niepokoi Cię możliwość korzystania z zastępczych przeglądarek, które jej nie obsługują, jest wskazywanie na podstawie czasu w połączeniu z opcjonalnym operatorem łańcucha:
async function saveSettings () {
// A task queue of functions
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
];
let deadline = performance.now() + 50;
while (tasks.length > 0) {
// Optional chaining operator used here helps to avoid
// errors in browsers that don't support `isInputPending`:
if (navigator.scheduling?.isInputPending() || performance.now() >= deadline) {
// There's a pending user input, or the
// deadline has been reached. Yield here:
await yieldToMain();
// Extend the deadline:
deadline = performance.now() + 50;
// Stop the execution of the current loop and
// move onto the next iteration:
continue;
}
// Shift the task out of the queue:
const task = tasks.shift();
// Run the task:
task();
}
}
Takie podejście umożliwia użycie kreacji zastępczych w przypadku przeglądarek, które nie obsługują isInputPending()
. W tym celu korzysta się z terminu (i koryguje ten termin) tak, aby w razie potrzeby podział pracy został podzielony według potrzeb użytkownika lub w określonym momencie.
Luki w aktualnych interfejsach API
Wspomniane do tej pory interfejsy API mogą pomóc w rozdzielaniu zadań, ale mają znaczącą wadę: gdy wydajesz wątek główny, opóźniając uruchomienie kodu w kolejnym zadaniu, jest on dodawany na samym końcu kolejki zadań.
Jeśli kontrolujesz cały kod na swojej stronie, możesz utworzyć własny algorytm szeregowania z możliwością nadawania priorytetu zadaniom, ale skrypty innych firm nie będą go używać. W rezultacie nie możesz tak naprawdę określać priorytety pracy w takich środowiskach. Możesz ją dzielić na fragmenty lub wprost generować interakcje z użytkownikami.
Na szczęście istnieje specjalny interfejs API algorytmu szeregowania, który jest obecnie w fazie opracowywania i rozwiązuje te problemy.
Wydzielony interfejs API algorytmu szeregowania
Interfejs Scheduler API obecnie oferuje funkcję postTask()
, która w momencie pisania jest dostępna w przeglądarkach Chromium oraz w Firefoksie za flagą. postTask()
umożliwia bardziej precyzyjne planowanie zadań i jest jednym ze sposobów na pomaganie przeglądarce w określaniu priorytetów, aby zadania o niskim priorytecie trafiały do wątku głównego. postTask()
korzysta z obietnic i akceptuje ustawienie priority
.
Interfejs postTask()
API ma 3 priorytety, których możesz użyć:
'background'
dla zadań o najniższym priorytecie.'user-visible'
w zadaniach o średnim priorytecie. Jest to ustawienie domyślne, jeśli nie ustawiono żadnej funkcjipriority
.'user-blocking'
– na potrzeby krytycznych zadań, które muszą być uruchamiane z wysokim priorytetem.
Przeanalizujmy kod poniżej, na którym interfejs postTask()
API służy do uruchamiania 3 zadań o najwyższym możliwym priorytecie, a pozostałych 2 – najniższym.
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ą zaplanowane w taki sposób, że zadania priorytetowe w przeglądarce – na przykład interakcje użytkowników – mogą działać w odpowiedni sposób.
To uproszczony przykład użycia właściwości postTask()
. Istnieje możliwość tworzenia instancji różnych obiektów TaskController
o wspólnych priorytetach między zadaniami. Obejmuje to też możliwość zmiany priorytetów różnych instancji TaskController
w razie potrzeby.
Wbudowany zysk z kontynuacją przez scheduler.yield
Jedną z proponowanych elementów interfejsu API algorytmu szeregowania jest scheduler.yield
, czyli interfejs API zaprojektowany specjalnie do przekazywania głównego wątku w przeglądarce, który jest obecnie dostępny do wypróbowania w ramach testowania origin. Użycie przypomina funkcję yieldToMain()
omówioną wcześniej w tym artykule:
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();
}
}
Powyższy kod jest w dużym stopniu znany, ale zamiast używać yieldToMain()
, zadzwonisz i wybierzesz metodę await scheduler.yield()
.
Zaletą funkcji scheduler.yield()
jest kontynuacja, co oznacza, że jeśli wygenerujesz w trakcie zestawu zadań, po osiągnięciu punktu zysku pozostałe zaplanowane zadania będą kontynuowane w tej samej kolejności. Pozwala to uniknąć przywłaszczenia kolejności wykonywania kodu przez skrypty innych firm.
Podsumowanie
Zarządzanie zadaniami jest trudne, ale w ten sposób możesz szybciej reagować na interakcje użytkowników. Nie ma jednej wskazówki, jak zarządzać zadaniami i nadawać im priorytety. Jest to szereg różnych technik. Podczas zarządzania zadaniami musisz pamiętać o tych najważniejszych kwestiach:
- Przekazywanie w wątku głównym zadań o znaczeniu krytycznym, które są widoczne dla użytkowników.
- Użyj metody
isInputPending()
, aby przejść do wątku głównego, gdy użytkownik próbuje wejść w interakcję ze stroną. - Nadawaj zadaniom priorytety za pomocą narzędzia
postTask()
. - Na koniec rób jak najmniejszą pracę nad funkcjami.
Korzystając z co najmniej jednego z tych narzędzi, możesz uporządkować swoją pracę w aplikacji w taki sposób, aby traktowała ją priorytetowo pod kątem potrzeb użytkownika, a jednocześnie zadbać o wykonanie mniej istotnych zadań. W ten sposób poprawisz wygodę użytkowników, szybciej zareagują na Twoje reklamy i będą chętniej z nich korzystać.
Specjalne dziękujemy Philipowi Waltonowi za weryfikację techniczną tego artykułu.
Baner powitalny pochodzący ze strony Unsplash dzięki uprzejmości Amirali Mirhashemian –