Buku Resep Offline

Jake Archibald
Jake Archibald

Dengan Service Worker, kami menyerah mencoba mengatasi offline, dan memberi developer komponen bergerak untuk mengatasinya sendiri. Layanan ini memberi Anda kontrol atas penyimpanan cache dan cara penanganan permintaan. Itu berarti Anda bisa membuat pola Anda sendiri. Mari kita lihat beberapa kemungkinan pola secara terpisah, tetapi pada praktiknya Anda kemungkinan akan menggunakan banyak pola tersebut sekaligus, bergantung pada URL dan konteks.

Untuk demo beberapa pola ini yang dapat digunakan, lihat Trained-to-thrill, dan video ini yang menampilkan dampak performa.

Mesin cache—kapan harus menyimpan resource

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

Saat diinstal—sebagai dependensi

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

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

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

Inilah item yang akan membuat situs Anda sama sekali tidak berfungsi jika gagal diambil, item yang dijadikan 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 menggunakan promise untuk menentukan durasi dan keberhasilan penginstalan. Jika promise ditolak, penginstalan dianggap gagal dan Pekerja Layanan ini akan ditinggalkan (jika versi lama berjalan, maka akan dibiarkan). caches.open() dan cache.addAll() menampilkan promise. Jika salah satu resource gagal diambil, panggilan cache.addAll() akan ditolak.

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

Saat diinstal—bukan sebagai dependensi

Saat menginstal - bukan sebagai dependensi.
Saat menginstal - bukan sebagai dependensi.

Hal ini serupa dengan yang di atas, tetapi tidak akan menunda penyelesaian penginstalan dan tidak akan menyebabkan penginstalan gagal jika penyimpanan gagal ke cache.

Ideal untuk: resource yang lebih besar yang tidak langsung diperlukan, 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 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 mengantisipasi kemungkinan tidak adanya level tersebut dan mencoba lagi menyimpannya dalam cache jika tidak ada.

Pekerja Layanan dapat dihentikan saat level 11–20 didownload karena telah selesai menangani peristiwa, yang berarti peristiwa tersebut tidak akan disimpan dalam cache. Di masa mendatang, 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 diaktifkan

Saat diaktifkan.
Saat diaktifkan.

Cocok untuk: pembersihan dan migrasi.

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

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

Saat interaksi pengguna

Saat interaksi pengguna.
Pada interaksi pengguna.

Ideal untuk: saat seluruh situs tidak dapat diambil secara offline, dan Anda memilih untuk mengizinkan pengguna memilih konten yang ingin mereka gunakan secara offline. Misalnya, video tentang sesuatu seperti YouTube, artikel di Wikipedia, galeri tertentu di Flickr.

Berikan tombol "Baca nanti" atau "Simpan untuk offline" kepada pengguna. Saat diklik, ambil yang Anda perlukan dari jaringan dan tampilkan 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);
      });
  });
});

caches API tersedia dari halaman serta pekerja layanan, yang berarti Anda dapat menambahkan ke cache langsung dari halaman.

Saat respons jaringan

Saat respons jaringan.
Saat respons jaringan.

Cocok untuk: sering memperbarui fasilitas seperti kotak masuk pengguna, atau konten artikel. Juga berguna untuk konten non-esensial seperti avatar, tetapi perlu hati-hati.

Jika permintaan tidak cocok dengan item di cache, ambil dari jaringan, kirimkan ke halaman, dan tambahkan ke cache pada waktu yang sama.

Jika Anda melakukannya untuk berbagai URL, seperti avatar, Anda harus berhati-hati agar tidak membuat penyimpanan asal Anda menjadi terlalu besar. Jika pengguna perlu menggunakan kembali ruang disk, Anda tidak ingin menjadi kandidat utama. Pastikan Anda menghapus item di 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 yang efisien, Anda hanya dapat membaca isi respons/permintaan satu kali. Kode di atas menggunakan .clone() untuk membuat salinan tambahan yang dapat dibaca secara terpisah.

Di trained-to-thrill, saya menggunakan ini untuk menyimpan cache gambar Flickr.

Stale-while-revalidate

Stale-while-revalidate.
Stale-while-revalidate.

Ideal untuk: memperbarui resource secara berkala jika memiliki versi terbaru tidaklah esensial. Avatar dapat termasuk dalam kategori ini.

Gunakan versi yang di-cache jika ada, 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.

Pada pesan push

Pada pesan push.
Di pesan push.

Push API adalah fitur lain yang dibuat di atas Service Worker. Hal ini memungkinkan Service Worker diaktifkan untuk merespons pesan dari layanan pesan OS. Hal ini terjadi bahkan jika pengguna tidak memiliki tab yang dibuka untuk situs Anda. Hanya pekerja layanan yang diaktifkan. Anda meminta izin untuk melakukan tindakan ini dari halaman dan pengguna akan diminta.

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

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

Kode ini akan 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. Ini memungkinkan Anda meminta sinkronisasi data latar belakang yang hanya terjadi sekali, atau dengan interval (yang sangat heuristik). Hal ini terjadi bahkan saat pengguna tidak membuka tab di situs Anda. Hanya Pekerja Layanan yang diaktifkan. Anda meminta izin untuk melakukannya dari halaman dan pengguna akan dimintai izin untuk melakukannya.

Cocok untuk: update yang tidak mendesak, terutama 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 akan diberi ruang kosong dalam jumlah tertentu untuk melakukan apa yang diinginkan. Ruang kosong tersebut digunakan bersama oleh semua penyimpanan asal: Penyimpanan(lokal), IndexedDB, Akses Sistem File, dan tentu saja Cache.

Jumlah yang Anda dapatkan tidak sesuai spesifikasi. Hal ini akan bervariasi bergantung pada kondisi perangkat dan penyimpanan. Anda dapat mengetahui jumlahnya melalui:

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 halnya semua penyimpanan browser, browser bebas menghapus data Anda jika perangkat mengalami tekanan penyimpanan. Sayangnya, browser tidak dapat membedakan antara film yang ingin Anda pertahankan dengan biaya apa pun, dan game yang tidak Anda minati.

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.

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

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

Saran Penyajian—merespons permintaan

Tidak masalah seberapa banyak cache yang Anda lakukan, pekerja layanan tidak akan menggunakan cache kecuali Anda memberitahukan waktu dan caranya. Berikut adalah beberapa pola untuk menangani permintaan:

Hanya cache

Hanya cache.
Hanya cache.

Ideal untuk: apa pun yang Anda anggap statis untuk "versi" situs tertentu. Anda harus menyimpan cache item tersebut di 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 perlu sering menangani kasus ini secara khusus, Cache, fallback ke jaringan akan membahasnya.

Khusus jaringan

Khusus jaringan.
Khusus 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 perlu sering menangani kasus ini secara spesifik, Cache, fallback ke jaringan akan membahasnya.

Cache, fallback ke jaringan

Cache, fallback ke jaringan.
Cache, beralih kembali ke jaringan.

Ideal untuk: mem-build offline-first. Dalam kasus tersebut, berikut cara 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 memberi Anda perilaku "hanya cache" untuk item yang ada di cache dan perilaku "hanya jaringan" untuk apa pun yang tidak di-cache (yang mencakup semua permintaan non-GET, karena tidak dapat di-cache).

Perlombaan cache dan jaringan

Perlombaan cache dan jaringan.
Persaingan cache dan jaringan.

Ideal untuk: aset kecil jika Anda mengejar performa pada perangkat dengan akses disk lambat.

Dengan beberapa kombinasi hard drive lama, pemindai virus, dan koneksi internet yang lebih cepat, mendapatkan resource dari jaringan bisa lebih cepat daripada masuk ke disk. Namun, masuk ke jaringan saat pengguna memiliki konten di perangkatnya dapat menjadi pemborosan data, jadi ini perlu diingat baik-baik.

// 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 kembali ke cache

Jaringan kembali ke cache.
Jaringan kembali ke cache.

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

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

Namun, metode ini memiliki kelemahan. Jika memiliki koneksi yang terputus-putus atau lambat, pengguna harus menunggu jaringan gagal sebelum mendapatkan konten yang dapat diterima dengan sempurna di perangkatnya. Hal ini dapat memakan waktu yang sangat lama dan merupakan pengalaman pengguna yang membuat frustrasi. Lihat pola berikutnya, Cache then network, 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.
Cache, lalu jaringan.

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

Ini mengharuskan halaman membuat dua permintaan, satu ke cache, dan satu ke jaringan. Dasar pemikirannya adalah menampilkan data yang di-cache terlebih dahulu, lalu memperbarui halaman saat/jika data jaringan telah tiba.

Terkadang Anda bisa saja mengganti data saat ini saat data baru masuk (misalnya, papan peringkat game), tetapi hal ini dapat mengganggu dengan 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 mungkin karena Twitter umumnya mempertahankan urutan yang sebagian besar bersifat linier ke konten. Saya menyalin pola ini untuk trained-to-thrill guna menampilkan konten ke layar secepat mungkin, sekaligus menampilkan konten terbaru segera setelah datanya tiba.

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 masuk ke jaringan dan memperbarui {i>cache<i} selama Anda bekerja.

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 bagian trained-to-thrill, saya mengatasinya dengan menggunakan XHR, bukan pengambilan, dan menyalahgunakan header Accept untuk memberi tahu Pekerja Layanan tempat mendapatkan hasilnya (kode halaman, kode Pekerja Layanan).

Fallback generik

Fallback generik.
Fallback generik.

Jika gagal menayangkan item dari cache dan/atau jaringan, Anda mungkin perlu menyediakan 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 tujuan fallback Anda mungkin berupa dependensi penginstalan.

Jika halaman memposting email, pekerja layanan Anda mungkin melakukan fallback 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

Templating sisi ServiceWorker.
Pembuatan template sisi ServiceWorker.

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

Merender halaman di server akan membuat semuanya menjadi cepat, tetapi hal itu dapat berarti menyertakan data status yang mungkin tidak logis dalam cache, misalnya "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, trained-to-thrill menggunakan:

Cukup lihat permintaan tersebut dan putuskan tindakan 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 menarik:

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

Bacaan lebih lanjut