Service Worker ile, sorunu çevrimdışı çözme denemesinden vazgeçtik ve geliştiricilere sorunu kendileri çözmeleri için hareketli parçaları verdik. Bu sayede, önbelleğe alma ve isteklerin nasıl işlendiği üzerinde kontrol sahibi olabilirsiniz. Yani kendi desenlerinizi oluşturabilirsiniz. Olası birkaç kalıba tek tek göz atalım. Ancak pratikte, URL'ye ve bağlama bağlı olarak bunların çoğunu birlikte kullanmanız muhtemeldir.
Bu kalıplardan bazılarının çalışan bir demosunu görmek için Heyecan verici bir deneyim için eğitilen makalesine ve performans üzerindeki etkisini gösteren bu videoya göz atın.
Önbelleğe alma makinesi: Kaynakları ne zaman depolayacağınız
Hizmet Çalışanı, istekleri önbelleğe alma işleminden bağımsız olarak işlemenize olanak tanır. Bu nedenle, bunları ayrı ayrı göstereceğim. Öncelikle, önbelleğe alma işlemi ne zaman yapılmalıdır?
Yükleme sırasında (bağımlılık olarak)
Service Worker size bir install
etkinliği verir. Diğer etkinlikleri işlemeden önce hazır olması gereken öğeleri hazırlamak için bu özelliği kullanabilirsiniz. Bu işlem sırasında Hizmet İşleyicinizin önceki sürümleri çalışmaya ve sayfa yayınlamaya devam eder. Bu nedenle, burada yaptığınız işlemler bu durumu etkilememelidir.
Kullanım alanları: CSS, resimler, yazı tipleri, JS, şablonlar... temel olarak sitenizin "sürümüne" göre statik olarak kabul edebileceğiniz her şey.
Bunlar, getirilemezse sitenizin tamamen çalışmasını engelleyecek ve platforma özel eşdeğer bir uygulamanın ilk indirme işleminin bir parçası olacak öğelerdir.
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
, yüklemenin uzunluğunu ve başarısını tanımlamak için bir söz alır. Sözleşme reddedilirse yükleme başarısız kabul edilir ve bu Hizmet Çalışanı terk edilir (çalışır durumdaki eski bir sürüm olduğunda ise bu sürüm olduğu gibi bırakılır). caches.open()
ve cache.addAll()
iade taahhütleri.
Kaynaklardan herhangi biri getirilemezse cache.addAll()
çağrısı reddedilir.
trained-to-thrill kanalında, statik öğeleri önbelleğe almak için bu yöntemi kullanıyorum.
Bağımlılık olarak değil, yükleme sırasında
Bu, yukarıdakine benzer ancak yüklemenin tamamlanmasını geciktirmez ve önbelleğe alma başarısız olursa yüklemenin başarısız olmasına neden olmaz.
Kullanım alanı: Bir oyunun sonraki seviyelerine ait öğeler gibi hemen ihtiyaç duyulmayan daha büyük kaynaklar.
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
();
}),
);
});
Yukarıdaki örnekte, 11-20 arasındaki seviyeler için cache.addAll
vaadi event.waitUntil
'a iletilmiyor. Bu nedenle, oyun başarısız olsa bile çevrimdışı olarak oynanmaya devam eder. Elbette bu düzeylerin bulunmayabileceği ihtimaline karşı hazırlıklı olmanız ve eksiklerse bunları önbelleğe almaya yeniden çalışmanız gerekir.
11-20 arasındaki seviyeler indirilirken Service Worker, etkinlikleri işlemeye son verdiği için sonlandırılabilir. Bu durumda, seviyeler önbelleğe alınmaz. Gelecekte Web Periodic Background Synchronization API, bu tür durumların yanı sıra filmler gibi daha büyük indirme işlemlerini de yönetecek. Bu API şu anda yalnızca Chromium çatallarında desteklenmektedir.
Etkinleştirildiğinde
Kullanım alanları: Temizlik ve taşıma.
Yeni bir Hizmet Çalışanı yüklendikten ve önceki sürüm kullanılmadığında yeni sürüm etkinleştirilir ve bir activate
etkinliği alırsınız. Eski sürüm kullanımdan kaldırıldığı için IndexedDB'de şema taşıma işlemlerini gerçekleştirmek ve kullanılmayan önbellekleri silmek için iyi bir zaman.
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);
}),
);
}),
);
});
Etkinleştirme sırasında fetch
gibi diğer etkinlikler bir sıraya alınır. Bu nedenle, uzun bir etkinleştirme sayfa yüklemelerini engelleyebilir. Etkinleştirmenizi mümkün olduğunca basit tutun ve yalnızca eski sürüm etkinken yapamadığınız işlemler için kullanın.
trained-to-thrill kanalında bu özelliği eski önbellekleri kaldırmak için kullanıyorum.
Kullanıcı etkileşimi üzerinde
Kullanım alanı: Sitenin tamamı çevrimdışı alınamadığında ve kullanıcının çevrimdışı kullanmak istediği içeriği seçmesine izin verdiğinizde. Örneğin, YouTube gibi bir platformdaki bir video, Wikipedia'daki bir makale veya Flickr'daki belirli bir galeri.
Kullanıcıya "Daha sonra oku" veya "İnternete bağlı değilken okumak için kaydet" düğmesi sunun. Tıklandığında, ağdan ihtiyacınız olan bilgileri getirip önbelleğe yerleştirin.
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);
});
});
});
Önbellekler API'si, hem sayfalarda hem de hizmet çalışanlarında kullanılabilir. Yani önbelleğe doğrudan sayfadan ekleme yapabilirsiniz.
Ağ yanıtı hakkında
Kullanım alanı: Kullanıcının gelen kutusu veya makale içerikleri gibi sık güncellenen kaynaklar. Avatarlar gibi zaruri olmayan içerikler için de kullanışlıdır ancak dikkatli olunmalıdır.
Bir istek önbellekte hiçbir şeyle eşleşmezse ağdan alın, sayfaya gönderin ve aynı anda önbelleğe ekleyin.
Bunu avatarlar gibi bir dizi URL için yaparsanız kaynağınızın depolama alanını aşırı yüklememeye dikkat etmeniz gerekir. Kullanıcının disk alanında yer açması gerekiyorsa birincil aday olmak istemezsiniz. Önbellekte artık ihtiyacınız olmayan öğeleri kaldırdığınızdan emin olun.
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;
})
);
});
}),
);
});
Verimli bir bellek kullanımı sağlamak için bir yanıtın/isteğin gövdesini yalnızca bir kez okuyabilirsiniz. Yukarıdaki kod, ayrı olarak okunabilen ek kopyalar oluşturmak için .clone()
kullanır.
trained-to-thrill kanalında, Flickr resimlerini önbelleğe almak için bu yöntemi kullanıyorum.
Stale-while-revalidate
Kullanım alanı: En son sürüme sahip olmanın gerekli olmadığı, sık sık güncellenen kaynaklar. Avatarlar bu kategoriye girebilir.
Önbelleğe alınmış bir sürüm varsa bunu kullanın ancak bir sonraki sefer için güncelleme alın.
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;
});
}),
);
});
Bu, HTTP'nin stale-while-revalidate özelliğine çok benzer.
Push mesajında
Push API, Service Worker'ın üzerine inşa edilmiş başka bir özelliktir. Bu sayede Hizmet İşleyici, işletim sisteminin mesajlaşma hizmetinden gelen bir mesaja yanıt olarak uyandırılabilir. Bu durum, kullanıcının sitenizde açık bir sekmesi olmasa bile gerçekleşir. Yalnızca hizmet çalışanı uyandırılır. Bir sayfadan bunu yapmak için izin istediğinizde kullanıcıya istem gösterilir.
Kullanım alanı: Bildirimle ilgili içerikler (ör. sohbet mesajı, son dakika haberi veya e-posta). Ayrıca, yapılacaklar listesi güncellemesi veya takvim değişikliği gibi anında senkronizasyondan yararlanan içeriklerin sık sık değiştirilmesi de bu kapsamdadır.
Sık karşılaşılan bir sonuç, dokunulduğunda ilgili bir sayfayı açan/odağı bu sayfaya alan bir bildirimdir. Bu bildirim için önbellekleri bu işlemden önce güncellemek son derece önemlidir. Kullanıcı, push mesajını aldığında internete bağlıdır ancak bildirimle etkileşime geçtiğinde internete bağlı olmayabilir. Bu nedenle, bu içeriğin çevrimdışı olarak sunulması önemlidir.
Bu kod, bildirim göstermeden önce önbellekleri günceller:
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/');
}
});
Arka plan senkronizasyonu açık
Arka planda senkronizasyon, Service Worker'ın üzerine inşa edilmiş başka bir özelliktir. Arka plan veri senkronizasyonunu tek seferlik veya belirli bir aralıklarla (son derece sezgisel) istemenize olanak tanır. Bu durum, kullanıcının sitenizde açık bir sekmesi olmasa bile gerçekleşir. Yalnızca hizmet çalışanı uyandırılır. Bir sayfadan bunu yapmak için izin istediğinizde kullanıcıdan izin istenir.
Kullanım alanları: Acil olmayan güncellemeler (özellikle de sosyal medya zaman çizelgeleri veya haber makaleleri gibi güncelleme başına bir push mesajının kullanıcılar için çok sık olacağı kadar düzenli olarak gerçekleşenler).
self.addEventListener('sync', function (event) {
if (event.id == 'update-leaderboard') {
event.waitUntil(
caches.open('mygame-dynamic').then(function (cache) {
return cache.add('/leaderboard.json');
}),
);
}
});
Önbellek kalıcılığı
Kaynağınıza, istediği gibi kullanabileceği belirli bir miktarda boş alan verilir. Bu boş alan, tüm kaynak depolama alanları arasında paylaşılır: (yerel) Depolama, IndexedDB, Dosya Sistemi Erişimi ve tabii ki Önbellekler.
Aldığınız tutar belirtilmez. Bu süre, cihaza ve depolama koşullarına bağlı olarak değişir. Ne kadar krediniz olduğunu şu yöntemlerle öğrenebilirsiniz:
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.`);
}
Ancak tüm tarayıcı depolama alanlarında olduğu gibi, cihaz depolama alanı sıkıntısı yaşarsa tarayıcı verilerinizi silebilir. Maalesef tarayıcı, ne pahasına olursa olsun saklamak istediğiniz filmler ile çok önemsemediğiniz oyunlar arasında ayrım yapamaz.
Bu sorunun üstesinden gelmek için StorageManager arayüzünü kullanın:
// 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.
});
Elbette kullanıcının izin vermesi gerekir. Bunun için Permissions API'yi kullanın.
Artık kullanıcıların silme işlemini kontrol etmesini bekleyebileceğimiz için kullanıcıyı bu akışa dahil etmek önemlidir. Cihazın depolama alanı dolduğunda ve gerekli olmayan verileri temizlemek sorunu çözmüyorsa kullanıcı hangi öğeleri saklayacağını ve hangilerini kaldıracağını kendisi belirler.
Bunun işe yaraması için işletim sistemlerinin, tarayıcıyı tek bir öğe olarak raporlamak yerine depolama alanı dökümlerinde "dayanıklı" kaynaklarını platforma özel uygulamalara eşdeğer olarak ele alması gerekir.
Yayınlama Önerileri: İsteklere yanıt verme
Ne kadar önbelleğe alma işlemi yaparsanız yapın, hizmet çalışanı ne zaman ve nasıl kullanılacağını söylemediğiniz sürece önbelleği kullanmaz. İstekleri işleme konusunda birkaç kalıp aşağıda verilmiştir:
Yalnızca önbelleğe alma
Kullanım alanı: Sitenizin belirli bir "sürümüne" göre statik olarak değerlendirdiğiniz her şey. Bunları yükleme etkinliğinde önbelleğe almanız gerekir. Böylece, bu bilgilerin orada bulunduğundan emin olabilirsiniz.
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));
});
Genellikle bu durumu özel olarak ele almanız gerekmez. Ağ üzerinden yedekleme için önbelleğe alma bu durumu kapsar.
Yalnızca ağ
Kullanım alanı: Analytics ping'leri, GET olmayan istekler gibi çevrimdışı eşdeğeri olmayan öğeler.
self.addEventListener('fetch', function (event) {
event.respondWith(fetch(event.request));
// or simply don't call event.respondWith, which
// will result in default browser behavior
});
Genellikle bu durumu özel olarak ele almanız gerekmez. Ağ üzerinden yedekleme için önbelleğe alma bu durumu kapsar.
Önbelleğe alma, ağa geri dönme
Kullanım alanı: Çevrimdışı öncelikli modeller oluşturma. Bu gibi durumlarda, isteklerin çoğunu bu şekilde ele alırsınız. Diğer kalıplar, gelen isteğe bağlı istisnalardır.
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.match(event.request).then(function (response) {
return response || fetch(event.request);
}),
);
});
Bu, önbelleğe alınan öğeler için "yalnızca önbelleğe alma" davranışını ve önbelleğe alınmayan öğeler için "yalnızca ağ" davranışını (önbelleğe alınamayacakları için GET olmayan tüm istekler dahil) sağlar.
Önbellek ve ağ yarışı
Kullanım alanı: Yavaş disk erişimi olan cihazlarda performans elde etmek istediğiniz küçük öğeler.
Eski sabit diskler, virüs tarayıcıları ve daha hızlı internet bağlantılarının bazı kombinasyonlarında, ağdan kaynak almak diske gitmekten daha hızlı olabilir. Ancak kullanıcının cihazında içerik bulunduğunda ağa gitmek veri israfına neden olabilir.
// 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)]));
});
Ağ, önbelleğe geri dönüyor
Kullanım alanı: Sitenin "sürümü" dışında sık güncellenen kaynaklar için hızlı bir çözüm. Örneğin, makaleler, avatarlar, sosyal medya zaman çizelgeleri ve oyun skor tabloları.
Bu sayede, online kullanıcılara en güncel içeriği sunarken çevrimdışı kullanıcılara daha eski bir önbelleğe alınmış sürüm sunarsınız. Ağ isteği başarılı olursa büyük olasılıkla önbelleğe alma girişini güncellemek istersiniz.
Ancak bu yöntemin kusurları vardır. Kullanıcının bağlantısı aralıklarla kesiliyorsa veya yavaşsa cihazında mükemmel şekilde kabul edilebilir içerikleri alabilmek için ağın bağlantısını kaybetmesini beklemesi gerekir. Bu işlem çok uzun sürebilir ve can sıkıcı bir kullanıcı deneyimi sunar. Daha iyi bir çözüm için sonraki kalıba (Önce önbelleğe alma, sonra ağ) bakın.
self.addEventListener('fetch', function (event) {
event.respondWith(
fetch(event.request).catch(function () {
return caches.match(event.request);
}),
);
});
Önbelleğe alma ve ardından ağ
Kullanım alanı: Sık güncellenen içerikler. Örneğin, makaleler, sosyal medya zaman çizelgeleri ve oyun skor tabloları.
Bunun için sayfanın biri önbelleğe, diğeri ağa olmak üzere iki istek yapması gerekir. Amaç, önce önbelleğe alınmış verileri göstermek, ardından ağ verileri geldiğinde/gelirse sayfayı güncellemektir.
Bazen yeni veriler geldiğinde mevcut verileri değiştirebilirsiniz (ör.oyun skor tablosu). Ancak bu, daha büyük içerik parçalarında soruna yol açabilir. Kullanıcının okuyabileceği veya etkileşimde bulunabileceği bir öğeyi "kaybetmeyin".
Twitter, yeni içeriği eski içeriğin üzerine ekler ve kullanıcının deneyimi kesintiye uğramayacak şekilde kaydırma konumunu ayarlar. Bu, Twitter'ın içerikleri çoğunlukla doğrusal bir düzende tutmasından kaynaklanır. Heyecan verici için bu kalıbı kopyaladım. Böylece, içeriği olabildiğince hızlı bir şekilde ekrana getirirken güncel içeriği de gelir gelmez gösterebilirim.
Sayfadaki kod:
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);
Hizmet Çalışanı'ndaki kod:
Her zaman ağa gidip önbelleği güncellemeniz gerekir.
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.open('mysite-dynamic').then(function (cache) {
return fetch(event.request).then(function (response) {
cache.put(event.request, response.clone());
return response;
});
}),
);
});
trained-to-thrill adlı makalede, fetch yerine XHR kullanarak ve Hizmet Çalışanı'na sonucu nereden alacağını (sayfa kodu, Hizmet Çalışanı kodu) bildirmek için Accept başlığını kötüye kullanarak bu sorunun üstesinden geldim.
Genel yedek
Önbellekten ve/veya ağdan bir şey yayınlayamazsanız genel bir yedek sunabilirsiniz.
Kullanım alanları: Avatarlar, başarısız POST istekleri ve "Çevrimdışıyken kullanılamaz" sayfası gibi ikincil görüntüler.
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.
}),
);
});
Yedek olarak kullandığınız öğe büyük olasılıkla bir yükleme bağımlılığıdır.
Sayfanız bir e-posta gönderiyorsa servis çalışanınız, e-postayı IndexedDB "giden kutusu"nda depolamaya geri dönebilir ve gönderimin başarısız olduğunu ancak verilerin başarıyla korunduğunu sayfaya bildirerek yanıt verebilir.
Hizmet çalışanı tarafında şablon oluşturma
Kullanım alanı: Sunucu yanıtlarının önbelleğe alınamadığı sayfalar.
Sayfaları sunucuda oluşturmak işlemleri hızlandırır ancak bu, önbellekte anlamlı olmayabilecek durum verilerini (ör. "… olarak giriş yaptı") dahil etmek anlamına gelebilir. Sayfanız bir hizmet çalışanı tarafından kontrol ediliyorsa şablonla birlikte JSON verileri isteyebilir ve bunun yerine bunları oluşturabilirsiniz.
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',
},
});
}),
);
});
Tümünü bir araya getirme
Bu yöntemlerden biriyle sınırlı değilsiniz. Hatta istek URL'sine bağlı olarak bunların çoğunu kullanmanız muhtemeldir. Örneğin, heyecan verici özelliğinde şunlar kullanılır:
- Statik kullanıcı arayüzü ve davranış için yükleme sırasında önbelleğe alma
- Flickr resimleri ve verileri için ağ yanıtında önbelleğe alma
- Çoğu istek için önbellekten getirme, ağa geri dönme
- Flickr arama sonuçları için önce önbellekten, sonra ağdan getirme
İsteği inceleyip ne yapacağınıza karar vermeniz yeterlidir:
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);
}),
);
});
…anladınız.
Kredi
…sevimli simgeler için:
- buzzyrobot tarafından Code
- Scott Lewis'in Calendar (Takvim)
- Network by Ben Rizzo
- Thomas Le Bas tarafından SD
- iconsmind.com tarafından oluşturulan CPU
- trasnik tarafından Çöp Kutusu
- @daosme tarafından Bildirim
- Mister Pixel tarafından Layout
- P.J. Onori tarafından Cloud
"Yayınla" düğmesine basmadan önce birçok hatayı yakaladığı için Jeff Posnick'e de teşekkürler.
Daha fazla bilgi
- Hizmet İşleyiciler: Giriş
- Hizmet Çalışanı hazır mı?: Ana tarayıcılarda uygulama durumunu takip edin
- JavaScript Promise'leri: Giriş - Promise'ler rehberi