עם Service Workers, נתנו למפתחים דרך לפתור בעיות בחיבור לרשת. אתם מקבלים שליטה על שמירת נתונים במטמון ועל אופן הטיפול בבקשות. כלומר, אתם יכולים ליצור דפוסים משלכם. כדאי לבדוק כמה דוגמאות אפשריות בנפרד, אבל בפועל, סביר להניח שתשתמשו בהן יחד, בהתאם לכתובת ה-URL ולהקשר.
כדי לראות הדגמה של חלק מהדפוסים האלה, אפשר לעיין במאמר Trained-to-thrill.
מתי כדאי לאחסן משאבים
קובצי שירות (service worker) מאפשרים לטפל בבקשות באופן עצמאי מניהול מטמון, ולכן אדגים אותם בנפרד. קודם כל, צריך לקבוע מתי כדאי להשתמש במטמון.
בהתקנה, כפונקציה שמוגדרת כ-dependency
Service Worker API מספק לכם אירוע install. אפשר להשתמש בזה כדי להכין דברים שצריכים להיות מוכנים לפני שמטפלים באירועים אחרים. במהלך
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 מקבל הבטחה להגדרת משך ההתקנה והצלחתה. אם ההבטחה נדחית, ההתקנה נחשבת כהתקנה שנכשלה, וקובץ ה-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 יכול לטפל במקרים כאלה, וגם בהורדות גדולות יותר כמו סרטים.
בהפעלה
מתאים במיוחד ל: ניקוי והעברה.
אחרי שמתקינים 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, מאמר בוויקיפדיה או גלריה מסוימת ב-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);
});
});
});
Cache API זמין מדפים ומקובצי שירות (service worker), כך שאפשר להוסיף למטמון ישירות מהדף.
בתגובה לרשת
השיטה האידיאלית ל: עדכון תכופות של משאבים כמו תיבת הדואר הנכנס של משתמש או תוכן של מאמר. הוא שימושי גם לתוכן לא חיוני כמו אווטרים, אבל צריך להשתמש בו בזהירות.
אם בקשה לא תואמת לשום דבר במטמון, המערכת מקבלת אותה מהרשת, שולחת אותה לדף ומוסיפה אותה למטמון בו-זמנית.
אם עושים את זה לטווח של כתובות 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 מתעורר. אתם מבקשים הרשאה לעשות זאת מדף מסוים, והמשתמש מקבל הנחיה.
מתאים במיוחד ל: תוכן שקשור להתראה, כמו הודעה בצ'אט, ידיעה חדשותית או אימייל. גם תוכן שמשתנה לעיתים רחוקות ומומלץ לסנכרן אותו באופן מיידי, כמו עדכון של רשימת מטלות או שינוי ביומן.
התוצאה הסופית הנפוצה היא הודעה, שבלחיצה עליה נפתח דף רלוונטי ומוצג במרכז המסך, וחשוב מאוד לעדכן את מטמון הנתונים מראש. המשתמש מחובר לאינטרנט בזמן קבלת הודעת הפוש, אבל יכול להיות שהוא לא יהיה מחובר כשהוא יבצע אינטראקציה עם ההתראה, ולכן חשוב שהתוכן הזה יהיה זמין במצב אופליין.
הקוד הזה מעדכן את מטמון הנתונים לפני הצגת ההתראה:
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 מופעל. אתם מבקשים הרשאה לעשות את זה מדף מסוים, והמשתמש מקבל הנחיה.
מתאים במיוחד ל: עדכונים לא דחופים, במיוחד עדכונים שמתרחשים בתדירות גבוהה מדי בשביל לשלוח למשתמשים הודעת פוש על כל עדכון, כמו ציר זמן ברשתות חברתיות או מאמרי חדשות.
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, File System Access וכמובן מטמון.
הסכום שתקבלו לא מצוין. ההבדלים תלויים במכשיר ובתנאי האחסון. אפשר לבדוק כמה כסף יש לכם באמצעות:
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));
});
…אבל בדרך כלל לא צריך לטפל במקרה הזה באופן ספציפי, כי הוא מכוסה על ידי Cache, falling back to network.
רשת בלבד
מתאים במיוחד ל: פעולות שאין להן מקבילה אופליין, כמו פינגים של ניתוח נתונים, בקשות שאינן GET.
self.addEventListener('fetch', function (event) {
event.respondWith(fetch(event.request));
// or don't call event.respondWith, which
// will result in default browser behavior
});
…אבל בדרך כלל לא צריך לטפל במקרה הזה באופן ספציפי, כי הוא מכוסה על ידי Cache, falling back to network.
מטמון, חזרה לרשת
מומלץ ל: בניית אפליקציות שפועלות אופליין. במקרים כאלה, כך תטפלו ברוב הבקשות. דפוסים אחרים הם חריגים שמבוססים על הבקשה הנכנסת.
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.match(event.request).then(function (response) {
return response || fetch(event.request);
}),
);
});
כך מתקבלת התנהגות של 'מטמון בלבד' לגבי פריטים שנמצאים במטמון, והתנהגות של 'רשת בלבד' לגבי פריטים שלא נמצאים במטמון (כולל כל הבקשות שאינן GET, כי אי אפשר לשמור אותן במטמון).
תחרות בין מטמון לרשת
מתאים במיוחד ל: נכסים קטנים שבהם אתם רוצים לשפר את הביצועים במכשירים עם גישה איטית לדיסק.
בשילובים מסוימים של כוננים קשיחים ישנים, סורקי וירוסים וחיבורים מהירים יותר לאינטרנט, השגת משאבים מהרשת יכולה להיות מהירה יותר מאשר גישה לדיסק. עם זאת, אם המשתמש ניגש לרשת כשהתוכן כבר נמצא במכשיר שלו, זה עלול לבזבז נתונים, אז כדאי לזכור את זה.
// Promise.race rejects when a promise rejects before fulfilling.
// To make a 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)]));
});
הרשת חוזרת למטמון
מתאים במיוחד ל: תיקון מהיר של משאבים שמתעדכנים לעיתים קרובות, מחוץ ל'גרסה' של האתר. לדוגמה: מאמרים, דמויות, ציר זמן ברשתות חברתיות ורשימות של שחקנים מובילים במשחקים.
כלומר, משתמשים אונליין מקבלים את התוכן העדכני ביותר, אבל משתמשים אופליין מקבלים גרסה ישנה יותר שנשמרה במטמון. אם בקשת הרשת מצליחה, סביר להניח שתרצו לעדכן את רשומת המטמון.
עם זאת, יש פגמים בשיטה הזו. אם למשתמש יש חיבור איטי או חיבור עם הפסקות, הוא יצטרך לחכות עד שהרשת תיכשל לפני שהוא יקבל את התוכן המקובל לחלוטין שכבר נמצא במכשיר שלו. התהליך הזה יכול להימשך זמן רב מאוד, והוא יוצר חוויית משתמש מתסכלת. כדי לראות פתרון טוב יותר, אפשר לעיין בדפוס הבא, Cache then network.
self.addEventListener('fetch', function (event) {
event.respondWith(
fetch(event.request).catch(function () {
return caches.match(event.request);
}),
);
});
מטמון ואז רשת
מתאים במיוחד ל: תוכן שמתעדכן לעיתים קרובות. לדוגמה, מאמרים, ציר זמן ברשתות החברתיות וטבלאות הישגים במשחקים.
לשם כך, הדף צריך לשלוח שתי בקשות: אחת למטמון ואחת לרשת. הרעיון הוא להציג קודם את הנתונים ששמורים במטמון, ואז לעדכן את הדף אם וכאשר נתוני הרשת מגיעים.
לפעמים אפשר פשוט להחליף את הנתונים הנוכחיים כשמגיעים נתונים חדשים (למשל, טבלת מובילים במשחק), אבל החלפה כזו עלולה לשבש את התצוגה של תוכן גדול יותר. בקיצור, אל תגרמו לתוכן שהמשתמש קורא או מקיים איתו אינטראקציה "להיעלם".
טוויטר מוסיפה את התוכן החדש מעל התוכן הישן ומשנה את מיקום הגלילה כדי שהמשתמש לא יופרע. הדבר אפשרי כי בטוויטר נשמר סדר ליניארי ברובו של התוכן. העתקתי את התבנית הזו עבור 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);
}),
);
});
קריאה נוספת
- Service workers ו-Cache Storage API
- JavaScript Promises—an Introduction: מדריך בנושא הבטחות
זיכויים
לסמלים המקסימים:
- Code מאת buzzyrobot
- Calendar מאת Scott Lewis
- Network by Ben Rizzo
- SD מאת Thomas Le Bas
- CPU by iconsmind.com
- Trash מאת trasnik
- התראה מאת @daosme
- Layout מאת Mister Pixel
- Cloud מאת P.J. Onori
תודה לג'ף פוזניק על כך שזיהה הרבה שגיאות לפני שלחצתי על 'פרסום'.