Użyj robotów internetowych do uruchamiania JavaScriptu poza głównym wątkiem przeglądarki

Architektura poza głównym wątkiem może znacznie zwiększyć niezawodność i wygodę użytkowników aplikacji.

Surma
Surma

W ciągu ostatnich 20 lat sieć bardzo się rozwinęła – od statycznych dokumentów z kilkoma stylami i obrazami do złożonych i dynamicznych aplikacji. Jedna rzecz pozostaje jednak zasadniczo bez zmian: mamy tylko jeden wątek na kartę przeglądarki (z pewnymi wyjątkami) do renderowania naszych witryn i uruchamiania kodu JavaScript.

W rezultacie główny wątek stał się niesamowicie przeciążony. A w miarę jak aplikacje internetowe stają się coraz bardziej złożone, główny wątek staje się pokaźnym wąskim gardłem wydajności. Co jeszcze gorsze, czas potrzebny na uruchomienie kodu w wątku głównym w przypadku danego użytkownika jest prawie całkowicie nieprzewidywalny, ponieważ możliwości urządzenia mają ogromny wpływ na wydajność. Ta nieprzewidywalność będzie się zwiększać tylko w miarę jak użytkownicy korzystają z internetu na coraz bardziej zróżnicowanych urządzeniach – od bardzo ograniczonych telefonów z podstawową przeglądarką po flagowe urządzenia o dużej mocy obliczeniowej i dużej częstotliwości odświeżania.

Jeśli chcemy, aby zaawansowane aplikacje internetowe niezawodnie spełniały wytyczne dotyczące wydajności, takie jak podstawowe wskaźniki internetowe, które opierają się na danych empirycznych dotyczących ludzkiego postrzegania i psychologii, potrzebujemy sposobów na wykonanie kodu poza głównym wątkiem.

Dlaczego pracownicy internetowi?

JavaScript jest domyślnie językiem jednowątkowym, który uruchamia zadania w wątku głównym. Roboty internetowe zapewniają jednak sposób na wyjście z wątku głównego, umożliwiając programistom uruchamianie osobnych wątków do obsługi pracy z wątkiem głównym. Choć zakres pracowników internetowych jest ograniczony i nie zapewnia bezpośredniego dostępu do DOM, mogą one być bardzo przydatne, jeśli wymagają wykonania wielu czynności, które w przeciwnym razie przytłoczyłyby główny wątek.

Jeśli chodzi o podstawowe wskaźniki internetowe, wykonywanie zadań poza głównym wątkiem może być korzystne. W szczególności odciążenie pracy z wątku głównego do instancji roboczych może ograniczyć rywalizację o wątek główny, co może poprawić ważne wskaźniki reagowania, takie jak od interakcji do następnego wyrenderowania (INP) i opóźnienie przy pierwszym działaniu (FID). Gdy wątek główny ma mniej pracy do przetworzenia, może szybciej reagować na interakcje użytkowników.

Mniej pracy z wątkiem głównym, zwłaszcza podczas uruchamiania, może mieć też korzystny wpływ na największe wyrenderowanie treści (LCP) ze względu na ograniczenie długich zadań. Renderowanie elementu LCP wymaga czasu wątku głównego – na potrzeby renderowania tekstu lub obrazów, które są częstymi i powszechnymi elementami LCP. A dzięki zmniejszeniu ogólnej pracy w wątku głównym możesz ograniczyć ryzyko zablokowania elementu LCP strony przez kosztowną pracę, którą wykonałby robot internetowy.

Tworzenie wątków z użyciem instancji roboczych

Inne platformy zwykle obsługują równoległą pracę, umożliwiając wątekowi funkcję, która działa równolegle z resztą programu. Możesz uzyskać dostęp do tych samych zmiennych w obu wątkach, a dostęp do tych udostępnionych zasobów można zsynchronizować z muteksami i semaforami, aby zapobiec sytuacjom wyścigu.

W przypadku JavaScriptu możemy uzyskać podobną funkcjonalność od pracowników sieciowych, które działają od 2007 roku i działają we wszystkich popularnych przeglądarkach od 2012 roku. Instancje robocze działają równolegle z wątkiem głównym, ale nie mogą udostępniać zmiennych (w przeciwieństwie do wątków systemu operacyjnego).

Aby utworzyć instancję roboczą, przekaż plik do konstruktora, który uruchomi ten plik w osobnym wątku:

const worker = new Worker("./worker.js");

Komunikuj się z instancją roboczą, wysyłając komunikaty za pomocą interfejsu postMessage API. Przekaż wartość wiadomości jako parametr w wywołaniu postMessage, a następnie dodaj detektor zdarzeń wiadomości do instancji roboczej:

main.js

const worker = new Worker('./worker.js');
worker.postMessage([40, 2]);

worker.js

addEventListener('message', event => {
  const [a, b] = event.data;

  // Do stuff with the message
  // ...
});

Aby wysłać wiadomość z powrotem do wątku głównego, użyj tego samego interfejsu API postMessage w obrębie instancji roboczej i skonfiguruj odbiornik zdarzeń w wątku głównym:

main.js

const worker = new Worker('./worker.js');

worker.postMessage([40, 2]);
worker.addEventListener('message', event => {
  console.log(event.data);
});

worker.js

addEventListener('message', event => {
  const [a, b] = event.data;

  // Do stuff with the message
  postMessage(a + b);
});

Trzeba przyznać, że podejście to jest w pewnym stopniu ograniczone. W przeszłości pracownicy sieci wykorzystywali głównie do przenoszenia jednej ciężkiej pracy z głównego wątku. Próba obsługi wielu operacji za pomocą jednego zasobu internetowego szybko staje się niewygodna: trzeba zakodować nie tylko parametry, ale także operację w wiadomości i prowadzić księgowość, aby dopasować odpowiedzi na żądania. Jest to prawdopodobnie powód, dla którego mechanizmy robocze nie są szerzej stosowane.

Gdybyśmy jednak mogli wyeliminować pewne trudności w komunikacji między wątkiem głównym a instancjami roboczymi, ten model doskonale sprawdzi się w wielu przypadkach. Na szczęście istnieje biblioteka, która właśnie to umożliwia.

Comlink to biblioteka, której celem jest umożliwienie użytkownikom dostępu do pracowników internetowych bez konieczności zapamiętywania szczegółów postMessage. Comlink umożliwia udostępnianie zmiennych między instancjami roboczymi w wątku głównym – prawie tak jak w przypadku innych języków programowania, które obsługują wątki.

Aby skonfigurować interfejs Comlink, zaimportuj go do instancji roboczej i zdefiniuj zestaw funkcji udostępnianych w wątku głównym. Następnie importujesz aplikację Comlink w wątku głównym, pakujesz instancję roboczą i uzyskujesz dostęp do ujawnionych funkcji:

worker.js

import {expose} from 'comlink';

const api = {
  someMethod() {
    // ...
  }
}

expose(api);

main.js

import {wrap} from 'comlink';

const worker = new Worker('./worker.js');
const api = wrap(worker);

Zmienna api w wątku głównym działa tak samo jak w instancji roboczej, z tym że każda funkcja zwraca obietnicę dla wartości, a nie samą wartość.

Jaki kod trzeba przenieść do instancji roboczej?

Instancje robocze nie mają dostępu do interfejsu DOM i wielu interfejsów API, takich jak WebUSB, WebRTC lub Web Audio, dlatego nie możesz umieścić w instancji roboczej elementów aplikacji, które wymagają takiego dostępu. Mimo to każdy niewielki fragment kodu przeniesiony do instancji roboczej kupuje w wątku głównym więcej miejsca na rzeczy, które muszą tam się znaleźć, np. na aktualizację interfejsu użytkownika.

Jednym z problemów, z jakimi spotykają się programiści stron internetowych, jest to, że większość aplikacji internetowych korzysta z platformy interfejsu, takiej jak Vue czy React, do administrowania wszystkimi elementami aplikacji. Wszystkie elementy są częścią platformy i są nieodłącznie powiązane z DOM. Przejście na architekturę OMT może być trudne.

Jeśli jednak przejdziemy na model, w którym problemy związane z interfejsem zostaną oddzielone od innych kwestii, takich jak zarządzanie stanami, pracownicy internetowi będą mogli być przydatni nawet w przypadku aplikacji opartych na platformie. W przypadku PROXX tak właśnie działa.

PROXX: studium przypadku firmy OMT

Zespół Google Chrome opracował PROXX jako klon Minesweepera, który spełnia wymagania progresywnej aplikacji internetowej, w tym pracę w trybie offline i zapewniającą ciekawy interfejs dla użytkowników. Niestety wczesne wersje gry radziły sobie słabo na urządzeniach z ograniczonym dostępem, takich jak telefony z podstawową przeglądarką, co sprawiło, że zespół zdał sobie sprawę, że główny wątek jest wąskim gardłem.

Zespół zdecydował się skorzystać z pracowników internetowych, aby oddzielić stan wizualny gry od logiki:

  • Wątek główny obsługuje renderowanie animacji i przejść.
  • Robot internetowy obsługuje logikę gry, która jest czysto obliczeniowa.

Firma OMT miała interesujący wpływ na wydajność telefonów z podstawową przeglądarką PROXX. W wersji innej niż OMT interfejs użytkownika jest zablokowany przez 6 sekund od interakcji z nim. Nie ma żadnych opinii, a użytkownik musi odczekać pełne 6 sekund, zanim będzie mógł zrobić coś innego.

Czas odpowiedzi UI w wersji PROXX niezwiązanej z OMT.

Natomiast w wersji OMT aktualizacja interfejsu użytkownika zajmuje 12 sekund. Choć może się to wydawać spadkiem wydajności, w rzeczywistości wpływa to na wzrost opinii użytkowników. Spowolnienie następuje, ponieważ aplikacja wysyła więcej klatek niż jej wersja bez OMT, która w ogóle nie wysyła żadnych klatek. Dzięki temu użytkownik wie, że coś się dzieje, i może kontynuować rozgrywkę w miarę aktualizowania interfejsu, dzięki czemu gra jest znacznie lepsza.

Czas odpowiedzi UI w wersji OMT PROXX.

To świadomy kompromis: dajemy użytkownikom z ograniczonym dostępem do urządzeń z ograniczonym dostępem, które czują się lepiej, bez kary dla użytkowników zaawansowanych urządzeń.

Implikacje architektury OMT

Jak widać w przykładzie PROXX, OMT zapewnia działanie aplikacji na większej liczbie urządzeń, ale nie przyspiesza działania:

  • Przenosisz tylko pracę z wątku głównego bez ograniczania liczby prac.
  • Dodatkowy nakład pracy związany z komunikacją między instancją roboczą a wątkiem głównym może czasami spowalniać działanie usługi.

Biorąc pod uwagę wady i zalety

Przetwarzanie interakcji użytkownika, takich jak przewijanie w czasie działania JavaScriptu, w wątku głównym może być bezpłatne, dlatego liczba pomijanych klatek może być nieco większa, mimo że łączny czas oczekiwania może być nieco dłuższy. Lepszym rozwiązaniem jest skłonienie użytkownika do odczekania trochę przed usunięciem klatki, bo margines błędu jest mniejszy w przypadku pominiętych klatek: usunięcie klatki odbywa się w milisekundach, a użytkownik widzi czas oczekiwania po upływie setek milisekund.

Ze względu na nieprzewidywalność wydajności na różnych urządzeniach celem architektury OMT tak naprawdę jest zmniejszenie ryzyka – zwiększenie niezawodności aplikacji w obliczu bardzo zmiennych warunków działania, a nie korzyści z wydajności wynikającej z równoległości. Wzrost odporności i ulepszenia w zakresie wrażeń użytkownika są warte więcej niż jakakolwiek niewielka różnica w szybkości działania.

Uwaga dotycząca narzędzi

Roboty internetowe nie są jeszcze popularne, więc większość narzędzi modułowych, takich jak webpack i Rollup, nie obsługuje ich od razu. (tak właśnie działa Parcel). Na szczęście istnieją wtyczki, które umożliwiają pracę z pakietami internetowymi i Podsumowaniem:

Podsumowanie

Aby nasze aplikacje były jak najbardziej niezawodne i dostępne, zwłaszcza w coraz bardziej globalnym rynku, musimy obsługiwać urządzenia z ograniczonym dostępem – to właśnie dzięki nim większość użytkowników uzyskuje dostęp do internetu na całym świecie. OMT to obiecujący sposób na zwiększenie wydajności na takich urządzeniach bez negatywnego wpływu na użytkowników zaawansowanych urządzeń.

Korzystanie z OMT ma też dodatkowe korzyści:

Pracownicy sieciowi nie muszą się martwić. Narzędzia takie jak Comlink odciążają pracowników i sprawiają, że sprawdzają się one w przypadku różnego rodzaju aplikacji internetowych.

Baner powitalny z albumu Unsplash autorstwa Jamesa Peacocka.