Strona główna / Artykuły / Pracownicy dedykowani, współdzieleni i serwisowi: wybór odpowiedniego wątku przeglądarki

Pracownicy dedykowani, współdzieleni i serwisowi: wybór odpowiedniego wątku przeglądarki

Zrozum, w jaki sposób pracownicy dedykowani, współdzieleni i służbowi różnią się zakresem działania, sposobem komunikacji i celem, a także dowiedz się, kiedy każdy z nich faktycznie poprawia aplikację internetową.

2425 słów

Język JavaScript na stronie działa w jednym głównym wątku, a mimo to nowoczesne aplikacje pozostają responsywne podczas przetwarzania dużych plików, działają offline oraz otrzymują powiadomienia typu push w stanie bezczynności. Duża część tego efektu wynika z użycia workerów, które uruchamiają skrypty poza głównym wątkiem. Jednak termin „worker” obejmuje trzy różne narzędzia, a wybór niewłaściwego prowadzi do marnowania wysiłków. Ten przewodnik pokazuje, do czego służy każdy typ, w jaki sposób komunikują się ze sobą oraz kiedy nie warto ich używać.

W tej rodzinie jest trzech członków:

Web Workers
     │
     ├── Dedicated Worker
     │
     ├── Shared Worker
     │
     └── Service Worker

Dlaczego główny wątek potrzebuje pomocy

Domyślnie twój kod dzieli jeden wątek z aktualizacjami DOM, obsługą wprowadzanych danych, renderowaniem oraz animacjami:

JavaScript
   ↓
DOM updates
   ↓
User interactions
   ↓
Rendering
   ↓
Animations

Ponieważ te zadania wykonują się na zmianę, długi wywołanie synchroniczne, takie jak to poniżej, zatrzymuje wątek aż do jego zakończenia:

const result = expensiveCalculation();

Dopóki nie zostanie ono zakończone, przeglądarka nie może obsługiwać wprowadzanych danych ani rysować kolejnego klatki. Użytkownik odczuwa to w następujący sposób:

User clicks button
        ↓
Heavy JavaScript starts
        ↓
Main thread is busy
        ↓
UI becomes sluggish
        ↓
Calculation finishes
        ↓
UI becomes responsive again

Pracownik rozwiązuje ten problem, wykonywając JavaScript w swoim oddzielnym kontekście, dzięki czemu główny wątek pozostaje wolny do obsługi interfejsu.

Jak strona i pracownik współpracują

Wyobraźmy sobie dwa wątki połączone kanałem komunikacji: główny wątek zarządza interfejsem użytkownika, DOM-em, zdarzeniami i renderowaniem, natomiast pracownik zajmuje się obliczeniami, analizą i przetwarzaniem danych:

                 Browser
                    │
          ┌─────────┴─────────┐
          │                   │
          ↓                   ↓
     Main Thread          Worker Thread
          │                   │
     UI / DOM             Heavy Work
     Events               Calculations
     Rendering             Parsing
     Interaction           Processing
          │                   │
          └──── Messages ─────┘

Strona wysyła dane do pracownika, wywołując metodę postMessage na obiekcie pracownika:

worker.postMessage(data);

Wewnątrz pracownika globalna metoda postMessage wysyła wynik z powrotem na stronę:

postMessage(result);

Obie strony nie dzielą się zwykłymi zmiennymi; wszystko jest przekazywane w postaci wiadomości.

Dlaczego pracownicy nie mogą edytować DOM-u

Główny zakres pracownika nie ma dostępu do tych elementów:

document
window
DOM elements

Taka linijka w pliku pracownika po prostu nie działa, ponieważ document nie jest tam zdefiniowany:

// worker.js

document.querySelector("#app");

Zamiast tego wzorcem jest wykonywanie obliczeń w pracowniku, wysyłanie wyniku i pozwalanie głównej wątkowi na zastosowanie go na stronie:

Main Thread
     │
     │ postMessage(data)
     ↓
   Worker
     │
     │ performs calculation
     ↓
   Worker
     │
     │ postMessage(result)
     ↓
Main Thread
     │
     ↓
Update DOM

Zachowanie własności DOM w jednym wątku oznacza, że kod w tle nigdy nie może zmienić interfejsu w trakcie renderowania.

Trzy typy pracowników na pierwszy rzut oka

Ich zakresy się różnią: jeden twórca, kilka kontekstów tego samego pochodzenia lub warstwa sieciowa:

┌──────────────────────────────┐
│        Web Workers           │
├──────────────────────────────┤
│                              │
│  Dedicated Worker            │
│  → One script/client         │
│                              │
│  Shared Worker               │
│  → Multiple same-origin      │
│    browsing contexts         │
│                              │
│  Service Worker              │
│  → Network / caching /       │
│    offline / background      │
│    capabilities              │
│                              │
└──────────────────────────────┘

Dedykowani pracownicy do intensywnych obliczeń

Dedykowany pracownik należy do skryptu, który go tworzy. Gdy ktoś mówi „przenieś to obliczenie do pracownika”, ma na myśli właśnie ten typ.

Z poziomu strony konstruuje się pracownika na podstawie URL skryptu, wysyła mu liczbę i zapisuje wszystko, co wraca:

const worker = new Worker("worker.js");
worker.postMessage(1000000);
worker.onmessage = (event) => {
    console.log("Result:", event.data);
};

Pracownik słucha wiadomości, sumuje wszystkie liczby całkowite poniżej otrzymanej wartości i odpowiada z sumą:

// worker.js

self.onmessage = (event) => {
    const number = event.data;
    let result = 0;
    for (let i = 0; i < number; i++) {
        result += i;
    }
    self.postMessage(result);
};

Cały proces jest prosty:

Main Thread
     │
     │ 1000000
     ↓
Dedicated Worker
     │
     │ Calculate
     ↓
   Result
     │
     ↓
Main Thread

Pracownik jest powiązany ze swoim twórcą i nigdy nie jest dzielony z niepowiązanymi stronami; dwa zakładki mają po dwóch niezależnych pracowników.

Dobre kandydaty na dedykowanego pracownika

Są przydatne do zadań wymagających dużych zasobów CPU w jednej aplikacji. Typowym przykładem jest przekształcanie dużych danych API: pracownik przekształca dane, a główny wątek jedynie je wyświetla.

API Response
     ↓
Large JSON
     ↓
   Worker
     ↓
Parse / Transform
     ↓
Main Thread
     ↓
Render UI

Zmiana rozdzielczości i filtrowanie obrazów odbywa się w podobny sposób:

Image
  ↓
Worker
  ↓
Resize / Transform
  ↓
Result
  ↓
 UI

Podobnie jest z filtrowaniem, sortowaniem i agregacją dużych zbiorów danych:

Large Dataset
      ↓
    Worker
      ↓
  Filtering
   Sorting
 Aggregation
      ↓
     UI

Podobnie nadają się szyfrowanie, kompresja, analiza dużych plików oraz złożone obliczenia. Zasada brzmi: jeśli pracochłonne zadania powodują opóźnienia w interfejsie, rozważ użycie dedykowanego pracownika.

Pracownicy współdzieleni dla kilku kart

Załóżmy teraz, że ta sama aplikacja jest otwarta jednocześnie w kilku kontekstach przeglądania:

Tab A
Tab B
Tab C

Pracownik współdzielony umożliwia skryptom w kilku oknach, kartach lub iframach połączenie się z jednym pracownikiem, pod warunkiem że mają ten sam źródło:

             Shared Worker
              /    |    \
             /     |     \
          Tab A   Tab B   Tab C

Bez niego każda karta tworzy własną kopię:

Tab A → Worker A
Tab B → Worker B
Tab C → Worker C

Z nim każda karta łączy się z jedną instancją:

Tab A ─┐
Tab B ─┼──> Shared Worker
Tab C ─┘

Komunikacja przez porty

Dо pracownika współdzielonego dostępuje się za pośrednictwem wyraźnego MessagePort. Strona uruchamia port i wysyła wiadomość:

const worker = new SharedWorker("worker.js");
worker.port.start();
worker.port.postMessage("Hello");

Każdy nowy klient wyzwalający zdarzenie connect w pracowniku powoduje, że jego obsługa nasłuchuje na porcie tego klienta:

self.onconnect = (event) => {
    const port = event.ports[0];
    port.onmessage = (event) => {
        console.log(event.data);
    };
};

Główną praktyczną różnicą w porównaniu z dedykowanym pracownikiem jest to, że przy wielu klientach każde połączenie wymaga własnego kanału, a odpowiedzi są przekazywane przez port odpowiedniej karty.

Sytuacja z wieloma kartami

Wyobraź sobie narzędzie wewnętrzne, w którym różne karty pokazują różne widoki:

Tab 1 → Dashboard
Tab 2 → Reports
Tab 3 → Analytics

Jeden komponent w tle mógłby przechowywać wspólny stan lub koordynować komunikację między wszystkimi kartami:

              Shared Worker
                   │
        ┌──────────┼──────────┐
        ↓          ↓          ↓
     Tab 1       Tab 2      Tab 3
        │          │          │
        └──────────┼──────────┘
                   ↓
             Shared State

Dzięki temu każda karta unika konieczności posiadania własnej instancji pracownika, ale każdy kontekst musi dzielić ten sam punkt początkowy. Wspieranie wspólnych pracowników historycznie pozostawało w tyle za dedykowanymi pracownikami, szczególnie w niektórych przeglądarkach mobilnych, dlatego najpierw sprawdź aktualne dane dotyczące kompatybilności.

Pracownicy usług dla działania w sieci i poza nią

Pracownik usług jest najbardziej niezrozumianym typem. Nie jest to po prostu przemieniona nazwa dedykowanego pracownika; znajduje się pomiędzy twoją aplikacją, przeglądarką a siecią:

Browser
   │
   ↓
Service Worker
   │
   ├── Cache
   │
   ├── Network
   │
   ├── Offline response
   │
   └── Background capabilities

Ponieważ przerywa żądania, umożliwia korzystanie z funkcji offline, cacheowanie, dostosowane obsługi żądań, powiadomienia typu push oraz synchronizację w tle.

Znajduje się pomiędzy stroną a serwerem

Załóżmy, że użytkownik odwiedza twoją stronę:

https://example.com

Bez service workera każde żądanie trafia bezpośrednio z przeglądarki do serwera:

Browser
   ↓
Internet
   ↓
Server

Gdy jest zarejestrowany, żądania najpierw przechodzą przez niego, a on może dostarczyć je z cache’u lub przekazać do sieci:

Browser
   ↓
Service Worker
   ↓
   ├── Cache
   │
   └── Network

On decyduje o losie każdego żądania. Strategia „cache-first” natychmiast zwraca dane, jeśli są dostępne w cache’u, w przeciwnym razie pobiera je i cacheuje tam, gdzie to możliwe:

Request
   ↓
Is it cached?
   │
   ├── YES → Return cached response
   │
   └── NO
       ↓
     Network
       ↓
     Response
       ↓
     Cache if appropriate

Aplikacje działające offline są budowane na tej logice. Aby zapoznać się z przykładem, sprawdź jak włączyć obsługę offline w aplikacjach internetowych za pomocą service workerów.

Rejestracja i cykl życia

Przeglądarka zarządza cyklem życia, który jest tutaj uproszczony:

Register
   ↓
Download
   ↓
Install
   ↓
Activate
   ↓
Control Pages

Najpierw rejestruje się skrypt, gdy API jest dostępne:

if ("serviceWorker" in navigator) {
    navigator.serviceWorker.register("/sw.js");
}

Następnie przeglądarka zajmuje się instalacją, aktywacją i aktualizacjami. Z kolei dedykowany worker powstaje w wyniku bezpośredniego wywołania konstruktora, co nigdy nie dotyczy service workerów:

new Worker(...)

Wymagają one bezpiecznego kontekstu (HTTPS, przy czym w środowisku rozwojowym dozwolony jest localhost). Nowy worker nie kontroluje już otwartych stron, chyba że je przejmie podczas ponownego załadowania, co często utrudnia testowanie.

Przechwytywanie żądań

Minimalny service worker nasłuchuje zdarzeń fetch; ten konkretny jedynie zapisuje każdą adresację URL:

self.addEventListener("fetch", (event) => {
    console.log("Request:", event.request.url);
});

Prawdziwe cacheowanie opiera się na tym mechanizmie. W przypadku pliku app.js: serwuje się go z pamięci cache, jeśli jest dostępna; w przeciwnym razie pobiera się go, przechowuje i zwraca.

User requests app.js
        ↓
Service Worker
        ↓
Is app.js cached?
     /       \
   YES        NO
    ↓          ↓
 Return      Network
 Cache          ↓
              Cache
                ↓
             Return

Dlatego odgrywają kluczową rolę w aplikacjach Progressive Web Apps (PWAs) oraz projektach opartych na pracy offline.

Porównanie trzech typów obok siebie

Dedykowany worker

Krótko mówiąc, to „wątek w tle należący do jednej strony”:

 Page
  │
  ↓
Dedicated Worker

Podoba się do obliczeń wymagających dużych zasobów CPU, parsowania, przetwarzania obrazów oraz przetwarzania danych.

Worker współdzielony

Krótko mówiąc, to „jeden worker, którego mogą używać kilka stron z tego samego źródła”:

Tab A ─┐
Tab B ─┼──> Shared Worker
Tab C ─┘

Podoba się do zadań w tle współdzielonych między różnymi kontekstami przeglądania, a także do wspólnej komunikacji lub zarządzania stanem.

Service worker

Krótko mówiąc, to „warstwa pomiędzy aplikacją a siecią”:

App
 ↓
Service Worker
 ↓
Cache / Network

Podoba się do aplikacji offline, cache’owania, przerywania żądań, powiadomień typu push oraz synchronizacji w tle.

Jednozdaniowy podsumowanie każdego

Każdy w jednym zdaniu:

Dedicated Worker
        ↓
"Do this heavy computation for me."

Shared Worker
        ↓
"Let multiple pages use this worker."

Service Worker
        ↓
"Help my web application interact with
the network and browser capabilities."

W ten sposób trudno pomylić te trzy podejścia.

Zakres, komunikacja i typowe zastosowania

Dedykowane: jeden skrypt, obliczenia w tle, postMessage(), brak DOM:

Scope:
One script

Main purpose:
Background computation

Communication:
postMessage()

DOM access:
❌ No

Common use:
Heavy computation

Dzielone: kilka kontekstów z tym samym pochodzeniem, MessagePort, brak DOM, koordynacja między kartami:

Scope:
Multiple same-origin contexts

Main purpose:
Shared background work

Communication:
MessagePort

DOM access:
❌ No

Common use:
Cross-tab/shared worker communication

Usługa: strony w obrębie tego samego pochodzenia i zakresu ścieżki, zdarzenia i API platformy, brak DOM, cacheowanie, praca offline, funkcje push i synchronizacja:

Scope:
Origin/path controlled pages

Main purpose:
Network + background capabilities

Communication:
Events / messaging / APIs

DOM access:
❌ No

Common use:
Caching, offline, push, background sync

Kiedy worker spowalnia działanie

Workerzy nie są automatycznie szybsze. Ich uruchomienie wymaga zasobów, a dane muszą być przekazywane między kontekstami:

 Main Thread
      ↕
    Worker

Komunikacja również zajmuje czas. W przypadku małego zadania nakładki mogą przewyższyć samą objętość pracy:

Small Task
   ↓
Worker overhead
   ↓
Communication
   ↓
Actual calculation

Wtedy worker może być wolniejszy od kodu wstawionego bezpośrednio. Używaj go tylko wtedy, gdy zadanie jest na tyle obfite, że przeniesienie go na inny wątek przynosi widoczną korzyść.

Co faktycznie przechodzi przez granicę

Wiadomości są kopiowane za pomocą algorytmu strukturalnego klonowania, więc duże obciążenia czasowe generują czas kopiowania. Obiekty przenoszalne, takie jak ArrayBuffer, są przekazywane bez kopiowania, ale nadawca traci do nich dostęp. SharedArrayBuffer umożliwia prawdziwą wspólną pamięć tylko przy dodatkowych wymaganiach bezpieczeństwa (izolacja między domenami). W większości aplikacji należy korzystać wyłącznie z wyraźnych wiadomości:

Main Thread
    ↓
postMessage()
    ↓
  Worker
    ↓
postMessage()
    ↓
Main Thread

Używanie dedykowanego workera w React

Jeśli aplikacja React przetwarza duży zbiór danych podczas renderowania lub w obsłudze zdarzeń, interfejs użytkownika ulega zatrzymaniu:

React UI
   ↓
Large calculation
   ↓
Main thread blocked
   ↓
UI becomes sluggish

Z dedykowanym workerem komponent wysyła dane i aktualizuje stan, gdy przychodzi wynik:

React UI
   │
   ├──────────────> Worker
   │                  │
   │                  ↓
   │              Calculation
   │                  │
   │                  ↓
   │<──────────── Result
   │
   ↓
Update UI

React nadal renderuje treść, podczas gdy worker wykonywa obliczenia. Twórz workera w efekcie i wezwij terminate() w jego funkcji czyszczenia, aby nie przetrwał dłużej niż komponent. Rozwiązanie to nadaje się do wizualizacji danych, edytorów obrazów, przetwarzania dźwięku i wideo, dużych plików oraz analiz na stronie klienta.

Wybór odpowiedniego workera

Zadania wymagające dużej mocy obliczeniowej CPU wskazują na dedykowanego workera:

Do you have CPU-heavy JavaScript?
          │
         YES
          ↓
   Use Dedicated Worker

Jeśli wymagania brzmią w ten sposób:

Multiple tabs/pages need
the same worker

wtedy typ do oceny to:

Shared Worker

A jeśli wymagania obejmują cokolwiek z tego, co zostało wymienione:

Caching
Offline support
Network interception
Push notifications
Background sync

wtedy odpowiedź brzmi:

Service Worker

Powszechne błędy

Przenoszenie wszystkiego do workera

Używaj workerów tylko wtedy, gdy rozwiązują one konkretny problem.

Oczekiwanie dostępu do DOM

To nie może zadziałać:

worker.document.querySelector(...);

Pracownicy wysyłają dane z powrotem; główna wątek aktualizuje DOM.

Wykorzystanie service worker do obliczeń

Service workers są skierowane na sieć i cykl życia aplikacji, a przeglądarka może je zatrzymać w stanie bezczynności. Do prostych obliczeń liczbowych, takich jak:

1 million records
       ↓
complex calculation

Zacznij od dedykowanego pracownika.

Ignowowanie kosztów komunikacji

Każda wymiana danych wiąże się z dodatkowymi kosztami:

Main Thread
     ↕
Messaging
     ↕
  Worker

Przenieś tylko tak dużo pracy, aby to pokryć.

Podsumowanie

Zasada przewodnia: kosztowny JavaScript nie powinien bez powodu blokować głównego wątku. Te trzy typy odpowiadają trzem problemom:

                    Web Workers
                         │
          ┌──────────────┼──────────────┐
          ↓              ↓              ↓
     Dedicated         Shared         Service
       Worker          Worker          Worker
          │              │              │
     One script      Multiple        Network /
     uses it         contexts        offline
  • Dedykowany pracownik: intensywne obliczenia dla jednej strony.
  • Współdzielony pracownik: praca w tle udostępniana kilku kontekstom o tym samym pochodzeniu.
  • Service worker: interwencja w ruchu sieciowym, cacheowanie oraz możliwości pracy offline.
  • Zanim je wdrożysz, zmierz czas wykonywania zadań blokujących, upewnij się, że przewyższają one koszty obsługi komunikacji, i sprawdź wsparcie przeglądarki. W ten sposób workers stanowią trzy konkretne rozwiązania na trzy odrębne pytania.

    Literatura pokrewna