Buku Resep Offline

Jake Archibald
Jake Archibald

Dengan Service Worker, kami menyerah mencoba mengatasi offline, dan memberi developer komponen bergerak untuk mengatasinya sendiri. Hal ini memberi Anda kontrol atas proses cache dan cara menangani permintaan. Artinya, Anda dapat membuat pola 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 menyimpan resource

Service Worker memungkinkan Anda menangani permintaan secara independen dari penyimpanan dalam cache, jadi saya akan mendemonstrasikannya secara terpisah. Yang pertama, caching, kapan harus dilakukan?

Saat menginstal—sebagai dependensi

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

Service Worker memberi Anda peristiwa install. Anda dapat menggunakannya untuk menyiapkan berbagai item yang harus sudah siap sebelum Anda 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 menggunakannya untuk menyimpan cache aset statis.

Saat menginstal—bukan sebagai dependensi

Saat menginstal - bukan sebagai dependensi.
Saat menginstal - 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 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.

Ideal untuk: pembersihan dan migrasi.

Setelah Service Worker baru diinstal dan versi sebelumnya tidak digunakan, versi baru akan diaktifkan, dan Anda akan 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.
Saat 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.

Ideal untuk: memperbarui resource secara berkala, 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 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 dimasukkan dalam kategori ini.

Gunakan ini jika tersedia versi yang di-cache, namun untuk yang berikutnya, ambil pembaruan.

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

Ini sangat mirip dengan stale-while-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 melakukannya dari halaman dan pengguna akan diminta.

Ideal untuk: konten yang berkaitan dengan notifikasi, seperti pesan chat, artikel berita terbaru, atau email. Selain itu, konten yang jarang berubah yang 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 demikian jika pengguna pada akhirnya berinteraksi dengan notifikasi, jadi membuat konten ini tersedia secara offline adalah penting.

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 jika pengguna tidak memiliki tab yang dibuka untuk situs Anda. Hanya Pekerja Layanan yang diaktifkan. Anda meminta izin untuk melakukannya dari halaman dan pengguna akan ditanyai.

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 akan diberi ruang bebas dalam jumlah tertentu untuk melakukan apa yang diinginkannya. 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 Anda inginkan untuk disimpan, dan game yang tidak 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.

Sangat penting untuk menjadikan pengguna sebagai bagian dari alur ini, karena kami sekarang dapat meminta pengguna mengontrol penghapusan. Jika perangkat mengalami tekanan penyimpanan, dan menghapus data non-esensial tidak dapat mengatasinya, 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 berapa banyak cache yang Anda lakukan, pekerja layanan tidak akan menggunakan cache kecuali jika Anda memberi tahu 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 spesifik, 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, yang akan 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 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 "hanya cache" untuk item yang ada di cache dan perilaku "hanya jaringan" untuk semua item yang tidak di-cache (termasuk 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 memerlukan waktu yang sangat lama dan membuat pengalaman pengguna yang merepotkan. 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.

Ideal 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 dapat mengganti data saat ini saat data baru tiba (misalnya papan peringkat game), tetapi hal itu dapat mengganggu konten yang lebih besar. Pada dasarnya, jangan "hilangkan" 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 terhubung ke jaringan dan memperbarui cache sambil jalan.

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 mengatasinya dengan menggunakan XHR, bukan fetch, dan menyalahgunakan header Accept untuk memberi tahu Pekerja Layanan tempat untuk 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

Pembuatan template 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 terbatas 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