Service Worker - kod, który odpowiada zamiast serwera

Wyłącz internet, odśwież stronę - a ona dalej działa. Stoi za tym Service Worker: osobny skrypt, który wchodzi między stronę a sieć i przechwytuje każde żądanie o plik, zanim trafi ono do serwera. To on decyduje, czy odpowiedzieć z sieci, z własnego cache (Cache API), czy po swojemu - a z tej jednej decyzji biorą się wszystkie strategie cache'owania, tryb offline i klasyczna pułapka 'po wdrożeniu user widzi starą wersję'.
Wyłączasz internet, odświeżasz stronę - i ona dalej się ładuje. Nie przypadkiem, nie dlatego, że coś zostało w pamięci - ktoś to zaplanował. Między stroną a siecią siedzi wtedy osobny skrypt, który przechwycił żądanie o plik i odpowiedział sam, kopią zapisaną wcześniej u siebie.
W poprzednim poście o HTTP cachingu cache był po stronie przeglądarki, a ty wpływałeś na niego tylko pośrednio - nagłówkami w odpowiedzi serwera. Reguły ustalał serwer, decyzje podejmowała przeglądarka, a ty mogłeś jedynie podpowiedzieć. Service Worker to odwraca: daje ci kod, który sam przechwytuje każde żądanie i decyduje, co odpowiedzieć.
#Osobny skrypt, który stoi przed siecią
Service Worker to plik JS, który przeglądarka uruchamia osobno od strony - we własnym wątku, w tle, bez dostępu do DOM. Nie dotkniesz z niego document ani window - jego globalny obiekt to self (dlatego w kodzie niżej zobaczysz self.addEventListener). W zamian dostaje coś, czego zwykły skrypt nie ma: stoi jako pośrednik między stroną a siecią. Każde żądanie, które strona wysyła - obrazek, CSS, wywołanie API - odpala jego zdarzenie fetch, zanim cokolwiek poleci do sieci. I tu uwaga na nazwę: fetch to nazwa zdarzenia, nie metody fetch(). Zdarzenie łapie żądania na poziomie sieci, więc nie ma znaczenia, czym je wywołałeś - czy fetch(), czy XMLHttpRequest, czy biblioteką w stylu axios (która i tak pod spodem korzysta z jednego z nich). Każde żądanie kontrolowanej strony przechodzi przez to samo zdarzenie.
A skoro przez nie przechodzi, to Service Worker decyduje, co z nim zrobić. Ma trzy wyjścia: odpowiedzieć kopią z cache, zbudować odpowiedź samodzielnie (np. stronę “jesteś offline” albo wygenerowany JSON, bez ruszania sieci) albo przepuścić żądanie dalej do sieci, jakby go w ogóle nie było. Z jednym zastrzeżeniem - musi już kontrolować tę kartę (czyli otwartą stronę w przeglądarce). “Kontrolować” to konkretna relacja: dopiero gdy przeglądarka przypisze daną kartę do zainstalowanego Service Workera, jej żądania zaczynają przez niego przechodzić. Karty, której nie kontroluje, nawet nie dotyka - jej żądania lecą prosto do sieci, jakby workera nie było. I jest haczyk: przy pierwszej wizycie worker dopiero się instaluje, więc tę kartę, która go zarejestrowała, przejmuje pod kontrolę dopiero przy następnym wejściu (wracam do tego przy cyklu życia).
To go odróżnia od zwykłego Web Workera. Web Worker też jest osobnym wątkiem, ale służy do jednego: policzyć coś ciężkiego poza głównym wątkiem, żeby nie zaciąć interfejsu. Service Worker liczeniem się nie zajmuje - jego rola to przechwytywanie sieci i cache. Jeden zdejmuje obciążenie z procesora, drugi staje między tobą a serwerem.
Stronie mówisz o nim raz, przy starcie:
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js");
}Service Worker kontroluje tylko ścieżki w swoim scope - domyślnie katalogu, z którego go zarejestrowano. sw.js w katalogu głównym kontroluje całą stronę; ten sam plik w /app/ kontrolowałby tylko /app/*. Dlatego skrypt zwykle ląduje w roocie.
#Trzy akty: install, activate, fetch
Service Worker żyje w trzech zdarzeniach. Pierwsze to install - odpala się raz, gdy przeglądarka pierwszy raz widzi ten skrypt. To moment na precache: wrzucasz do cache pliki, które aplikacja na pewno będzie potrzebować.
const CACHE = "assets-v1";
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(CACHE)
.then((cache) => cache.addAll(["/", "/style.css", "/app.js"])),
);
});caches.open otwiera nazwany schowek (nazwę trzymam w stałej CACHE, bo przyda się za chwilę), addAll pobiera listę plików i je tam zapisuje. event.waitUntil mówi przeglądarce: nie uznawaj instalacji za skończoną, dopóki to się nie wykona.
To jest Cache API - schowek par żądanie/odpowiedź, którym zarządzasz ręcznie. I tu uwaga, bo to nie to samo co HTTP cache z poprzedniego posta: tam przeglądarka sama zapisywała i unieważniała wpisy według nagłówków. Tu ty otwierasz schowek, ty wkładasz, ty wyrzucasz.
Drugie zdarzenie, activate, to moment na sprzątanie. Gdy wdrażasz nową wersję, podbijasz stałą CACHE (np. na assets-v2), a activate kasuje każdy schowek o innej nazwie - czyli wszystko, co zostało po starej wersji:
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)),
),
),
);
});caches.keys() zwraca nazwy wszystkich schowków, a usuwasz te, które nie są aktualnym CACHE. Bez tego stare pliki zostają w cache w nieskończoność.
Trzecie zdarzenie, fetch, to serce. Odpala się przy każdym żądaniu strony:
self.addEventListener("fetch", (event) => {
event.respondWith(
caches
.match(event.request)
.then((cached) => cached ?? fetch(event.request)),
);
});event.respondWith mówi przeglądarce: tę odpowiedź biorę na siebie - nie pobieraj jej sama z sieci, użyj tego, co ci podam. A podajemy proste pytanie: mam to w cache? caches.match szuka zapisanej kopii tego żądania. Jest kopia - oddajemy ją od razu. Nie ma - dopiero wtedy idziemy do sieci po plik (fetch). I to już cała strategia: najpierw cache, sieć tylko gdy trzeba.
#Strategie: jak odpowiadać na żądanie
Skoro to ty piszesz respondWith, to ty wybierasz, skąd przyjdzie odpowiedź. Trzy wzorce wracają najczęściej.
Cache-first - najpierw cache, a do sieci idź tylko wtedy, gdy w cache nic nie ma. To wzorzec z przykładu wyżej. Świetny do plików, które się nie zmieniają.
Network-first - najpierw sieć, a cache jako plan B. Dla danych, które mają być świeże, ale gdy sieci nie ma, lepiej pokazać ostatnią znaną wersję niż błąd. Typowy wybór wszędzie tam, gdzie zależy ci na świeżości, ale offline ma być choćby zapasowym planem.
Stale-while-revalidate - oddaj kopię z cache od ręki, a w tle dociągnij świeżą i podmień ją na następny raz:
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.open("pages").then(async (cache) => {
const cached = await cache.match(event.request);
const fresh = fetch(event.request).then((response) => {
cache.put(event.request, response.clone());
return response;
});
return cached ?? fresh;
}),
);
});Jeden szczegół z tego kodu warto wyłapać: response.clone(). Odpowiedź z sieci da się odczytać tylko raz, a tu potrzebujesz jej dwa razy - jedną kopię oddajesz stronie, drugą wkładasz do cache. Dlatego klonujesz: bez tego druga próba odczytu by się wywaliła.
To ten sam stale-while-revalidate, co w nagłówku z poprzedniego posta o HTTP caching - tyle że tam robił to CDN albo przeglądarka według dyrektywy, a tu piszesz go sam. Użytkownik nigdy nie czeka, kosztem tego, że czasem widzi treść sprzed jednej wizyty.
Wybór strategii nie jest dowolny - zależy od tego, co serwujesz. Pliki z hashem w nazwie (te immutable z poprzedniego posta) pasują do cache-first, bo pod daną nazwą i tak się nie zmienią. Dane z API, które muszą być aktualne, wołają o network-first. Dla treści, gdzie liczy się szybkość, a chwilowa nieaktualność nie szkodzi, zostaje stale-while-revalidate.
Te same trzy strategie, zestawione obok siebie. Ustaw strategię, stan sieci i to, czy plik jest już w cache - i zobacz, co dostaje użytkownik i skąd:
Przełączaj strategię, stan sieci i cache - i patrz, co dostaje user i skąd.
- źródło
- z cache
- treść
- może być stara
- szybkość
- natychmiast
Cache-first ma kopię, więc oddaje ją od razu i nie rusza sieci - tak samo online i offline.
#Cache invalidation: najczęstsza pułapka
Wraca pytanie, które ciągnie się przez całą tę serię: skoro to ty zarządzasz cache’em, to ty go unieważniasz. I z Service Workerami to właśnie unieważnianie jest najczęstszym źródłem bólu - przez to, jak działa ich aktualizacja.
Gdy zmienisz sw.js i użytkownik wejdzie na stronę, przeglądarka pobiera nowy skrypt, ale nie aktywuje go od razu. Nowa wersja czeka (stan waiting), aż wszystkie karty ze starym Service Workerem zostaną zamknięte. Dopóki użytkownik trzyma otwartą choć jedną, działa stary skrypt - i serwuje stare pliki ze starego schowka.
Dwa narzędzia to przełamują. self.skipWaiting() (w install) załatwia czekanie na aktywację: każe przeglądarce nie trzymać nowej wersji w stanie waiting, tylko aktywować ją od razu. Ale sama aktywacja to za mało - świeży worker nadal nie kontroluje kart, które są już otwarte, dopóki ich nie przeładujesz. Na to jest self.clients.claim() (w activate): każe od ręki przejąć już otwarte karty, bez czekania na przeładowanie. Razem sprawiają, że nowa wersja przejmuje stronę przy najbliższym wejściu, a nie dopiero po zamknięciu wszystkich starych kart.
Tylko że to broń obosieczna: jeśli nowy skrypt zacznie serwować nowe assety stronie, która załadowała się jeszcze ze starymi, możesz dostać niespójność. Dlatego wersjonowanie schowków (assets-v1 → assets-v2) i sprzątanie w activate przestaje być opcją, a staje się obowiązkiem.
I jeszcze jedno, co łączy ten post z poprzednim: Service Worker nie zastępuje HTTP cache, tylko stoi przed nim. Żądanie idzie po kolei: strona → Service Worker → (jeśli woła sieć) HTTP cache → serwer. Sam sw.js też podlega HTTP cache, więc zwykle serwuje się go z no-cache, żeby przeglądarka zawsze sprawdziła, czy nie ma nowej wersji skryptu - inaczej zapętlasz problem na poziom wyżej.
#Nie tylko cache
Najczęściej sięga się po Service Workery dla cache’owania i trybu offline, ale ten sam pośrednik napędza też powiadomienia push i synchronizację w tle - to osobne API zbudowane na tym samym fundamencie.
W praktyce rzadko pisze się te handlery ręcznie. Biblioteki w rodzaju Workbox dają gotowe strategie (CacheFirst, NetworkFirst, StaleWhileRevalidate) i ogarniają wersjonowanie za ciebie. Ale pod spodem to dokładnie ten sam model - warto rozumieć, co generują, zanim się na nich oprzesz.
#Co zapamiętać
- Service Worker to programowalny pośrednik między stroną a siecią - osobny wątek, bez DOM, przechwytuje każde
fetchi sam decyduje, co odpowiedzieć. - Cache API to twój schowek - ty zapisujesz, ty unieważniasz. Wersjonuj nazwy schowków i sprzątaj stare w
activate. - Strategię dobierasz do treści - cache-first dla niezmiennych assetów, network-first dla świeżych danych, stale-while-revalidate dla kompromisu.
- Aktualizacja jest leniwa - nowy worker czeka, aż zamkniesz stare karty.
skipWaitingiclients.claimją przyspieszają, ale wymagają porządku w wersjonowaniu.
Następnym razem, gdy aplikacja po wdrożeniu uparcie pokazuje starą wersję mimo wyczyszczonego cache przeglądarki, nie zgaduj - otwórz Application → Service Workers w DevTools i sprawdź, czy nie wisi tam stary worker w stanie waiting, oddający pliki ze swojego schowka. Wyczyszczenie cache przeglądarki go nie ruszy - on jest osobną warstwą, którą sam napisałeś, i serwuje dokładnie to, co kazałeś mu zapisać.
Komentarze
Wczytywanie komentarzy…