Offline-first PWA po pierwszej wizycie
Offline-first to podejście blisko związane z aplikacjami PWA (Progressive Web App), pozwalające użytkownikom odwiedzającym stronę na korzystanie z niej w przypadku słabego połączenia z internetem bądź jego braku/utraty. Technika ta najczęściej bazuje na pośrednikach, tzw. service workerach i obejmuje zapisywanie pobranych źródeł do pamięci przeglądarki. W sytuacji, gdy przeglądarka nie może połączyć się z serwerem, zasoby serwowane są właśnie z pamięci.
Service Workers
Service worker jest właściwie plikiem skryptowym (JavaScript), który wykonuje się w tle, pośrednicząc w komunikacji z serwerem. Aby poprawnie zaimplementować go w naszej aplikacji, warto zapoznać się z cyklem życia service workerów oraz zdarzeniami, które są obsługiwane:
Pierwszym krokiem potrzebnym do dodania naszego service workera jest jego rejestracja przez klienta (skrypt na stronie). Po przeanalizowaniu kodu, service worker przechodzi w stan installing
. Po zainstalowaniu kolejna faza waiting
trwa aż do momentu, gdy klienci (np. inne karty przeglądarki) tej samej aplikacji z aktywnymi workerami zostaną zamknięci. Etap oczekiwania możemy pominąć, wywołując metodę skipWaiting()
.
Uwaga: pominięcie fazy oczekiwania może prowadzić do problemów związanych ze spójnością kodu i danych – zasoby załadowane przez poprzedniego workera (w innej karcie) mogą nie być kompatybilne z obecnie instalowanym.
W kolejnej fazie activating
możemy zająć się wyczyszczeniem starych rekordów. Po jej zakończeniu nasz worker zacznie obsługiwać następujące zdarzenia:
- fetch – pobranie zasobu;
- sync – wykonanie zadania, gdy użytkownik będzie miał połączenie z internetem;
- push – odebranie wiadomości z servera.
Właściwie to podpięcie się pod klienta (kartę przeglądarki) nastąpi dopiero po odświeżeniu strony. Jeśli chcemy przyspieszyć ten proces, możemy użyć funkcji clients.claim()
.
Uwaga: podpięcie pod klienta, który ma już załadowaną stronę może skutkować niespójnym zachowaniem – np. jeśli cache'ujemy wszystkie zrequestowane zasoby, to dotychczasowe zasoby nie zostaną zapisane w pamięci podręcznej.
Oto przykładowa implementacja workera, który cache'uje każdą udaną odpowiedź i zapytanie, a w razie problemów próbuje pobrać zasób z pamięci:
// offline-sw.js
const CACHE = "offline-cache-v1";
const PREFETCH_PAGES = ["/404"];
const self = this;
self.addEventListener("install", function (event) {
console.debug("[SW] Pre-install")
event.waitUntil(
caches.open(CACHE).then(function (cache) {
return cache.addAll(PREFETCH_PAGES);
})
);
console.debug("[SW] Post-install")
});
self.addEventListener('activate', function(event) {
console.debug("[SW] Pre-activate")
event.waitUntil(self.clients.claim());
console.debug("[SW] Post-activate")
});
self.addEventListener("fetch", function (event) {
console.debug("[SW] Fetch -> " + event.request.url)
if (event.request.method !== "GET") return;
event.respondWith(
fetch(event.request)
.then(function (response) {
event.waitUntil(updateCache(event.request, response.clone()));
console.debug("[SW] Fetch network first -> " + event.request.url)
return response;
})
.catch(function (error) {
console.debug("[SW] Fetch cache first -> " + event.request.url)
return fromCache(event.request);
})
);
});
function fromCache(request) {
return caches.open(CACHE).then(function (cache) {
return cache.match(request).then(function (matching) {
if (!matching || matching.status === 404) {
return Promise.reject("no-match");
}
return matching;
});
});
}
function updateCache(request, response) {
return caches.open(CACHE).then(function (cache) {
return cache.put(request, response);
});
}
Klient
Do szczęścia brakuje nam już tylko jeszcze rejestracji naszego workera. Przed tym powinniśmy jednak sprawdzić, czy klient (przeglądarka) zapewnia wsparcie dla service workerów, odpytując obiekt window.navigator
w poszukiwaniu właściwości serviceWorker
. Następnie warto zastanowić się, w której fazie ładowania strony chcemy zarejestrować naszego workera.
Dobrą praktyką jest opóźnienie rejestracji do momentu, gdy strona i jej zasoby w pełni się załadowały. Dzięki takiej priorytetyzacji nasza strona nieco szybciej załaduje się przy pierwszym wejściu, co może mieć duże znaczenie w przypadku internautów z gorszym połączeniem internetowym.
Z drugiej strony możemy zastosować agresywną strategię cache'owania wszystkich zasobów i zarejestrować workera na samym początku. Niestety ze względu na jego asynchroniczne działanie, część zasobów finalnie załaduje się przed jego aktywacją. W przypadku podejścia offline-first problem możemy rozwiązać poprzez kombinację clients.claim()
oraz:
- Opóźnienie ładowania strony aż do aktywacji workera – trudne w implementacji, niepożądane ze względu opóźnienie w ładowaniu;
- Automatyczne wywołanie odświeżenia strony po aktywacji workera – odświeżenie strony może być niepożądane;
- Pre-cache'owanie wszystkich niezbędnych zasobów w fazie install – trudności w przypadku dynamicznie generowanych nazw;
- Ponowne pobranie zasobów po aktywacji workera – komplikuje się w przypadku bardziej złożonych zapytań (body/cors);
- Pogodzenie się z tym, że service worker w pełni załaduje aplikację do cache'a dopiero po odświeżeniu.
Jak łatwo się domyślić każdy z tych sposobów wiąże się z jakimiś niedogodnościami. Punkt 3. można zaobserwować w kodzie service workera pokazanym wyżej – przed aktywacją pobieramy stronę 404 w celu zapisania w pamięci podręcznej. Punkty 2. i 4. można zaimplementować po stronie klienta:
<script type="text/javascript">
// /index.html
if ("serviceWorker" in navigator) {
console.debug("Deferring service worker registration to page load");
window.addEventListener("load", function() {
if (navigator.serviceWorker.controller) {
console.debug("[Client] This page is already controlled by: " + navigator.serviceWorker.controller.scriptURL);
} else {
console.debug("[Client] This page is currently not controlled by a service worker.");
console.debug("[Client] Registering a new service worker");
navigator.serviceWorker.register("/offline-sw.js", {
scope: "/",
}).then(function() {
console.debug("[Client] Successfully registered service worker");
navigator.serviceWorker.addEventListener("controllerchange", function(event) {
console.debug("[Client] Service worker activated");
if ("performance" in window) {
refetch();
} else {
reload();
}
});
}).catch(function(error) {
console.error(error);
});
};
})
} else {
console.debug("Service workers are not supported");
}
function reload() {
console.debug("[Client] Reloading page to loaded resources for caching")
location.reload();
}
function refetch() {
console.debug("[Client] Requesting already loaded resources for caching")
performance.getEntries()
.map(function(resource) {
return new Request(resource.name, { mode: "no-cors" });
}).forEach(function(request) {
console.debug("[Client] Fetch -> " + request.url);
fetch(request);
});
}
</script>
Do ponownego pobrania zasobów wykorzystałem Performance API. Interfejs ten pozwala na wyświetlenie załadowanych do tej pory plików. W przypadku plików statycznych i prostych requestów GET takie rozwiązanie jest zadowalające.
Do załadowania fontów z serwerów google, konieczne będzie dodanie { mode: "no-cors" }
. W momencie ponownego pobrania zasobów, aktywny już service worker zajmie się ich zapisaniem do pamięci.
Uwaga: Korzystając z Performance API warto zauważyć, że w momencie aktywacji SW część requestów (ajax) może być w trakcie realizacji i nie będą one jeszcze wpisane na liste otrzymaną z
getEntries()
, tym samym nie zostaną ponownie załadowane i zapisane do pamięci. Ten problem brzegowy można rozwiązać za pomocą PerformanceObservera biorąc pod uwagę czas początkowy zrequestowanego zasobu.
Podsumowanie
Oczywiście aktywacja service-workera i odświeżenie nie wyklucza samego pre-cache'owanie zasobów. Przykładowo jeśli w aplikacji masz oddzielną stronę 404, warto ją zawczasu załadować, gdyż pierwsze jej otworzenie może nastąpić w trybie offline. Wtedy zostanie ona załadowana z pamięci, mimo, że użytkownik nigdy wcześniej jej nie widział.
Na tej samej stronie warto wyświetlić wiadomość w przypadku problemów z połączeniem i serwować ją z cache w przypadku, gdy nie możemy połączyć się z serwerem, a w cache'u brakuje właściwej strony.
Przykładowa branch testowy z aplikacją PWA w trybie offline-first dodałem na Netlify. Po pierwszym uruchomieniu (clear site data) i wybraniu trybu offline w service workerze, ze sporadycznymi problemami (problem brzegowy z Performance API podczas serwowania postu z cache) aplikacja powinna umożliwić wyświetlenie pięciu pierwszych postów (również po odświeżeniu) oraz strony 404 dla pozostałych zasobów.