עם Service Worker, ויתרנו על הניסיון לפתור את הבעיה במצב אופליין, והענקנו למפתחים את החלקים הנעים כדי לפתור אותה בעצמם. היא מאפשרת לכם לשלוט במטמון ובאופן שבו הבקשות מטופלות. כלומר, אתם יכולים ליצור דפוסים משלכם. נבחן כמה דפוסים אפשריים בנפרד, אבל בפועל סביר להניח שתשתמשו בהרבה מהם יחד, בהתאם לכתובת ה-URL ולהקשר.
להדגמה פעילה של חלק מהדפוסים האלה, תוכלו לצפות באימון של ריגושים ובסרטון הזה שמציג את ההשפעה על הביצועים.
מכונת המטמון – מתי צריך לאחסן משאבים
Service Worker מאפשר לטפל בבקשות בנפרד מהאחסון במטמון, ולכן אציג אותם בנפרד. קודם כול, אחסון במטמון – מתי כדאי לעשות זאת?
בהתקנה – כיחסי תלות
קובץ ה-Service Worker יוצר אירוע install
. אפשר להשתמש באירוע הזה כדי להכין דברים שצריך להיות מוכנים לפני שמטפלים באירועים אחרים. בזמן הזה, כל גרסה קודמת של קובץ ה-Service Worker עדיין פועלת ומציגה דפים, ולכן הפעולות שאתם מבצעים כאן לא אמורות להפריע לכך.
אידיאלית ל: CSS, תמונות, גופנים, JS, תבניות... בעיקרון כל מה שנחשב כסטטי ל"גרסה" הזו של האתר.
אלה פריטים שהאתר לא יפעל כלל אם לא יישלחו, ושההורדה הראשונית של אפליקציה מקבילה ספציפית לפלטפורמה תכלול אותם.
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
מקבלת הבטחה שמגדירה את משך ההתקנה ואת הצלחת ההתקנה. אם ה-promise נדחה, ההתקנה נחשבת לכישלון וקובץ ה-Service Worker הזה יוגדר כלא פעיל (אם פועלת גרסה ישנה יותר, היא תישאר ללא שינוי). caches.open()
ו-cache.addAll()
החזרות הבטחות.
אם לא ניתן לאחזר אחד מהמשאבים, הקריאה ל-cache.addAll()
נדחית.
באתר trained-to-thrill, אני משתמש בזה כדי לשמור נכסים סטטיים במטמון.
בעת ההתקנה - לא כתלויות
הפעולה הזו דומה לזו שצוינה למעלה, אבל היא לא תעכב את השלמת ההתקנה ולא תגרום להתקנה להיכשל אם השמירה במטמון נכשלת.
מתאים במיוחד: משאבים גדולים יותר שלא נדרשים באופן מיידי, כמו נכסים לרמות מתקדמות יותר במשחק.
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
();
}),
);
});
בדוגמה שלמעלה, ההתחייבות של cache.addAll
לרמות 11 עד 20 לא מועברת חזרה אל event.waitUntil
, כך שגם אם היא תיכשל, המשחק עדיין יהיה זמין במצב אופליין. כמובן, תצטרכו להיערך לאפשרות שהרמות האלה לא יהיו זמינות, ולנסות שוב לשמור אותן במטמון אם הן חסרות.
יכול להיות ש-Service Worker יופסק במהלך ההורדה של הרמות 11 עד 20 כי הוא סיים לטפל באירועים, כלומר הם לא יישמרו במטמון. בעתיד, השימוש ב-Web Periodic Background Synchronization API יטפל במקרים כאלה ובהורדות גדולות יותר כמו סרטים. ה-API הזה נתמך כרגע רק במזלגות של Chromium.
בזמן ההפעלה
אידיאלית: לניקוי ולהעברה.
אחרי שמתקינים Service Worker חדש ולא משתמשים בגרסה קודמת, הגרסה החדשה תופעל ומקבלים אירוע activate
. עכשיו, כשהגרסה הישנה לא בשימוש, זה הזמן המתאים לטפל בהעברות של סכימות ב-IndexedDB ולמחוק מטמון שלא בשימוש.
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);
}),
);
}),
);
});
במהלך ההפעלה, אירועים אחרים כמו fetch
מועברים לתור, כך שהפעלה ארוכה עלולה לחסום את טעינת הדפים. מומלץ להפעיל את השירות בצורה מצומצמת ככל האפשר, ולהשתמש בו רק לדברים שלא יכולתם לעשות בזמן שהגרסה הישנה הייתה פעילה.
ב-trained-to-thrill אני משתמש בזה כדי להסיר מטמון ישן.
באינטראקציה של משתמש
למי זה אידיאלי: אם אי אפשר להעביר את כל האתר למצב אופליין, ובחרתם לאפשר למשתמשים לבחור את התוכן שהם רוצים שיהיה זמין במצב אופליין. לדוגמה: סרטון ב-YouTube, מאמר ב-Wikipedia או גלריה מסוימת ב-Flickr.
נותנים למשתמש לחצן 'לקריאה מאוחר יותר' או 'שמירה למצב אופליין'. כשמקישים עליו, אוספים מהרשת את מה שצריך ומכניסים אותו למטמון.
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 זמין בדפים וגם ב-Service Workers, כלומר אפשר להוסיף למטמון ישירות מהדף.
תגובת רשת
מתאים במיוחד ל: משאבים שמתעדכנים לעיתים קרובות, כמו תיבת הדואר הנכנס של משתמש או תוכן של מאמרים. אפשר להשתמש בה גם בתוכן לא חיוני כמו דמויות וירטואליות, אבל צריך להיזהר.
אם בקשה לא תואמת לאף פריט במטמון, מקבלים אותה מהרשת, שולחים אותה לדף ומוסיפים אותה למטמון בו-זמנית.
אם תעשו זאת למגוון כתובות URL, כמו דמויות, עליכם להיזהר שלא להעמיס על נפח האחסון של המקור. אם המשתמש צריך לפנות מקום בכונן אתם לא רוצים להיות המועמד הראשי. חשוב להסיר מהמטמון פריטים שאין לכם יותר צורך בהם.
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;
})
);
});
}),
);
});
כדי לנצל את הזיכרון בצורה יעילה, אפשר לקרוא את גוף התגובה/הבקשה רק פעם אחת. הקוד שלמעלה משתמש ב-.clone()
כדי ליצור עותקים נוספים שאפשר לקרוא בנפרד.
באתר trained-to-thrill אני משתמש בזה כדי לשמור בזיכרון את התמונות מ-Flickr.
Stale-while-revalidate
השיטה הזו מתאימה במיוחד: למשאבים שמתעדכנים לעיתים קרובות, שבהם אין צורך בגרסה העדכנית ביותר. קטגוריה זו יכולה לכלול דמויות וירטואליות.
אם יש גרסה ששמורה במטמון, אפשר להשתמש בה, אבל כדאי לאחזר עדכון לפעם הבאה.
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;
});
}),
);
});
האפשרות הזו דומה מאוד לאפשרות stale-while-revalidate ב-HTTP.
בהודעת Push
Push API הוא תכונה נוספת שמבוססת על Service Worker. כך אפשר להעיר את Service Worker בתגובה להודעה משירות ההודעות של מערכת ההפעלה. מצב זה קורה גם אם למשתמש אין כרטיסייה פתוחה באתר. רק קובץ ה-Service Worker מופעל. אתם מבקשים הרשאה לעשות זאת מדף, והמשתמש יקבל בקשה.
מתאים במיוחד ל: תוכן שקשור להתראה, כמו הודעת צ'אט, כתבה חדשותית דחופה או אימייל. כמו כן, תוכן שמשתנה לעיתים רחוקות ואפשר ליהנות מהסנכרון המיידי שלו, כמו עדכון של רשימת משימות או שינוי ביומן.
התוצאה הסופית המשותפת היא התראה שכשמקישים עליה, נפתחת או מתמקדת בדף רלוונטי, אבל חשוב מאוד לעדכן את המטמון לפני שזה קורה. ברור שהמשתמש מחובר לאינטרנט בזמן שהוא מקבל את הודעת ה-push, אבל יכול להיות שהוא לא יהיה מחובר כשיבצע לבסוף אינטראקציה עם ההתראה. לכן חשוב שהתוכן הזה יהיה זמין במצב אופליין.
הקוד הזה מעדכן את המטמון לפני הצגת התראה:
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/');
}
});
ברקע-סנכרון
סנכרון ברקע הוא תכונה נוספת שמבוססת על Service Worker. היא מאפשרת לבקש סנכרון של נתוני רקע באופן חד-פעמי, או במרווח זמן (היוריסטי מאוד). זה קורה גם אם אין למשתמש כרטיסייה פתוחה באתר. רק קובץ ה-Service Worker מופעל. מבקשים הרשאה לעשות זאת מדף, והמשתמש יתבקש לאשר.
מתאים במיוחד לעדכונים לא דחופים, במיוחד כאלה שמתרחשים באופן קבוע כל כך שהודעת ה-push לכל עדכון תהיה תדירה מדי עבור המשתמשים, כמו צירי זמן של רשתות חברתיות או מאמרי חדשות.
self.addEventListener('sync', function (event) {
if (event.id == 'update-leaderboard') {
event.waitUntil(
caches.open('mygame-dynamic').then(function (cache) {
return cache.add('/leaderboard.json');
}),
);
}
});
שמירה במטמון
למקור מוקצה נפח אחסון מסוים שאפשר לעשות בו מה שרוצים. המרחב הפנוי הזה משותף לכל מקורות האחסון: אחסון(מקומי), IndexedDB, גישה למערכת הקבצים וכמובן מטמון.
הסכום שאתם מקבלים לא מוגדר מראש. הוא משתנה בהתאם למכשיר ולתנאי האחסון. אפשר לבדוק כמה נקודות יש לכם:
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.`);
}
עם זאת, כמו כל האחסון בדפדפן, הדפדפן יכול למחוק את הנתונים אם המכשיר נמצא בלחץ אחסון. לצערנו, הדפדפן לא יכול להבדיל בין הסרטים שאתם רוצים לשמור בכל מחיר לבין המשחק שאתם לא ממש מתעניינים בו.
כדי לעקוף את הבעיה, משתמשים בממשק 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.
});
כמובן על המשתמש להעניק הרשאה. לשם כך, משתמשים ב-Permissions API.
חשוב שהמשתמש יהיה חלק מהתהליך הזה, כי עכשיו הוא יכול לשלוט במחיקה. אם המכשיר שלהם נמצא בלחץ אחסון, והסרת נתונים לא חיוניים לא פותרת את הבעיה, המשתמש יכול להחליט אילו פריטים להשאיר ואילו להסיר.
כדי שהפתרון הזה יעבוד, צריך שמערכות ההפעלה יתייחסו למקורות 'עמידים' כאל אפליקציות ספציפיות לפלטפורמה בפירוט של השימוש בנפח האחסון, במקום לדווח על הדפדפן כפריט יחיד.
הצעות להצגת מודעות – מענה לבקשות
לא משנה כמה שמירה במטמון, ה-Service Worker לא ישתמש במטמון אלא אם תדעו לו מתי ואיך. הנה כמה דפוסים לטיפול בבקשות:
מטמון בלבד
מתאים במיוחד ל: כל דבר שנתפס כסטטי ב'גרסה' מסוימת של האתר. כדאי לשמור אותם במטמון באירוע ההתקנה, כדי שתוכלו להסתמך על כך שהם יהיו שם.
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));
});
…אבל לא תמיד צריך לטפל במקרה הזה באופן ספציפי, והוא מכוסה באפשרות אחסון במטמון, חזרה לרשת.
רשת בלבד
מתאים במיוחד ל: דברים שאין להם מקבילה אופליין, כמו פינגים של Analytics, בקשות שאינן 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
});
…אבל לא תמיד צריך לטפל במקרה הזה באופן ספציפי, והוא מכוסה באפשרות אחסון במטמון, חזרה לרשת.
מטמון, חזרה לרשת
אידיאלי: לפיתוח גרסת אופליין שעומדת בדרישות. במקרים כאלה, כך תטפלו ברוב הבקשות. דפוסים אחרים יהיו חריגים על סמך הבקשה הנכנסת.
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.match(event.request).then(function (response) {
return response || fetch(event.request);
}),
);
});
כך מתקבלת התנהגות 'מטמון בלבד' לפריטים במטמון, והתנהגות מסוג 'רשת בלבד' לכל תוכן שלא נשמר במטמון (כולל את כל הבקשות שאינן GET, כי לא ניתן לשמור אותן במטמון).
מרוץ במטמון וברשת
אידיאלי: לנכסים קטנים שבהם אתם רודפים אחרי הביצועים במכשירים עם גישה איטית לדיסק.
בשילובים מסוימים של דיסקים קשיחים ישנים, סורקי וירוסים וחיבורי אינטרנט מהירים יותר, אחזור משאבים מהרשת יכול להיות מהיר יותר מאשר אחזור מהדיסק. עם זאת, חשוב לזכור שהפנייה לערוץ כשהתוכן נמצא במכשיר של המשתמש עלולה להיות בזבוז נתונים.
// 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)]));
});
הרשת חוזרת למטמון
מתאים במיוחד: תיקון מהיר למשאבים שמתעדכנים לעיתים קרובות, מחוץ ל'גרסה' של האתר. לדוגמה: מאמרים, דמויות, לוחות זמנים ברשתות החברתיות ולוחות לשחקנים מובילים.
כלומר, משתמשים אונליין מקבלים את התוכן העדכני ביותר, אבל משתמשים אופליין מקבלים גרסה ישנה יותר שנשמרה במטמון. אם הבקשה לרשת תתבצע בהצלחה, סביר להניח שתרצו לעדכן את הרשומה במטמון.
עם זאת, לשיטה הזו יש חסרונות. אם החיבור של המשתמש איטי או לא יציב, הוא יצטרך לחכות שהרשת תיכשל לפני שיקבל את התוכן שכבר מקובל במכשיר. התהליך הזה עשוי להימשך זמן רב מאוד וחוויית המשתמש היא מתסכלת. לפתרון טוב יותר ראו את התבנית הבאה, מטמון ואז רשת.
self.addEventListener('fetch', function (event) {
event.respondWith(
fetch(event.request).catch(function () {
return caches.match(event.request);
}),
);
});
מטמון ואז רשת
מתאים במיוחד ל: תוכן שמתעדכן לעיתים קרובות. למשל: מאמרים, לוחות זמנים ברשתות החברתיות ומשחקים. לוחות לידרבורד.
לשם כך, הדף צריך לשלוח שתי בקשות, אחת למטמון ואחת לרשת. הרעיון הוא להציג קודם את הנתונים ששמורים במטמון, ואז לעדכן את הדף כשנתוני הרשת מגיעים (אם הם מגיעים).
לפעמים אפשר פשוט להחליף את הנתונים הנוכחיים כשמתקבלים נתונים חדשים (למשל, לוח הישגי השחקנים המובילים של המשחק), אבל הם עלולים להפריע לקטעי תוכן גדולים יותר. בעיקרון, אל 'תיעלמו' משהו שהמשתמש קורא או יוצר איתו אינטראקציה.
מערכת Twitter מוסיפה את התוכן החדש מעל התוכן הישן ומתאימה את מיקום הגלילה כדי שהמשתמש לא ייפגע. הדבר אפשרי כי ב-Twitter התוכן נשמר בדרך כלל בסדר לינארי. העתקתי את התבנית הזו ל-trained-to-thrill כדי להציג תוכן במסך מהר ככל האפשר, תוך הצגת תוכן עדכני ברגע שהוא מגיע.
קוד בדף:
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);
קוד בקובץ השירות (service worker):
צריך תמיד לגשת לרשת ולעדכן מטמון בכל שלב.
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 תיארתי פתרון לבעיה הזו באמצעות שימוש ב-XHR במקום ב-fetch, ושימוש לרעה בכותרת Accept כדי להורות לקובץ השירות (Service Worker) מאיפה לקבל את התוצאה (קוד הדף, קוד קובץ השירות).
חלופה כללית
אם לא מוצג משהו מהמטמון או מהרשת, כדאי לספק חלופה כללית.
מתאים במיוחד ל: תמונות משניות כמו דמויות, בקשות POST שנכשלו ודף 'לא זמין במצב אופליין'.
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.
}),
);
});
הפריט שאליו אתם משתמשים עשוי להיות תלות בהתקנה.
אם הדף שלכם מפרסם אימייל, יכול להיות ש-service worker יעבור לשמירת האימייל ב'תיבת דואר יוצאת' של IndexedDB, ויודיע לדף שהשליחה נכשלה אבל הנתונים נשמרו.
יצירת תבניות בצד קובץ השירות (service worker)
אידיאלית: לדפים שאי אפשר לשמור את תגובת השרת שלהם במטמון.
עיבוד דפים בשרת מאפשר לכם לעבוד מהר יותר, אבל יכול להיות שזה יגרום לכם לכלול במטמון נתוני מצב שלא יתאימו לו, למשל 'התחבר כ…'. אם הדף נשלט על ידי Service Worker, אפשר לבקש נתוני JSON יחד עם תבנית ולעבד אותם במקום זאת.
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',
},
});
}),
);
});
סיכום של כל המידע
אתם לא מוגבלים לאחת מהשיטות האלה. למעשה, סביר להניח שתשתמשו ברבים מהם, בהתאם לכתובת ה-URL של הבקשה. לדוגמה, במודל trained-to-thrill נעשה שימוש ב:
- cache on install (אחסון במטמון בהתקנה), לממשק המשתמש ולהתנהגות הסטטיים
- מטמון בתגובה מהרשת, לתמונות ולנתונים של Flickr
- אחזור מהמטמון, חזרה לרשת, לרוב הבקשות
- אחזור מהמטמון ואז מהרשת, עבור תוצאות החיפוש ב-Flickr
פשוט בודקים את הבקשה ומחליטים מה לעשות:
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);
}),
);
});
... הבנת את התמונה.
זיכויים
...לסמלים היפים:
- Code מאת buzzyrobot
- Calendar מאת סקוט לואיס
- Network by Ben Rizzo
- SD מאת Thomas Le Bas
- CPU מ-iconsmind.com
- Trash מאת trasnik
- הודעה מאת @daosme
- פריסה מאת Mister Pixel
- Cloud מאת P.J. Onori
ותודה לג'ף פוזניק (Jeff Posnick) שאיתר כל כך הרבה שגיאות מלל לפני שלחצתי על "publish".
קריאה נוספת
- Service Workers – מבוא
- האם Service Worker מוכן? – מעקב אחרי סטטוס ההטמעה בדפדפנים הראשיים
- JavaScript Promises – מבוא – מדריך להבטחות