Typowe techniki tworzenia aplikacji offline

Jake Archibald
Jake Archibald

Dzięki Service Workers udostępniliśmy programistom sposób na rozwiązanie problemu z połączeniem sieciowym. Masz kontrolę nad buforowaniem i sposobem obsługi żądań. Oznacza to, że możesz tworzyć własne wzory. Przyjrzyj się kilku możliwym wzorcom w izolacji, ale w praktyce prawdopodobnie będziesz ich używać jednocześnie, w zależności od adresu URL i kontekstu.

Przykłady niektórych z tych wzorców znajdziesz w Trained-to-thrill.

Kiedy przechowywać zasoby

Browser Support

  • Chrome: 40.
  • Edge: 17.
  • Firefox: 44.
  • Safari: 11.1.

Source

Skrypty service worker umożliwiają obsługę żądań niezależnie od buforowania, dlatego pokażę je osobno. Najpierw ustal, kiedy warto używać pamięci podręcznej.

Podczas instalacji jako zależność

Podczas instalacji jako zależność.

Interfejs Service Worker API udostępnia zdarzenie install. Możesz go użyć, aby przygotować elementy, które muszą być gotowe przed obsługą innych zdarzeń. Podczas install poprzednie wersje skryptu service worker działają i obsługują strony. Wszelkie działania podejmowane w tym czasie nie powinny zakłócać działania dotychczasowego service workera.

Idealne rozwiązanie w przypadku: plików CSS, obrazów, czcionek, JS, szablonów i wszystkiego innego, co w danej wersji witryny uważasz za statyczne.

Pobieraj elementy, które w przypadku niepowodzenia pobierania spowodują, że witryna przestanie działać, oraz elementy, które odpowiednia aplikacja na konkretną platformę uwzględniłaby w początkowym pobieraniu.

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 skrypt service worker zostanie porzucony (jeśli działa starsza wersja, pozostanie ona nienaruszona). caches.open()cache.addAll() obietnice zwrotu. Jeśli nie uda się pobrać żadnego z zasobów, wywołanie cache.addAll() zostanie odrzucone.

Na stronie trained-to-thrill używam tego do buforowania statycznych komponentów.

Podczas instalacji, a nie jako zależność

Podczas instalacji, a nie jako zależność.

Jest to podobne do instalowania jako zależności, ale nie opóźnia zakończenia instalacji i nie powoduje jej niepowodzenia w przypadku niepowodzenia buforowania.

Idealne rozwiązanie w przypadku: większych zasobów, które nie są potrzebne od razu, np. elementów 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
        ();
    }),
  );
});

Ten przykład nie przekazuje z powrotem do cache.addAll obietnicy dotyczącej poziomów 11–20, więc nawet jeśli się nie powiedzie, gra będzie nadal dostępna offline.event.waitUntil Oczywiście musisz uwzględnić możliwość braku tych poziomów i ponownie spróbować je zapisać w pamięci podręcznej, jeśli ich nie ma.

Proces service worker może zostać zakończony podczas pobierania poziomów 11–20, ponieważ zakończył obsługę zdarzeń, co oznacza, że nie zostaną one zapisane w pamięci podręcznej. Web Periodic Background Synchronization API może obsługiwać takie przypadki, a także większe pobierania, np. filmów.

Browser Support

  • Chrome: 40.
  • Edge: 17.
  • Firefox: 44.
  • Safari: 11.1.

Source

Po aktywacji

Po aktywacji

Idealny do: czyszczenia i migracji.

Gdy nowy skrypt service worker zostanie zainstalowany, a poprzednia wersja nie będzie używana, nowy skrypt zostanie aktywowany i otrzymasz zdarzenie activate. Ponieważ poprzednia wersja nie jest już potrzebna, to dobry moment na przeprowadzenie migracji schematu w IndexedDB i usunięcie nieużywanych pamięci podręcznych.

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 zdarzenia takie jak fetch są umieszczane w kolejce, więc długotrwała aktywacja może blokować wczytywanie stron. Zadbaj o to, aby aktywacja była jak najprostsza, i używaj jej tylko do czynności, których nie można było wykonać, gdy aktywna była poprzednia wersja.

Na stronie trained-to-thrill używam tego do usuwania starych pamięci podręcznych.

Przy interakcji użytkownika

Przy interakcji użytkownika.

Idealne rozwiązanie: gdy nie można udostępnić całej witryny w trybie offline i zdecydowano, że użytkownik będzie mógł wybrać treści, które mają być dostępne offline. np. film w YouTube, artykuł w Wikipedii lub konkretna galeria na Flickrze.

Udostępnij użytkownikowi przycisk „Przeczytaj później” lub „Zapisz do odczytu offline”. Gdy użytkownik kliknie link, pobierz potrzebne informacje z sieci i zapisz 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 Cache 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.

Browser Support

  • Chrome: 40.
  • Edge: 16.
  • Firefox: 41.
  • Safari: 11.1.

Source

Odpowiedź sieci

Odpowiedź w sieci.

Idealne rozwiązanie w przypadku: częstego aktualizowania zasobów, takich jak skrzynka odbiorcza użytkownika lub zawartość artykułu. Przydatne również 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, wyślij na stronę i jednocześnie dodaj do pamięci podręcznej.

Jeśli zrobisz to w przypadku zakresu adresów URL, np. awatarów, musisz uważać, aby nie przepełnić miejsca na dane w Twojej domenie. Jeśli użytkownik musi odzyskać miejsce na dysku, nie chcesz być 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 lub żądania tylko raz. W przykładzie kodu użyto .clone() do utworzenia dodatkowych kopii, które można odczytywać oddzielnie.

Na blogu trained-to-thrill używam tej funkcji do buforowania obrazów z Flickr.

Stale-while-revalidate

Stale-while-revalidate

Idealne rozwiązanie w przypadku: często aktualizowanych zasobów, w których przypadku posiadanie najnowszej wersji nie jest niezbędne. Do tej kategorii mogą należeć awatary.

Jeśli dostępna jest wersja w pamięci podręcznej, użyj jej, ale pobierz aktualizację na następny raz.

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;
      });
    }),
  );
});

Działa to bardzo podobnie do stale-while-revalidate w HTTP.

W wiadomości push

On push message

Push API to kolejna funkcja oparta na mechanizmie Service Worker. Dzięki temu usługa Service Worker może zostać wybudzona w odpowiedzi na wiadomość z usługi przesyłania wiadomości systemu operacyjnego. Dzieje się tak nawet wtedy, gdy użytkownik nie ma otwartej karty z Twoją witryną. Tylko proces roboczy usługi jest wybudzany. Prośbę o pozwolenie na to możesz przesłać ze strony, a użytkownik zobaczy odpowiedni komunikat.

Idealne do: treści związanych z powiadomieniem, np. wiadomości na czacie, najnowszych informacji lub e-maila. Rzadko zmieniające się treści, które korzystają z natychmiastowej synchronizacji, np. aktualizacja listy zadań do wykonania lub zmiana w kalendarzu.

Typowym efektem końcowym jest powiadomienie, które po kliknięciu otwiera i wyświetla odpowiednią stronę. W tym przypadku bardzo ważne jest wcześniejsze zaktualizowanie pamięci podręcznej. Użytkownik jest online w momencie otrzymania wiadomości push, ale może nie być online, gdy wchodzi w interakcję z powiadomieniem. Dlatego ważne jest, aby udostępniać te treści 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

Synchronizacja w tle to kolejna funkcja oparta na instancji roboczej usługi. Umożliwia jednorazowe lub okresowe (z użyciem bardzo heurystycznego interwału) wysyłanie żądań synchronizacji danych w tle. Dzieje się tak nawet wtedy, gdy użytkownik nie ma otwartej karty z Twoją witryną. Aktywowany jest tylko skrypt service worker. Prośbę o zezwolenie możesz wysłać ze strony, a użytkownik zobaczy odpowiedni komunikat.

Idealne rozwiązanie w przypadku: niepilnych aktualizacji, zwłaszcza tych, które pojawiają się tak często, że wysyłanie powiadomień push po każdej z nich byłoby zbyt uciążliwe dla użytkowników, np. w przypadku osi czasu w mediach społecznościowych lub artykułów prasowych.

self.addEventListener('sync', function (event) {
  if (event.id == 'update-leaderboard') {
    event.waitUntil(
      caches.open('mygame-dynamic').then(function (cache) {
        return cache.add('/leaderboard.json');
      }),
    );
  }
});

Trwałość pamięci podręcznej

Pochodzenie otrzymuje określoną ilość wolnego miejsca, które może wykorzystać w dowolny sposób. To bezpłatne miejsce jest współdzielone przez wszystkie źródła pamięci: pamięć(lokalną), IndexedDB, dostęp do systemu plików i oczywiście pamięć podręczną.

Kwota, którą otrzymasz, nie jest określona. Zależy to od urządzenia i warunków przechowywania. Możesz sprawdzić, ile masz środków, za pomocą tych metod:

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 wszystkich pamięci przeglądarki, przeglądarka może usunąć Twoje dane, jeśli na urządzeniu zabraknie miejsca. Niestety przeglądarka nie jest w stanie odróżnić filmów, które chcesz zachować za wszelką cenę, od gry, na której Ci nie zależy.

Aby obejść ten problem, 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 wyrazić na to zgodę. W tym celu użyj interfejsu Permissions API.

Zaangażowanie użytkownika w ten proces jest ważne, ponieważ dzięki temu może on kontrolować usuwanie danych. Jeśli na urządzeniu zaczyna brakować miejsca, a wyczyszczenie 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ównoważne aplikacjom na konkretne platformy w podziale wykorzystania pamięci, zamiast raportować przeglądarkę jako pojedynczy element.

Wyświetlanie sugestii

Bez względu na to, ile pamięci podręcznej używasz, service worker korzysta z niej tylko wtedy, gdy powiesz mu, kiedy i jak ma to robić. Oto kilka wzorców obsługi żądań:

Tylko pamięć podręczna

Tylko pamięć podręczna.

Idealne rozwiązanie w przypadku: wszystkiego, co uważasz za statyczne w danej „wersji” witryny. Powinny być one zapisane w zdarzeniu instalacji, więc możesz mieć pewność, że tam będą.

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 obsługiwać tego przypadku, obejmuje go pamięć podręczna z powrotem do sieci.

Tylko sieć

Tylko sieć.

Idealne w przypadku: elementów, które nie mają odpowiednika offline, np. pingów analitycznych czy żądań innych niż GET.

self.addEventListener('fetch', function (event) {
  event.respondWith(fetch(event.request));
  // or don't call event.respondWith, which
  // will result in default browser behavior
});

…chociaż nie musisz często obsługiwać tego przypadku, obejmuje go pamięć podręczna z powrotem do sieci.

Pamięć podręczna, powrót do sieci

Pamięć podręczna, z możliwością powrotu do sieci.

Idealne rozwiązanie do: tworzenia aplikacji działających w trybie offline. W takich przypadkach w większości przypadków będziesz postępować w ten sposób. Inne wzorce są wyjątkami opartymi na żądaniu przychodzącym.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.match(event.request).then(function (response) {
      return response || fetch(event.request);
    }),
  );
});

Dzięki temu w przypadku elementów w pamięci podręcznej uzyskasz zachowanie „tylko pamięć podręczna”, a w przypadku elementów nieznajdujących się w pamięci podręcznej – zachowanie „tylko sieć” (dotyczy to wszystkich żądań innych niż GET, ponieważ nie można ich przechowywać w pamięci podręcznej).

Wyścig pamięci podręcznej i sieci

Wyścig pamięci podręcznej i sieci.

Idealne w przypadku: małych zasobów, w 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, skanerów antywirusowych i szybszych połączeń internetowych pobieranie zasobów z sieci może być szybsze niż uzyskiwanie dostępu do dysku. Jednak w sytuacji, gdy użytkownik ma treści na urządzeniu, korzystanie z sieci może być marnotrawstwem danych, więc pamiętaj o tym.

// Promise.race rejects when a promise rejects before fulfilling.
// To make a 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ć wraca do pamięci podręcznej

Sieć wraca do pamięci podręcznej.

Idealne rozwiązanie w przypadku: zasobów, które są często aktualizowane poza „wersją” witryny. np. artykuły, awatary, osie czasu w mediach społecznościowych i tabele wyników w grach.

Oznacza to, że użytkownicy online otrzymują najbardziej aktualne treści, a użytkownicy offline – starszą wersję z pamięci podręcznej. Jeśli żądanie sieciowe zakończy się powodzeniem, prawdopodobnie zechcesz zaktualizować wpis w pamięci podręcznej.

Ta metoda ma jednak wady. Jeśli użytkownik ma niestabilne lub wolne połączenie, musi poczekać, aż sieć przestanie działać, zanim otrzyma w pełni akceptowalne treści, które są już na jego urządzeniu. Może to zająć bardzo dużo czasu i być frustrujące dla użytkowników. Lepsze rozwiązanie znajdziesz w następnym wzorcu, 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ć

Pamięć podręczna, a potem sieć.

Idealny do: treści, które są często aktualizowane. np. artykuły, osie 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, a drugiego do sieci. Chodzi o to, aby najpierw wyświetlić dane z pamięci podręcznej, a potem zaktualizować stronę, gdy dotrą dane z sieci.

Czasami możesz po prostu zastąpić bieżące dane, gdy pojawią się nowe (np. w tabeli wyników gry), ale w przypadku większych treści może to być uciążliwe. Nie ukrywaj 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 nie przerywać użytkownikowi przeglądania. Jest to możliwe, ponieważ Twitter zachowuje w przypadku treści w większości liniową kolejność. Skopiowałem ten wzorzec dla trained-to-thrill, aby jak najszybciej wyświetlać treści na ekranie, a jednocześnie pokazywać 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 usłudze Service Worker:

Zawsze przechodź do sieci i aktualizuj pamięć podręczną na bieżąco.

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;
      });
    }),
  );
});

trained-to-thrill poradziłem sobie z tym problemem, używając XHR zamiast fetch i wykorzystując nagłówek Accept, aby poinformować skrypt service worker, skąd ma pobrać wynik (kod strony, kod skryptu service worker).

Ogólny przebieg

Ogólna opcja zastępcza.

Jeśli nie uda Ci się wyświetlić czegoś z pamięci podręcznej lub sieci, udostępnij ogólną wersję zastępczą.

Idealne do: obrazów dodatkowych, takich 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, do którego następuje powrót, jest prawdopodobnie zależnością instalacyjną.

Jeśli strona wysyła e-maila, service worker może zapisać go w skrzynce wychodzącej IndexedDB i odpowiedzieć, że wysyłanie się nie powiodło, ale dane zostały zachowane.

Tworzenie szablonów po stronie skryptu service worker

Szablony po stronie service workera.

Idealne rozwiązanie w przypadku: stron, których odpowiedzi serwera nie można przechowywać w pamięci podręcznej.

Szybsze renderowanie stron na serwerze może jednak oznaczać uwzględnianie danych stanu, które nie mają sensu w pamięci podręcznej, np. stanu zalogowania. Jeśli Twoja strona jest kontrolowana przez skrypt service worker, możesz poprosić o dane JSON wraz z szablonem i zamiast tego je renderować.

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',
        },
      });
    }),
  );
});

Łączenie elementów

Nie musisz ograniczać się do jednej z tych metod. W zależności od adresu URL żądania prawdopodobnie użyjesz wielu z nich. Na przykład trained-to-thrill używa:

Wystarczy, że sprawdzisz 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);
    }),
  );
});

Więcej informacji

Środki

W przypadku pięknych ikon:

Dziękuję też Jeffowi Posnickowi za wyłapanie wielu błędów, zanim kliknąłem „Opublikuj”.