Buku Resep Offline

Jake Archibald
Jake Archibald

Dengan Pekerja Layanan, kami berhenti mencoba menyelesaikan masalah secara offline, dan memberi developer bagian yang bergerak untuk menyelesaikannya sendiri. Hal ini memberi Anda kontrol atas penyimpanan cache dan cara penanganan permintaan. Artinya, Anda bisa membuat pola sendiri. Mari kita lihat beberapa kemungkinan pola secara terpisah, tetapi dalam praktiknya, Anda mungkin akan menggunakan banyak pola tersebut secara bersamaan, bergantung pada URL dan konteks.

Untuk melihat demo praktis dari beberapa pola ini, lihat Trained-to-thrill, dan video ini yang menunjukkan dampak performa.

Mesin cache—kapan menyimpan resource

Pekerja Layanan memungkinkan Anda menangani permintaan secara terpisah dari cache, jadi saya akan mendemonstrasikannya secara terpisah. Pertama, penyimpanan dalam cache, kapan harus dilakukan?

Saat penginstalan—sebagai dependensi

Saat menginstal - sebagai dependensi.
Saat penginstalan - sebagai dependensi.

Service Worker memberi Anda peristiwa install. Anda dapat menggunakannya untuk menyiapkan berbagai hal, hal yang harus siap sebelum Anda menangani peristiwa lainnya. Saat ini terjadi, Service Worker versi sebelumnya masih berjalan dan menayangkan halaman, sehingga hal-hal yang Anda lakukan di sini tidak boleh mengganggunya.

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

Hal-hal berikut akan membuat situs Anda sepenuhnya tidak berfungsi jika gagal diambil, hal-hal yang akan dijadikan bagian dari download awal oleh aplikasi khusus platform yang setara.

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 menggunakan promise untuk menentukan durasi dan keberhasilan penginstalan. Jika promise ditolak, penginstalan akan dianggap gagal dan Service Worker ini akan diabaikan (jika versi lama masih berjalan, versi tersebut akan dibiarkan). caches.open() dan cache.addAll() menampilkan promise. Jika ada resource yang gagal diambil, panggilan cache.addAll() akan ditolak.

Di training-to-thrill, saya menggunakan ini untuk menyimpan cache aset statis.

Saat menginstal—bukan sebagai dependensi

Saat penginstalan - bukan sebagai dependensi.
Saat penginstalan - bukan sebagai dependensi.

Hal ini serupa dengan di atas, tetapi tidak akan menunda penyelesaian penginstalan dan tidak akan menyebabkan penginstalan gagal jika caching gagal.

Ideal untuk: resource lebih besar yang tidak langsung diperlukan, seperti aset untuk level game selanjutnya.

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 di atas tidak meneruskan promise cache.addAll untuk level 11–20 kembali ke event.waitUntil. Jadi, meskipun gagal, game akan tetap tersedia secara offline. Tentu saja, Anda harus menangani kemungkinan tidak adanya level tersebut dan mencoba lagi dalam cache jika tidak ada level tersebut.

Service Worker dapat dimatikan saat level 11–20 didownload karena sudah selesai menangani peristiwa, yang berarti tidak akan disimpan dalam cache. Ke depannya, Web Periodic Background Synchronization API akan menangani kasus seperti ini, dan download yang lebih besar seperti film. API tersebut saat ini hanya didukung di fork Chromium.

Saat pengaktifan

Saat pengaktifan.
Saat diaktifkan.

Ideal untuk: pembersihan dan migrasi.

Setelah Service Worker baru diinstal dan versi sebelumnya tidak digunakan, Service Worker baru akan diaktifkan, dan Anda akan mendapatkan peristiwa activate. Karena versi lama sudah tidak diperlukan, inilah saat 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 lain seperti fetch akan dimasukkan ke dalam antrean, sehingga aktivasi yang lama berpotensi memblokir pemuatan halaman. Jaga aktivasi Anda seramping mungkin, dan hanya gunakan untuk hal-hal yang tidak bisa Anda lakukan saat versi lama aktif.

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

Tentang interaksi pengguna

Terhadap interaksi pengguna.
Tentang interaksi pengguna.

Ideal untuk: ketika seluruh situs tidak dapat diakses secara offline, dan Anda memilih untuk mengizinkan pengguna memilih konten yang ingin tersedia secara offline. Mis. video tentang YouTube, artikel di Wikipedia, atau galeri khusus di Flickr.

Berikan tombol "Baca nanti" atau "Simpan untuk offline". Saat diklik, ambil yang Anda butuhkan dari jaringan dan munculkan di 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 serta pekerja layanan, yang berarti Anda dapat menambahkan ke cache langsung dari halaman.

Di respons jaringan

Di respons jaringan.
Di respons jaringan.

Ideal untuk: referensi yang sering diperbarui, seperti kotak masuk pengguna atau konten artikel. Juga berguna untuk konten tidak penting seperti avatar, tetapi diperlukan kehati-hatian.

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

Jika Anda melakukan hal ini untuk berbagai URL, seperti avatar, Anda harus berhati-hati agar tidak menggelembungkan penyimpanan origin. Jika pengguna perlu memulihkan kapasitas disk, Anda tidak ingin menjadi kandidat utama. Pastikan Anda membuang item dalam cache yang tidak diperlukan lagi.

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

Agar penggunaan memori efisien, Anda hanya dapat membaca isi respons/permintaan satu kali. Kode di atas menggunakan .clone() untuk membuat salinan tambahan yang dapat dibaca secara terpisah.

Saat trainer-to-thrill menggunakan ini untuk menyimpan gambar Flickr dalam cache.

Tidak berlaku saat divalidasi ulang

Tidak berlaku saat divalidasi ulang.
Tidak berlaku saat validasi ulang.

Ideal untuk: sering mengupdate resource jika versi terbaru tidak terlalu penting. Avatar dapat termasuk dalam kategori ini.

Gunakan versi yang di-cache, tetapi ambil update untuk lain waktu.

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-temporary-revalidate HTTP.

Di pesan push

Di pesan push.
Di pesan push.

Push API adalah fitur lain yang dibuat selain Service Worker. Hal ini memungkinkan Service Worker diaktifkan sebagai respons terhadap pesan dari layanan pesan OS. Hal ini terjadi meskipun pengguna tidak memiliki tab yang terbuka untuk situs Anda. Hanya Pekerja Layanan yang diaktifkan. Anda akan meminta izin untuk melakukannya dari halaman dan pengguna akan diminta.

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

Hasil akhir yang umum adalah notifikasi yang, saat diketuk, akan membuka/memfokuskan halaman yang relevan. Namun, pembaruan cache sebelum hal ini terjadi extremely penting. Pengguna jelas online pada saat menerima pesan push, tetapi mungkin tidak demikian ketika akhirnya berinteraksi dengan notifikasi, jadi membuat konten ini tersedia secara offline adalah hal yang penting.

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

Saat sinkronisasi latar belakang

Saat sinkronisasi latar belakang.
Saat sinkronisasi latar belakang.

Sinkronisasi latar belakang adalah fitur lain yang dibuat di atas Pekerja Layanan. Fitur ini memungkinkan Anda meminta sinkronisasi data latar belakang sebagai satu kali atau pada interval (yang sangat heuristik). Hal ini terjadi meskipun pengguna tidak memiliki tab yang terbuka ke situs Anda. Hanya Service Worker yang diaktifkan. Anda meminta izin untuk melakukannya dari halaman dan pengguna akan diminta untuk melakukannya.

Ideal untuk: update yang tidak mendesak, terutama update yang terjadi secara rutin sehingga pesan push per update 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

Origin Anda diberi ruang kosong dalam jumlah tertentu untuk melakukan apa yang diinginkannya. Ruang kosong tersebut digunakan bersama oleh semua penyimpanan asal: (local) Storage, IndexedDB, File System Access, dan tentu saja Cache.

Jumlah yang Anda dapatkan tidak ditentukan. Jumlah ini akan berbeda bergantung pada perangkat dan kondisi penyimpanan. Anda dapat mengetahui jumlah yang Anda peroleh melalui:

navigator.storageQuota.queryInfo('temporary').then(function (info) {
  console.log(info.quota);
  // Result: <quota in bytes>
  console.log(info.usage);
  // Result: <used data in bytes>
});

Namun, seperti semua penyimpanan browser, browser bebas membuang data Anda jika perangkat mengalami tekanan penyimpanan. Sayangnya, browser tidak dapat membedakan antara film yang ingin Anda pertahankan dengan segala biaya, dan game yang tidak terlalu penting bagi Anda.

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.

Penting untuk menjadikan pengguna sebagai bagian dari alur ini, karena sekarang kita dapat mengharapkan pengguna dapat mengontrol penghapusan. Jika perangkat mereka mengalami tekanan penyimpanan, dan menghapus data yang tidak penting tidak menyelesaikan masalah, pengguna dapat menilai item mana yang akan disimpan dan dihapus.

Agar berfungsi, sistem operasi harus memperlakukan origin yang "tahan lama" sebagai setara dengan aplikasi khusus platform dalam pengelompokan penggunaan penyimpanannya, bukan melaporkan browser sebagai item tunggal.

Saran Penayangan—merespons permintaan

Tidak masalah berapa banyak penyimpanan cache yang Anda lakukan, pekerja layanan tidak akan menggunakan cache kecuali jika Anda memberitahukan kapan dan bagaimana caranya. Berikut adalah beberapa pola untuk menangani permintaan:

Khusus cache

Hanya cache.
Hanya cache.

Ideal untuk: apa pun yang Anda anggap statis untuk "versi" tertentu situs Anda. Anda harus menyimpannya dalam cache saat peristiwa instal, 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 menangani kasus ini secara khusus, Cache, fallback ke jaringan akan mencakupnya.

Khusus jaringan

Khusus jaringan.
Hanya jaringan.

Ideal untuk: hal-hal yang tidak memiliki padanan offline, seperti ping analisis, permintaan non-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
});

...meskipun Anda tidak sering menangani kasus ini secara khusus, Cache, fallback ke jaringan akan mencakupnya.

Cache, fallback ke jaringan

{i>Cache<i}, melakukan fallback ke jaringan.
Cache, melakukan fallback ke jaringan.

Ideal untuk: membangun offline-first. Dalam kasus tersebut, beginilah cara Anda akan menangani sebagian besar permintaan. Pola lainnya akan menjadi pengecualian berdasarkan permintaan masuk.

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

Ini akan memberi Anda perilaku "hanya cache" untuk item yang ada di cache dan perilaku "khusus jaringan" untuk apa pun yang tidak di-cache (yang mencakup semua permintaan non-GET, karena permintaan tersebut tidak dapat di-cache).

Ras cache dan jaringan

Cache dan race jaringan.
Perlombaan cache dan jaringan.

Ideal untuk: aset kecil yang performanya 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 membuka disk. Namun, masuk ke jaringan ketika pengguna memiliki konten di perangkat mereka dapat menjadi pemborosan data, jadi perlu diingat hal ini.

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

Jaringan fallback ke cache

Jaringan yang kembali ke {i>cache<i}.
Penggantian jaringan kembali ke cache.

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

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

Namun, metode ini memiliki kekurangan. Jika koneksinya terputus-putus atau lambat, pengguna harus menunggu sampai jaringan gagal sebelum mendapatkan konten yang dapat diterima di perangkat mereka. 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

{i>Cache<i}, lalu jaringan.
Cache, lalu jaringan.

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

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/jika data jaringan tiba.

Terkadang Anda dapat langsung mengganti data saat ini saat data baru tiba (mis. papan peringkat game), tetapi hal tersebut dapat mengganggu konten yang lebih besar. Pada dasarnya, jangan "menghilangkan" sesuatu yang mungkin sedang dibaca atau berinteraksi dengan pengguna.

Twitter menambahkan konten baru di atas konten lama dan menyesuaikan posisi scroll sehingga pengguna tidak terganggu. Hal ini memungkinkan karena Twitter sebagian besar mempertahankan urutan yang sebagian besar bersifat linier ke konten. Saya menyalin pola ini untuk train-to-thrill agar bisa menampilkan konten di layar secepat mungkin, sekaligus menampilkan konten terbaru segera setelah konten tersebut masuk.

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 Service Worker:

Anda harus selalu masuk ke jaringan dan memperbarui cache selama Anda berjalan.

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 train-to-thrill saya mengatasi hal ini dengan menggunakan XHR, bukan pengambilan, dan menyalahgunakan header Accept untuk memberi tahu Service Worker tempat mendapatkan hasil (kode halaman, kode Service Worker).

Penggantian umum

Penggantian umum.
Penggantian umum.

Jika gagal menayangkan sesuatu dari cache dan/atau jaringan, Anda mungkin perlu memberikan fallback 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 menjadi tujuan penggantian kemungkinan besar adalah dependensi penginstalan.

Jika halaman Anda memposting email, pekerja layanan Anda dapat melakukan fallback untuk menyimpan email di 'outbox' IndexedDB dan merespons dengan memberi tahu halaman bahwa pengiriman gagal tetapi data berhasil dipertahankan.

Template sisi pekerja layanan

Template sisi ServiceWorker.
Pembuatan template sisi ServiceWorker.

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

Merender halaman di server akan mempercepat proses, tetapi itu dapat berarti menyertakan data status yang mungkin tidak masuk akal dalam cache, mis. "Login sebagai...". Jika halaman dikontrol oleh pekerja layanan, Anda dapat memilih untuk meminta data JSON beserta 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

Anda tidak dibatasi pada salah satu metode ini. Bahkan, Anda mungkin akan menggunakan banyak di antaranya, bergantung pada URL permintaan. Misalnya, train-to-thrill menggunakan:

Cukup lihat permintaan tersebut 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);
    }),
  );
});

...begitulah gambarannya.

Kredit

...untuk ikon-ikon yang menarik:

Dan terima kasih kepada Jeff Posnick karena menemukan banyak error sebelum saya mengklik "publish".

Bacaan lebih lanjut