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ą.
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.
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
- Włączanie obsługi offline w aplikacjach internetowych za pomocą Service Workers — Dowiedz się, jak używać Service Workers i Cache API, aby strona internetowa ładowała się natychmiastowo i nadal funkcjonowała nawet bez połączenia z Internetem.
- Jak przeglądarka rysuje obraz i jakie ma znaczenie React — Poznaj, w jaki sposób krytyczna ścieżka renderowania, procesy synchronizacji, Fiber oraz Scheduler współpracują ze sobą, aby aktualizacje React przekształcały się w piksele na ekranie.