W przypadku usług workera zrezygnowaliśmy z prób rozwiązania problemu offline i oddaliśmy deweloperom narzędzia do samodzielnego rozwiązywania tego typu problemów. Umożliwia kontrolowanie buforowania i sposób obsługi żądań. Oznacza to, że możesz tworzyć własne wzory. Przyjrzyjmy się kilku możliwym wzorom osobno, ale w praktyce prawdopodobnie będziesz używać wielu z nich jednocześnie w zależności od adresu URL i kontekstu.
Demo niektórych z tych wzorów znajdziesz w artykule Trained-to-thrill (w języku angielskim) oraz w tym filmie, który pokazuje wpływ na skuteczność.
Maszyna do buforowania – kiedy przechowywać zasoby
Usługa workera umożliwia obsługę żądań niezależnie od pamięci podręcznej, więc pokażę je osobno. Najpierw o buforowaniu, kiedy powinno się to odbywać?
Podczas instalacji jako zależność.
Usługa w tle wysyła Ci zdarzenie install
. Możesz go użyć, aby przygotować elementy, które muszą być gotowe, zanim obsłużysz inne zdarzenia. W tym czasie poprzednia wersja Twojego serwisu workera nadal działa i obsługuje strony, więc czynności, które wykonujesz w tym kroku, nie mogą zakłócać jego działania.
Idealne rozwiązanie: CSS, obrazy, czcionki, JS, szablony... właściwie wszystko, co uważasz za statyczne w „wersji” witryny.
Są to elementy, które uniemożliwiłyby działanie witryny, gdyby nie udało się ich pobrać. Odpowiednie aplikacje na poszczególne platformy stanowią część początkowego pobierania.
self.addEventListener('install', function (event) {
event.waitUntil(
caches.open('mysite-static-v3').then(function (cache) {
return cache.addAll([
'/css/whatever-v3.css',
'/css/imgs/sprites-v6.png',
'/css/fonts/whatever-v8.woff',
'/js/all-min-v4.js',
// etc.
]);
}),
);
});
event.waitUntil
przyjmuje obietnicę określenia długości i skuteczności instalacji. Jeśli obietnica zostanie odrzucona, instalacja zostanie uznana za nieudaną, a ten usługa pracująca na tle zostanie porzucona (jeśli starsza wersja jest uruchomiona, pozostanie nienaruszona). caches.open()
i cache.addAll()
.
Jeśli nie uda się pobrać któregokolwiek z zasobów, wywołanie cache.addAll()
zostanie odrzucone.
W przypadku trained-to-thrill używam tego do przechowywania w pamięci podręcznej statycznych zasobów.
podczas instalacji, a nie jako zależność.
Jest to podobne do opcji opisanej powyżej, ale nie spowoduje opóźnienia w zakończeniu instalacji ani nie doprowadzi do jej przerwania w przypadku błędu w przypadku buforowania.
Idealne rozwiązanie: większe zasoby, których nie trzeba od razu używać, np. zasoby do późniejszych poziomów gry.
self.addEventListener('install', function (event) {
event.waitUntil(
caches.open('mygame-core-v1').then(function (cache) {
cache
.addAll
// levels 11–20
();
return cache
.addAll
// core assets and levels 1–10
();
}),
);
});
W tym przykładzie nie jest spełnione cache.addAll
na poziomie 11–20 w porównaniu z event.waitUntil
, więc nawet jeśli nie uda się spełnić tego zobowiązania, gra będzie nadal dostępna w trybie offline. Oczywiście musisz uwzględnić możliwość braku tych poziomów i ponownie spróbować ich zapisania do pamięci podręcznej, jeśli się nie wczytują.
Podczas pobierania poziomów 11–20 może zostać przerwany proces obsługi zdarzeń przez usługę w tle, co oznacza, że nie będą one przechowywane w pamięci podręcznej. W przyszłości interfejs Web Periodic Background Synchronization API będzie obsługiwać takie przypadki oraz większe pliki do pobrania, takie jak filmy. Ten interfejs API jest obecnie obsługiwany tylko w odgałęzi Chromium.
Po włączeniu
Idealny do: czyszczenia i przenoszenia.
Gdy nowy skrypt service worker zostanie zainstalowany, a poprzednia wersja nie jest używana, nowy skrypt zostanie aktywowany i wywoła zdarzenie activate
. Ponieważ stara wersja nie jest już potrzebna, warto przeprowadzić migrację schematu w IndexedDB, a także usunąć nieużywane pamięci podręczne.
self.addEventListener('activate', function (event) {
event.waitUntil(
caches.keys().then(function (cacheNames) {
return Promise.all(
cacheNames
.filter(function (cacheName) {
// Return true if you want to remove this cache,
// but remember that caches are shared across
// the whole origin
})
.map(function (cacheName) {
return caches.delete(cacheName);
}),
);
}),
);
});
Podczas aktywacji inne zdarzenia, takie jak fetch
, są umieszczane w kole, więc długa aktywacja może potencjalnie blokować wczytywanie stron. Uaktywnij jak najmniej funkcji i używaj ich tylko do tych czynności, których nie można było wykonać, gdy aktywna była stara wersja.
W przypadku trained-to-thrill używam tego do usuwania starych pamięci podręcznych.
Przy interakcji użytkownika
Idealne rozwiązanie: gdy nie można udostępnić offline całej witryny, a chcesz umożliwić użytkownikowi wybranie treści, które mają być dostępne offline. Przykład: film w YouTube, artykuł w Wikipedii lub konkretna galeria na Flickrze.
Dodaj przycisk „Przeczytaj później” lub „Zapisz na potrzeby korzystania offline”. Po kliknięciu pobierasz z sieci potrzebne informacje i przechowujesz je w pamięci podręcznej.
document.querySelector('.cache-article').addEventListener('click', function (event) {
event.preventDefault();
var id = this.dataset.articleId;
caches.open('mysite-article-' + id).then(function (cache) {
fetch('/get-article-urls?id=' + id)
.then(function (response) {
// /get-article-urls returns a JSON-encoded array of
// resource URLs that a given article depends on
return response.json();
})
.then(function (urls) {
cache.addAll(urls);
});
});
});
Interfejs caches API jest dostępny na stronach i w skryptach service worker, co oznacza, że możesz dodawać elementy do pamięci podręcznej bezpośrednio ze strony.
Odpowiedź sieci
Idealne rozwiązanie do: często aktualizowanych zasobów, takich jak skrzynka odbiorcza użytkownika lub treść artykułu. Są one też przydatne w przypadku treści nieistotnych, takich jak awatary, ale należy zachować ostrożność.
Jeśli żądanie nie pasuje do niczego w pamięci podręcznej, pobierz je z sieci, prześlij na stronę i dopisz do pamięci podręcznej.
Jeśli zrobisz to w przypadku wielu adresów URL, np. awatarów, musisz uważać, aby nie zapełnić miejsca na dane źródłowego adresu. Jeśli użytkownik potrzebuje więcej miejsca na dysku, nie chcesz, aby to Twoje urządzenie było głównym kandydatem. Usuń z pamięci podręcznej elementy, których już nie potrzebujesz.
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.open('mysite-dynamic').then(function (cache) {
return cache.match(event.request).then(function (response) {
return (
response ||
fetch(event.request).then(function (response) {
cache.put(event.request, response.clone());
return response;
})
);
});
}),
);
});
Aby zapewnić efektywne wykorzystanie pamięci, możesz odczytać treść odpowiedzi/żądania tylko raz. Powyższy kod używa .clone()
do tworzenia dodatkowych kopii, które można czytać osobno.
W przypadku trained-to-thrill używam tego do przechowywania w pamięci podręcznej obrazów z Flickr.
Nieaktualny podczas ponownego sprawdzania ważności
Idealne rozwiązanie: często aktualizowane zasoby, w przypadku których posiadanie najnowszej wersji nie jest konieczne. Do tej kategorii mogą należeć awatarzy.
Jeśli dostępna jest wersja w pamięci podręcznej, użyj jej, ale następnym razem pobierz aktualizację.
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.open('mysite-dynamic').then(function (cache) {
return cache.match(event.request).then(function (response) {
var fetchPromise = fetch(event.request).then(function (networkResponse) {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
return response || fetchPromise;
});
}),
);
});
Jest to bardzo podobne do stale-while-revalidate w HTTP.
W wiadomości push
Push API to kolejna funkcja oparta na Service Worker. Dzięki temu usługa workera może zostać uruchomiona w odpowiedzi na wiadomość z usługi przesyłania wiadomości w systemie operacyjnym. Dzieje się tak nawet wtedy, gdy użytkownik nie ma otwartej karty z Twoją witryną. Wybudzany jest tylko Service Worker. Na stronie wyświetli się prośba o przyznanie uprawnień.
Idealne do: treści związanych z powiadomieniem, takich jak wiadomość na czacie, najnowsze informacje lub e-mail. Dotyczy to również treści, które zmieniają się rzadko i korzystają z natychmiastowej synchronizacji, takich jak aktualizacja listy zadań lub zmiana w kalendarzu.
Najczęstszym końcowym działaniem jest powiadomienie, które po kliknięciu otwiera odpowiednią stronę. W takim przypadku bardzo ważne jest zaktualizowanie pamięci podręcznej przed tym działaniem. Użytkownik jest oczywiście online w momencie otrzymania wiadomości push, ale może nie być online, gdy w końcu wejdzie w interakcję z powiadomieniem. Dlatego ważne jest, aby treści były dostępne w trybie offline.
Ten kod aktualizuje pamięć podręczną przed wyświetleniem powiadomienia:
self.addEventListener('push', function (event) {
if (event.data.text() == 'new-email') {
event.waitUntil(
caches
.open('mysite-dynamic')
.then(function (cache) {
return fetch('/inbox.json').then(function (response) {
cache.put('/inbox.json', response.clone());
return response.json();
});
})
.then(function (emails) {
registration.showNotification('New email', {
body: 'From ' + emails[0].from.name,
tag: 'new-email',
});
}),
);
}
});
self.addEventListener('notificationclick', function (event) {
if (event.notification.tag == 'new-email') {
// Assume that all of the resources needed to render
// /inbox/ have previously been cached, e.g. as part
// of the install handler.
new WindowClient('/inbox/');
}
});
Synchronizacja w tle
Synchronizacja w tle to kolejna funkcja oparta na serwisie Worker. Umożliwia żądanie synchronizacji danych w tle jednorazowo lub w określonym (bardzo heurystycznym) interwale. Dzieje się tak nawet wtedy, gdy użytkownik nie ma otwartej karty z Twoją witryną. Tylko skrypt service worker jest aktywowany. Użytkownik otrzyma prośbę o przyznanie uprawnień.
Idealne rozwiązanie: niepilne aktualizacje, zwłaszcza te, które pojawiają się tak regularnie, że wysyłanie powiadomienia push po każdej aktualizacji byłoby dla użytkowników zbyt uciążliwe, np. oś czasu w mediach społecznościowych czy artykuły informacyjne.
self.addEventListener('sync', function (event) {
if (event.id == 'update-leaderboard') {
event.waitUntil(
caches.open('mygame-dynamic').then(function (cache) {
return cache.add('/leaderboard.json');
}),
);
}
});
Pamięć podręczna z trwałością
Źródło ma pewną ilość wolnego miejsca, z którego może korzystać według własnego uznania. To wolne miejsce jest współdzielone przez wszystkie miejsca na dane: (lokalne) miejsce na dane, IndexedDB, dostęp do systemu plików i oczywiście pamięć podręczna.
Kwota, którą otrzymasz, nie jest określona. Czas ładowania zależy od urządzenia i warunków przechowywania. Aby sprawdzić, ile masz punktów:
if (navigator.storage && navigator.storage.estimate) {
const quota = await navigator.storage.estimate();
// quota.usage -> Number of bytes used.
// quota.quota -> Maximum number of bytes available.
const percentageUsed = (quota.usage / quota.quota) * 100;
console.log(`You've used ${percentageUsed}% of the available storage.`);
const remaining = quota.quota - quota.usage;
console.log(`You can write up to ${remaining} more bytes.`);
}
Jednak podobnie jak w przypadku innych danych w przeglądarce, przeglądarka może je usunąć, jeśli urządzenie ma za mało miejsca. Niestety przeglądarka nie potrafi odróżnić filmów, które chcesz zachować za wszelką cenę, od gry, która Cię nie interesuje.
Aby to obejść, użyj interfejsu StorageManager:
// From a page:
navigator.storage.persist()
.then(function(persisted) {
if (persisted) {
// Hurrah, your data is here to stay!
} else {
// So sad, your data may get chucked. Sorry.
});
Oczywiście użytkownik musi udzielić zgody. W tym celu użyj interfejsu Permissions API.
Ważne jest, aby użytkownik był częścią tego procesu, ponieważ teraz to on ma kontrolować usuwanie. Jeśli urządzenie ma mało miejsca na dane, a usunięcie nieistotnych danych nie rozwiązuje problemu, użytkownik może zdecydować, które elementy zachować, a które usunąć.
Aby to działało, systemy operacyjne muszą traktować „trwałe” źródła jako równe aplikacjom na konkretne platformy w swoich podsumowaniach wykorzystania miejsca na dane, zamiast raportować przeglądarkę jako pojedynczy element.
Sugerowane odpowiedzi – odpowiadanie na prośby
Nie ma znaczenia, ile danych jest przechowywanych w pamięci podręcznej. Worker usługi nie będzie korzystać z pamięci podręcznej, dopóki nie określisz, kiedy i jak ma to robić. Oto kilka wzorów obsługi żądań:
Tylko pamięć podręczna
Idealne rozwiązanie: wszystko, co uważasz za statyczne w przypadku danej „wersji” witryny. Te dane powinny być zapisane w pamięci podręcznej w zdarzeniu instalacji, więc możesz na nich polegać.
self.addEventListener('fetch', function (event) {
// If a match isn't found in the cache, the response
// will look like a connection error
event.respondWith(caches.match(event.request));
});
…chociaż nie musisz często zajmować się tym przypadkiem, Pamięć podręczna – Fallback na sieć to obejmuje.
Tylko sieć
Idealne do: rzeczy, które nie mają odpowiednika offline, np. pingi analityczne czy żądania inne niż GET.
self.addEventListener('fetch', function (event) {
event.respondWith(fetch(event.request));
// or simply don't call event.respondWith, which
// will result in default browser behavior
});
…chociaż nie musisz często zajmować się tym przypadkiem, Pamięć podręczna – Fallback na sieć to obejmuje.
Pamięć podręczna, powrót do sieci
Idealny do: tworzenia aplikacji offline. W takich przypadkach w taki sposób będziesz obsługiwać większość próśb. Inne wzorce będą wyjątkami na podstawie przychodzącego zapytania.
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.match(event.request).then(function (response) {
return response || fetch(event.request);
}),
);
});
Dzięki temu możesz uzyskać zachowanie „tylko pamięci podręcznej” w przypadku elementów w pamięci podręcznej i zachowanie „tylko sieci” w przypadku elementów, które nie są przechowywane w pamięci podręcznej (co obejmuje wszystkie żądania inne niż GET, ponieważ nie można ich przechowywać w pamięci podręcznej).
Wyścig pamięci podręcznej i sieci
Idealny do: małych zasobów, w przypadku których zależy Ci na wydajności na urządzeniach z wolnym dostępem do dysku.
W przypadku niektórych kombinacji starszych dysków twardych, skanera antywirusowego i szybszych połączeń z internetem pobieranie zasobów z sieci może być szybsze niż z dysku. Pamiętaj jednak, że korzystanie z sieci, gdy użytkownik ma treści na urządzeniu, może powodować marnowanie danych.
// Promise.race is no good to us because it rejects if
// a promise rejects before fulfilling. Let's make a proper
// race function:
function promiseAny(promises) {
return new Promise((resolve, reject) => {
// make sure promises are all promises
promises = promises.map((p) => Promise.resolve(p));
// resolve this promise as soon as one resolves
promises.forEach((p) => p.then(resolve));
// reject if all promises reject
promises.reduce((a, b) => a.catch(() => b)).catch(() => reject(Error('All failed')));
});
}
self.addEventListener('fetch', function (event) {
event.respondWith(promiseAny([caches.match(event.request), fetch(event.request)]));
});
Sieć przechodzi na pamięć podręczną
Idealne rozwiązanie: szybkie rozwiązanie dla zasobów, które są często aktualizowane, poza „wersją” witryny. np. artykuły, awatary, osi czasu w mediach społecznościowych i tabele wyników gier.
Oznacza to, że użytkownicy online otrzymują najnowsze treści, ale użytkownicy offline otrzymują starsze wersje z pamięci podręcznej. Jeśli żądanie sieci zakończy się powodzeniem, prawdopodobnie zaktualizujesz wpis w pamięci podręcznej.
Ta metoda ma jednak swoje wady. Jeśli użytkownik ma przerywane lub wolne połączenie, musi poczekać, aż sieć przestanie działać, aby uzyskać akceptowalne treści na urządzeniu. Może to zająć bardzo dużo czasu i być dla użytkowników frustrujące. Aby znaleźć lepsze rozwiązanie, zobacz następny wzór: Pamięć podręczna, a potem sieć.
self.addEventListener('fetch', function (event) {
event.respondWith(
fetch(event.request).catch(function () {
return caches.match(event.request);
}),
);
});
Pamięć podręczna, a potem sieć
Idealne rozwiązanie: treści, które są często aktualizowane. Np. artykuły, osi czasu w mediach społecznościowych i gry. Tabele wyników
Wymaga to wysłania przez stronę 2 żądań: jednego do pamięci podręcznej i drugiego do sieci. Chodzi o to, aby najpierw wyświetlić dane z pamięci podręcznej, a potem zaktualizować stronę, gdy tylko dotrą dane z sieci.
Czasami możesz po prostu zastąpić bieżące dane nowymi (np. tabelą wyników w grze), ale może to zakłócać wyświetlanie większych treści. Ogólnie rzecz biorąc, nie „usuwaj” elementów, które użytkownik może czytać lub z którymi może wchodzić w interakcję.
Twitter dodaje nowe treści nad starymi i dostosowuje pozycję przewijania, aby użytkownik nie stracił ciągłości treści. Jest to możliwe, ponieważ Twitter zachowuje w większości liniowy układ treści. Skopiowałem ten wzór w przypadku trained-to-thrill, aby jak najszybciej wyświetlać treści na ekranie, a także aby wyświetlać aktualne treści od razu po ich otrzymaniu.
Kod na stronie:
var networkDataReceived = false;
startSpinner();
// fetch fresh data
var networkUpdate = fetch('/data.json')
.then(function (response) {
return response.json();
})
.then(function (data) {
networkDataReceived = true;
updatePage(data);
});
// fetch cached data
caches
.match('/data.json')
.then(function (response) {
if (!response) throw Error('No data');
return response.json();
})
.then(function (data) {
// don't overwrite newer network data
if (!networkDataReceived) {
updatePage(data);
}
})
.catch(function () {
// we didn't get cached data, the network is our last hope:
return networkUpdate;
})
.catch(showErrorMessage)
.then(stopSpinner);
Kod w skrypcie service worker:
Zawsze należy przejść do sieci i zaktualizować pamięć podręczną.
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.open('mysite-dynamic').then(function (cache) {
return fetch(event.request).then(function (response) {
cache.put(event.request, response.clone());
return response;
});
}),
);
});
W trained-to-thrill rozwiązałem ten problem, używając XHR zamiast fetch i wykorzystując nagłówek Accept, aby poinformować skrypt service worker, skąd pobrać wynik (kod strony, kod skryptu service worker).
Zastępcze rozwiązanie ogólne
Jeśli nie uda Ci się wyświetlić czegoś z pamięci podręcznej lub sieci, możesz podać ogólne dane.
Idealne rozwiązanie: obrazy dodatkowe, takie jak awatary, nieudane żądania POST i strona „Niedostępne w trybie offline”.
self.addEventListener('fetch', function (event) {
event.respondWith(
// Try the cache
caches
.match(event.request)
.then(function (response) {
// Fall back to network
return response || fetch(event.request);
})
.catch(function () {
// If both fail, show a generic fallback:
return caches.match('/offline.html');
// However, in reality you'd have many different
// fallbacks, depending on URL and headers.
// Eg, a fallback silhouette image for avatars.
}),
);
});
Element, na który przechodzisz, jest prawdopodobnie zależnością instalacji.
Jeśli Twoja strona publikuje e-maila, usługa może przechowywać go w „skrzynce odbiorczej” IndexedDB i poinformować stronę, że wysłanie się nie powiodło, ale dane zostały zachowane.
Szablony po stronie skryptu service worker
Idealne rozwiązanie: strony, których odpowiedzi z serwera nie można przechowywać w pamięci podręcznej.
Renderowanie stron na serwerze przyspiesza działanie, ale może to oznaczać, że w pamięci podręcznej będą się znajdować dane stanu, które nie mają sensu, np. „Zalogowany jako…”. Jeśli stroną steruje skrypt service worker, możesz zamiast tego poprosić o dane JSON wraz z szablonem i zrenderować ten szablon.
importScripts('templating-engine.js');
self.addEventListener('fetch', function (event) {
var requestURL = new URL(event.request.url);
event.respondWith(
Promise.all([
caches.match('/article-template.html').then(function (response) {
return response.text();
}),
caches.match(requestURL.path + '.json').then(function (response) {
return response.json();
}),
]).then(function (responses) {
var template = responses[0];
var data = responses[1];
return new Response(renderTemplate(template, data), {
headers: {
'Content-Type': 'text/html',
},
});
}),
);
});
Podsumowanie
Nie musisz ograniczać się do jednej z tych metod. W zależności od adresu URL żądania prawdopodobnie będziesz używać wielu z nich. Na przykład wytrenowana na potrzeby wrażeń używa:
- buforowanie podczas instalacji w przypadku statycznego interfejsu użytkownika i zachowania;
- pamięć podręczna na podstawie odpowiedzi sieci, aby pobrać obrazy i dane z Flickr.
- pobieranie z bufora, a w przeciwnym razie z sieci w przypadku większości żądań;
- pobieranie z pamięci podręcznej, a potem z sieci w przypadku wyników wyszukiwania w Flickr.
Wystarczy, że przejrzysz prośbę i zdecydujesz, co zrobić:
self.addEventListener('fetch', function (event) {
// Parse the URL:
var requestURL = new URL(event.request.url);
// Handle requests to a particular host specifically
if (requestURL.hostname == 'api.example.com') {
event.respondWith(/* some combination of patterns */);
return;
}
// Routing for local URLs
if (requestURL.origin == location.origin) {
// Handle article URLs
if (/^\/article\//.test(requestURL.pathname)) {
event.respondWith(/* some other combination of patterns */);
return;
}
if (/\.webp$/.test(requestURL.pathname)) {
event.respondWith(/* some other combination of patterns */);
return;
}
if (request.method == 'POST') {
event.respondWith(/* some other combination of patterns */);
return;
}
if (/cheese/.test(requestURL.pathname)) {
event.respondWith(
new Response('Flagrant cheese error', {
status: 512,
}),
);
return;
}
}
// A sensible default pattern
event.respondWith(
caches.match(event.request).then(function (response) {
return response || fetch(event.request);
}),
);
});
… rozumiesz, o co chodzi.
Środki
…za piękne ikony:
- Code autorstwa buzzyrobot
- Kalendarz, Scott Lewis
- Network, Ben Rizzo
- SD, Thomas Le Bas
- CPU, źródło: iconsmind.com
- Trash (ang. „śmieć”)
- Powiadomienie od @daosme
- Projekt: Mister Pixel
- Cloud, P.J. Onori
Dziękuję też Jeffowi Posnickowi za to, że zanim kliknąłem „Opublikuj”, znalazł wiele rażących błędów.
Więcej informacji
- Service Workers – wprowadzenie
- Czy skrypt service worker jest gotowy? – śledź stan implementacji w głównych przeglądarkach.
- Obiecowania w JavaScriptzie – wprowadzenie – przewodnik po obietnicach