Teknik umum untuk membangun aplikasi offline

Jake Archibald
Jake Archibald

Dengan Service Worker, kami memberi developer cara untuk mengatasi koneksi jaringan. Anda mendapatkan kontrol atas penayangan cache dan cara penanganan permintaan. Artinya, Anda dapat membuat pola Anda sendiri. Lihat beberapa kemungkinan pola secara terpisah, tetapi dalam praktiknya, Anda kemungkinan akan menggunakannya secara bersamaan, bergantung pada URL dan konteks.

Untuk melihat demo yang berfungsi dari beberapa pola ini, lihat Trained-to-thrill.

Kapan harus menyimpan resource

Browser Support

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

Source

Pekerja layanan memungkinkan Anda menangani permintaan secara terpisah dari penyimpanan dalam cache, jadi saya akan mendemonstrasikannya secara terpisah. Pertama, tentukan kapan Anda harus menggunakan cache.

Saat penginstalan, sebagai dependensi

Saat penginstalan, sebagai dependensi.

Service Worker API memberi Anda peristiwa install. Anda dapat menggunakannya untuk menyiapkan berbagai hal, hal-hal yang harus siap sebelum Anda menangani peristiwa lain. Selama install, versi pekerja layanan Anda sebelumnya tetap berjalan dan menayangkan halaman. Apa pun yang Anda lakukan saat ini tidak boleh mengganggu pekerja layanan yang ada.

Ideal untuk: CSS, gambar, font, JS, template, atau apa pun yang Anda anggap statis untuk versi situs tersebut.

Ambil hal-hal yang akan membuat situs Anda tidak berfungsi sama sekali jika gagal diambil, hal-hal yang akan dibuat oleh aplikasi khusus platform yang setara sebagai bagian dari download awal.

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 memerlukan janji untuk menentukan durasi dan keberhasilan penginstalan. Jika promise ditolak, penginstalan dianggap gagal dan Service Worker ini akan dihentikan (jika versi lama sedang berjalan, versi tersebut akan tetap utuh). caches.open() dan cache.addAll() menampilkan promise. Jika salah satu resource gagal diambil, panggilan cache.addAll() akan ditolak.

Di trained-to-thrill, saya menggunakannya untuk menyimpan aset statis dalam cache.

Saat penginstalan, bukan sebagai dependensi

Saat penginstalan, bukan sebagai dependensi.

Hal ini mirip dengan menginstal sebagai dependensi, tetapi tidak akan menunda penyelesaian penginstalan dan tidak akan menyebabkan penginstalan gagal jika penyimpanan dalam cache gagal.

Ideal untuk: Resource yang lebih besar yang tidak diperlukan secara langsung, seperti aset untuk level game berikutnya.

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

Contoh ini tidak meneruskan janji cache.addAll untuk level 11–20 kembali ke event.waitUntil, jadi meskipun gagal, game akan tetap tersedia secara offline. Tentu saja, Anda harus memperhitungkan kemungkinan tidak adanya level tersebut dan mencoba lagi melakukan caching jika level tersebut tidak ada.

Service worker dapat dihentikan saat mendownload level 11–20 karena telah selesai menangani peristiwa, yang berarti level tersebut tidak akan di-cache. Web Periodic Background Synchronization API dapat menangani kasus seperti ini, dan download yang lebih besar seperti film.

Browser Support

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

Source

Saat diaktifkan

Saat mengaktifkan.

Ideal untuk: pembersihan dan migrasi.

Setelah pekerja layanan baru diinstal dan versi sebelumnya tidak digunakan, versi baru akan diaktifkan, dan Anda akan mendapatkan peristiwa activate. Karena versi sebelumnya sudah tidak digunakan, ini adalah waktu yang tepat untuk menangani migrasi skema di IndexedDB dan juga menghapus cache yang tidak digunakan.

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

Selama aktivasi, peristiwa seperti fetch dimasukkan ke dalam antrean, sehingga aktivasi yang lama dapat memblokir pemuatan halaman. Buat aktivasi Anda sesederhana mungkin, dan hanya gunakan untuk hal-hal yang tidak dapat Anda lakukan saat versi sebelumnya aktif.

Di trained-to-thrill, saya menggunakan ini untuk menghapus cache lama.

Pada interaksi pengguna

Saat interaksi pengguna.

Ideal untuk: saat seluruh situs tidak dapat diakses offline, dan Anda memilih untuk mengizinkan pengguna memilih konten yang ingin mereka akses offline. Misalnya, video di YouTube, artikel di Wikipedia, galeri tertentu di Flickr.

Memberi pengguna tombol "Baca nanti" atau "Simpan untuk offline". Saat diklik, ambil apa yang Anda butuhkan dari jaringan dan masukkan ke dalam cache.

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

Cache API tersedia dari halaman dan pekerja layanan, yang berarti Anda dapat menambahkan ke cache secara langsung dari halaman.

Browser Support

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

Source

Pada respons jaringan

Respons di jaringan.

Ideal untuk: sering memperbarui resource seperti kotak masuk pengguna atau konten artikel. Juga berguna untuk konten tidak penting seperti avatar, tetapi perlu berhati-hati.

Jika permintaan tidak cocok dengan apa pun di cache, ambil dari jaringan, kirim ke halaman, dan tambahkan ke cache secara bersamaan.

Jika Anda melakukannya untuk berbagai URL, seperti avatar, Anda harus berhati-hati agar tidak membebani penyimpanan asal Anda. Jika pengguna perlu merebut kembali ruang disk, Anda tidak ingin menjadi kandidat utama. Pastikan Anda menghapus item yang tidak lagi diperlukan dalam cache.

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

Untuk memungkinkan penggunaan memori yang efisien, Anda hanya dapat membaca isi respons/permintaan satu kali. Contoh kode menggunakan .clone() untuk membuat salinan tambahan yang dapat dibaca secara terpisah.

Di trained-to-thrill, saya menggunakannya untuk meng-cache gambar Flickr.

Tidak berlaku saat divalidasi ulang

Stale-while-revalidate.

Ideal untuk: resource yang sering diupdate dan tidak memerlukan versi terbaru. Avatar dapat termasuk dalam kategori ini.

Jika ada versi yang di-cache, gunakan versi tersebut, tetapi ambil update untuk waktu berikutnya.

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

Hal ini sangat mirip dengan stale-while-revalidate HTTP.

Pada pesan push

Pada pesan push.

Push API adalah fitur lain yang dibangun di atas service worker. Hal ini memungkinkan service worker diaktifkan sebagai respons terhadap pesan dari layanan pesan OS. Hal ini terjadi meskipun pengguna tidak membuka tab ke situs Anda. Hanya service worker yang diaktifkan. Anda meminta izin untuk melakukannya dari halaman dan pengguna akan diminta.

Ideal untuk: konten yang terkait dengan notifikasi, seperti pesan chat, berita terbaru, atau email. Selain itu, konten yang jarang berubah yang diuntungkan dari sinkronisasi langsung, seperti pembaruan daftar tugas atau perubahan kalender.

Hasil akhir yang umum adalah notifikasi yang, saat diketuk, akan membuka dan memfokuskan halaman yang relevan, dan yang pembaruan cache sebelumnya sangat penting. Pengguna sedang online pada saat menerima pesan push, tetapi mungkin tidak online saat berinteraksi dengan notifikasi, jadi penting untuk membuat konten ini tersedia secara offline.

Kode ini memperbarui cache sebelum menampilkan notifikasi:

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

Pada background-sync

Pada background-sync.

Sinkronisasi latar belakang adalah fitur lain yang dibuat di atas pekerja layanan. Anda dapat meminta sinkronisasi data latar belakang sebagai satu kali, atau pada interval (sangat heuristik). Hal ini terjadi meskipun pengguna tidak membuka tab ke situs Anda. Hanya pekerja layanan yang diaktifkan. Anda meminta izin untuk melakukannya dari halaman dan pengguna akan diminta.

Ideal untuk: pembaruan yang tidak mendesak, terutama yang terjadi secara rutin sehingga pesan push per pembaruan akan terlalu sering bagi pengguna, seperti linimasa media sosial atau artikel berita.

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

Persistensi cache

Asal Anda diberi ruang kosong dalam jumlah tertentu untuk melakukan apa pun yang diinginkannya. Ruang kosong tersebut dibagikan di antara semua penyimpanan asal: Penyimpanan(lokal), IndexedDB, Akses Sistem File, dan tentu saja Cache.

Jumlah yang Anda dapatkan tidak ditentukan. Hal ini berbeda-beda bergantung pada perangkat dan kondisi penyimpanan. Anda dapat mengetahui jumlah yang Anda miliki dengan:

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.`);
}

Namun, seperti semua penyimpanan browser, browser bebas menghapus data Anda jika perangkat mengalami tekanan penyimpanan. Sayangnya, browser tidak dapat membedakan antara film yang ingin Anda simpan dengan cara apa pun, dan game yang tidak terlalu Anda pedulikan.

Untuk mengatasi hal ini, gunakan antarmuka 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.
});

Tentu saja, pengguna harus memberikan izin. Untuk melakukannya, gunakan Permissions API.

Membuat pengguna menjadi bagian dari alur ini sangat penting, karena kita kini dapat mengharapkan mereka mengontrol penghapusan. Jika perangkat mereka mengalami tekanan penyimpanan, dan menghapus data yang tidak penting tidak menyelesaikannya, pengguna dapat menilai item mana yang akan disimpan dan dihapus.

Agar hal ini berfungsi, sistem operasi harus memperlakukan origin "tahan lama" sebagai setara dengan aplikasi khusus platform dalam perincian penggunaan penyimpanan, bukan melaporkan browser sebagai satu item.

Menayangkan saran

Tidak peduli seberapa banyak caching yang Anda lakukan, pekerja layanan hanya menggunakan cache saat Anda memberi tahu kapan dan bagaimana. Berikut beberapa pola untuk menangani permintaan:

Hanya cache

Khusus cache.

Ideal untuk: apa pun yang Anda anggap statis untuk "versi" tertentu dari situs Anda. Anda seharusnya telah menyimpannya dalam peristiwa penginstalan, sehingga Anda dapat mengandalkannya.

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

…meskipun Anda tidak sering perlu menangani kasus ini secara khusus, Cache, yang melakukan penggantian ke jaringan akan menanganinya.

Khusus jaringan

Khusus jaringan.

Ideal untuk: hal-hal yang tidak memiliki padanan offline, seperti ping analytics, permintaan non-GET.

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

…meskipun Anda tidak sering perlu menangani kasus ini secara khusus, Cache, yang melakukan penggantian ke jaringan akan menanganinya.

Cache, kembali ke jaringan

Cache, kembali ke jaringan.

Ideal untuk: membangun aplikasi offline-first. Dalam kasus seperti itu, berikut cara menangani sebagian besar permintaan. Pola lainnya adalah pengecualian berdasarkan permintaan yang masuk.

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

Hal ini memberi Anda perilaku "khusus cache" untuk item dalam cache dan perilaku "khusus jaringan" untuk item yang tidak di-cache (yang mencakup semua permintaan non-GET, karena tidak dapat di-cache).

Perlombaan cache dan jaringan

Perlombaan cache dan jaringan.

Ideal untuk: aset kecil saat Anda mengejar performa di perangkat dengan akses disk yang lambat.

Dengan beberapa kombinasi hard drive lama, pemindai virus, dan koneksi internet yang lebih cepat, mendapatkan resource dari jaringan bisa lebih cepat daripada mengakses disk. Namun, mengakses jaringan saat pengguna memiliki konten di perangkatnya dapat membuang-buang data, jadi perhatikan hal ini.

// 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)]));
});

Jaringan kembali ke cache

Jaringan kembali ke cache.

Ideal untuk: perbaikan cepat untuk resource yang sering diperbarui, di luar "versi" situs. Misalnya, artikel, avatar, linimasa media sosial, dan papan peringkat game.

Artinya, pengguna online mendapatkan konten terbaru, tetapi pengguna offline mendapatkan versi yang di-cache lebih lama. Jika permintaan jaringan berhasil, kemungkinan besar Anda ingin memperbarui entri cache.

Namun, metode ini memiliki kekurangan. Jika koneksi pengguna terputus-putus atau lambat, mereka harus menunggu hingga jaringan gagal sebelum mendapatkan konten yang sudah ada di perangkat mereka dan dapat diterima dengan sempurna. Hal ini dapat memakan waktu yang sangat lama dan merupakan pengalaman pengguna yang menjengkelkan. Lihat pola berikutnya, Cache lalu jaringan, untuk solusi yang lebih baik.

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

Cache lalu jaringan

Cache lalu jaringan.

Cocok untuk: konten yang sering diperbarui. Misalnya, artikel, linimasa media sosial, dan papan peringkat game.

Hal ini mengharuskan halaman membuat dua permintaan, satu ke cache, dan satu ke jaringan. Idenya adalah menampilkan data yang di-cache terlebih dahulu, lalu memperbarui halaman saat dan jika data jaringan tiba.

Terkadang Anda hanya dapat mengganti data saat ini saat data baru tiba (seperti papan peringkat game), tetapi hal itu dapat mengganggu konten yang lebih besar. Pada dasarnya, jangan "menghilangkan" sesuatu yang mungkin sedang dibaca atau digunakan oleh pengguna.

Twitter menambahkan konten baru di atas konten lama dan menyesuaikan posisi scroll sehingga pengguna tidak terganggu. Hal ini memungkinkan karena Twitter mempertahankan urutan konten yang sebagian besar linear. Saya menyalin pola ini untuk trained-to-thrill agar konten ditampilkan di layar secepat mungkin, sekaligus menampilkan konten terbaru segera setelah tersedia.

Kode di halaman:

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

Kode di pekerja layanan:

Anda harus selalu membuka jaringan dan mengupdate cache saat Anda melakukannya.

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

Di trained-to-thrill, saya mengatasi masalah ini dengan menggunakan XHR, bukan pengambilan data, dan menyalahgunakan header Accept untuk memberi tahu pekerja layanan tempat mendapatkan hasil dari (kode halaman, kode pekerja layanan).

Penggantian umum

Penggantian umum.

Jika Anda gagal menayangkan sesuatu dari cache atau jaringan, berikan penggantian generik.

Ideal untuk: gambar sekunder seperti avatar, permintaan POST yang gagal, dan halaman "Tidak tersedia saat 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.
      }),
  );
});

Item yang Anda gunakan sebagai pengganti kemungkinan adalah dependensi penginstalan.

Jika halaman Anda memposting email, pekerja layanan Anda dapat melakukan penggantian ke penyimpanan email di kotak keluar IndexedDB dan merespons dengan memberi tahu halaman bahwa pengiriman gagal, tetapi data berhasil disimpan.

Pembuatan template sisi pekerja layanan

Pembuatan template sisi pekerja layanan.

Ideal untuk: halaman yang respons servernya tidak dapat di-cache.

Merender halaman di server lebih cepat, tetapi hal itu dapat berarti menyertakan data status yang mungkin tidak masuk akal dalam cache, seperti status login. Jika halaman Anda dikontrol oleh pekerja layanan, Anda dapat memilih untuk meminta data JSON bersama dengan template dan merendernya.

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

Menyatukan semuanya

Anda tidak terbatas pada salah satu metode ini. Sebenarnya, Anda mungkin akan menggunakan banyak di antaranya, bergantung pada URL permintaan. Misalnya, trained-to-thrill menggunakan:

Cukup lihat permintaan dan putuskan apa yang harus dilakukan:

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

Bacaan lebih lanjut

Kredit

Untuk ikon yang indah:

Terima kasih juga kepada Jeff Posnick yang telah menemukan banyak error yang mengganggu sebelum saya menekan "publikasikan".