מחזור החיים של קובץ השירות (service worker)

Jake Archibald
Jake Archibald

מחזור החיים של קובץ השירות (service worker) הוא החלק הכי מורכב שלו. אם אתם לא יודעים מה המערכת מנסה לעשות ומה היתרונות שלה, יכול להיות שתרגישו שהיא פועלת נגדכם. אבל אחרי שמבינים איך זה עובד, אפשר לספק למשתמשים עדכונים חלקים ולא פולשניים, שמשלבים את הטוב ביותר מדפוסי האינטרנט והדפוסים המקוריים.

זהו מידע מעמיק, אבל התבליטים בתחילת כל קטע כוללים את רוב מה שצריך לדעת.

הכוונה

המטרה של מחזור החיים היא:

  • מאפשרת עבודה במצב אופליין.
  • לאפשר ל-Service Worker חדש להכין את עצמו בלי להפריע ל-Service Worker הנוכחי.
  • צריך לוודא שדף שנכלל בהיקף נשלט על ידי אותו קובץ שירות (או שאין קובץ שירות) לכל אורכו.
  • מוודאים שרק גרסה אחת של האתר פועלת בכל פעם.

הנקודה האחרונה חשובה מאוד. בלי קובצי Service Worker, המשתמשים יכולים לטעון כרטיסייה אחת לאתר שלכם, ואז לפתוח כרטיסייה אחרת. כתוצאה מכך, יכול להיות ששתי גרסאות של האתר יפעלו בו-זמנית. לפעמים זה בסדר, אבל כשמדובר ב-Shared Storage, יכול להיות מצב שבו לשתי כרטיסיות יש דעות שונות מאוד לגבי האופן שבו צריך לנהל את ה-Shared Storage המשותף שלהן. זה יכול לגרום לשגיאות, או גרוע מכך, לאובדן נתונים.

ה-Service Worker הראשון

בקצרה:

  • האירוע install הוא האירוע הראשון ש-service worker מקבל, והוא מתרחש רק פעם אחת.
  • אובייקט Promise שמועבר אל installEvent.waitUntil() מציין את משך ההתקנה ואם היא הצליחה או נכשלה.
  • ‫Service Worker לא יקבל אירועים כמו fetch ו-push עד שההתקנה שלו תסתיים בהצלחה והוא יהפוך ל"פעיל".
  • כברירת מחדל, בקשות האחזור של דף לא יעברו דרך קובץ שירות (service worker) אלא אם בקשת הדף עצמה עברה דרך קובץ שירות. לכן צריך לרענן את הדף כדי לראות את ההשפעות של ה-service worker.
  • clients.claim() יכולה לשנות את ברירת המחדל הזו, ולהשתלט על דפים לא נשלטים.

קחו את קוד ה-HTML הזה:

<!DOCTYPE html>
An image will appear here in 3 seconds:
<script>
  navigator.serviceWorker.register('/sw.js')
    .then(reg => console.log('SW registered!', reg))
    .catch(err => console.log('Boo!', err));

  setTimeout(() => {
    const img = new Image();
    img.src = '/dog.svg';
    document.body.appendChild(img);
  }, 3000);
</script>

הוא רושם קובץ Service Worker ומוסיף תמונה של כלב אחרי 3 שניות.

זה ה-Service Worker שלו, sw.js:

self.addEventListener('install', event => {
  console.log('V1 installing…');

  // cache a cat SVG
  event.waitUntil(
    caches.open('static-v1').then(cache => cache.add('/cat.svg'))
  );
});

self.addEventListener('activate', event => {
  console.log('V1 now ready to handle fetches!');
});

self.addEventListener('fetch', event => {
  const url = new URL(event.request.url);

  // serve the cat SVG from the cache if the request is
  // same-origin and the path is '/dog.svg'
  if (url.origin == location.origin && url.pathname == '/dog.svg') {
    event.respondWith(caches.match('/cat.svg'));
  }
});

הוא שומר במטמון תמונה של חתול, ומציג אותה בכל פעם שיש בקשה ל-/dog.svg. עם זאת, אם תריצו את הדוגמה שלמעלה, תראו כלב בפעם הראשונה שתטענו את הדף. מרעננים את הדף ורואים את החתול.

היקף ושליטה

היקף ברירת המחדל של רישום קובץ שירות (service worker) הוא ./ ביחס לכתובת ה-URL של הסקריפט. כלומר, אם רושמים קובץ שירות (service worker) בכתובת //example.com/foo/bar.js, היקף ברירת המחדל שלו הוא //example.com/foo/.

אנחנו קוראים לדפים, ל-workers ול-workers משותפים clients. ה-service worker יכול לשלוט רק בלקוחות שנמצאים בהיקף. אחרי שהלקוח "נשלט", השליפות שלו עוברות דרך ה-service worker שבטווח. אפשר לזהות אם לקוח נשלט באמצעות navigator.serviceWorker.controller, שיהיה null או מכונה של Service Worker.

הורדה, ניתוח והפעלה

קובץ ה-Service Worker הראשון שלכם יורד כשאתם קוראים ל-.register(). אם הסקריפט לא מצליח להוריד, לנתח או להציג שגיאה בהפעלה הראשונית שלו, ההבטחה לרישום נדחית ו-Service Worker נמחק.

בכלי הפיתוח ל-Chrome, השגיאה מוצגת במסוף ובקטע Service Worker בכרטיסיית האפליקציה:

השגיאה שמוצגת בכרטיסייה DevTools של ה-service worker

התקנה

האירוע הראשון ש-Service Worker מקבל הוא install. הוא מופעל ברגע שה-worker מופעל, והוא נקרא רק פעם אחת לכל service worker. אם משנים את הסקריפט של ה-service worker, הדפדפן מתייחס אליו כאל service worker אחר, והוא יקבל אירוע install משלו. בהמשך אפרט על העדכונים.

האירוע install הוא ההזדמנות שלכם לשמור במטמון את כל מה שאתם צריכים לפני שתוכלו לשלוט בלקוחות. ה-Promise שמועבר אל event.waitUntil() מאפשר לדפדפן לדעת מתי ההתקנה הושלמה ואם היא הצליחה.

אם ההבטחה נדחית, זה סימן שההתקנה נכשלה, והדפדפן זורק את ה-service worker. הוא אף פעם לא ישלוט בלקוחות. זה אומר שאנחנו יכולים להסתמך על כך ש-cat.svg נמצא במטמון באירועים של fetch. זו תלות.

הפעלה

אחרי ש-Service Worker מוכן לשלוט בלקוחות ולטפל באירועים פונקציונליים כמו push ו-sync, תקבלו אירוע activate. אבל זה לא אומר שהדף שקרא ל-.register() יהיה בשליטה.

בפעם הראשונה שאתם טוענים את הדמו, למרות שהבקשה ל-dog.svg נשלחת הרבה אחרי ש-Service Worker מופעל, הוא לא מטפל בבקשה, ואתם עדיין רואים את התמונה של הכלב. ברירת המחדל היא עקביות. אם טעינת הדף מתבצעת בלי קובץ שירות, גם משאבי המשנה שלו לא ייטענו. אם תטענו את הדמו בפעם השנייה (כלומר, תרעננו את הדף), הוא יהיה בשליטה. גם הדף וגם התמונה יעברו fetch אירועים, ובמקום זאת תופיע תמונה של חתול.

clients.claim

כדי להשתלט על לקוחות לא נשלטים, אפשר להפעיל את clients.claim() ב-service worker אחרי שהוא מופעל.

כאן אפשר לראות וריאציה של ההדגמה שלמעלה, שקוראת ל-clients.claim() באירוע activate שלה. בפעם הראשונה אמורים לראות חתול. אמרתי "אמור" כי זה תלוי בתזמון. תראו חתול רק אם קובץ השירות (service worker) יופעל ו-clients.claim() ייכנס לתוקף לפני שהתמונה תנסה להיטען.

אם משתמשים ב-Service Worker כדי לטעון דפים בצורה שונה מהטעינה שלהם דרך הרשת, clients.claim() עלול לגרום לבעיות, כי ה-Service Worker שולט בסופו של דבר בחלק מהלקוחות שנטענו בלעדיו.

עדכון ה-Service Worker

בקצרה:

  • עדכון מופעל אם מתרחש אחד מהמקרים הבאים:
    • ניווט לדף שנכלל בהיקף.
    • אירועים פונקציונליים כמו push ו-sync, אלא אם בוצעה בדיקת עדכונים ב-24 השעות האחרונות.
    • קוראים ל-.register() רק אם כתובת ה-URL של ה-service worker השתנתה. עם זאת, אל תשנו את כתובת ה-URL של ה-worker.
  • רוב הדפדפנים, כולל Chrome מגרסה 68 ואילך, מתעלמים כברירת מחדל מכותרות של שמירה במטמון כשהם בודקים אם יש עדכונים לסקריפט הרשום של קובץ השירות (service worker). הם עדיין מכבדים כותרות של שמירה במטמון כשהם מאחזרים משאבים שנטענו בתוך Service Worker דרך importScripts(). כדי לשנות את פעולת ברירת המחדל הזו, אפשר להגדיר את האפשרות updateViaCache כשרושמים את ה-service worker.
  • אם ה-Service Worker שונה בבייטים מה-Service Worker שכבר קיים בדפדפן, הוא נחשב מעודכן. (אנחנו מרחיבים את זה כדי לכלול גם סקריפטים או מודולים מיובאים).
  • ה-service worker המעודכן מופעל לצד הקיים, ומקבל אירוע install משלו.
  • אם ל-worker החדש יש קוד סטטוס לא תקין (לדוגמה, 404), אם הוא נכשל בניתוח, אם הוא מקפיץ שגיאה במהלך ההפעלה או אם הוא נדחה במהלך ההתקנה, ה-worker החדש נמחק, אבל ה-worker הנוכחי נשאר פעיל.
  • אחרי שההתקנה תסתיים, ה-worker המעודכן wait עד שה-worker הקיים ישלוט באפס לקוחות. (שימו לב שהלקוחות חופפים במהלך הרענון).
  • הפונקציה self.skipWaiting() מונעת את ההמתנה, כלומר ה-service worker מופעל ברגע שההתקנה שלו מסתיימת.

נניח ששינינו את הסקריפט של ה-service worker כך שיגיב עם תמונה של סוס במקום חתול:

const expectedCaches = ['static-v2'];

self.addEventListener('install', event => {
  console.log('V2 installing…');

  // cache a horse SVG into a new cache, static-v2
  event.waitUntil(
    caches.open('static-v2').then(cache => cache.add('/horse.svg'))
  );
});

self.addEventListener('activate', event => {
  // delete any caches that aren't in expectedCaches
  // which will get rid of static-v1
  event.waitUntil(
    caches.keys().then(keys => Promise.all(
      keys.map(key => {
        if (!expectedCaches.includes(key)) {
          return caches.delete(key);
        }
      })
    )).then(() => {
      console.log('V2 now ready to handle fetches!');
    })
  );
});

self.addEventListener('fetch', event => {
  const url = new URL(event.request.url);

  // serve the horse SVG from the cache if the request is
  // same-origin and the path is '/dog.svg'
  if (url.origin == location.origin && url.pathname == '/dog.svg') {
    event.respondWith(caches.match('/horse.svg'));
  }
});

לצפייה בהדגמה של התהליך שלמעלה. עדיין אמורה להופיע תמונה של חתול. הסיבה לכך היא…

התקנה

שימו לב ששיניתי את שם המטמון מ-static-v1 ל-static-v2. זה אומר שאני יכול להגדיר את המטמון החדש בלי להחליף דברים במטמון הנוכחי, שבו עדיין משתמש ה-Service Worker הישן.

התבניות האלה יוצרות מטמונים ספציפיים לגרסה, בדומה לנכסים שאפליקציית נייטיב הייתה כוללת בקובץ ההפעלה שלה. יכול להיות שיש לכם גם מטמונים שלא ספציפיים לגרסה, כמו avatars.

בהמתנה

אחרי שההתקנה מסתיימת, ה-Service Worker המעודכן ממתין עד שה-Service Worker הקיים מפסיק לשלוט בלקוחות, ורק אז הוא מופעל. המצב הזה נקרא "בהמתנה", וכך הדפדפן מוודא שרק גרסה אחת של ה-service worker פועלת בכל פעם.

אם הפעלתם את הדמו המעודכן, עדיין אמורה להופיע תמונה של חתול, כי ה-worker של גרסה 2 עדיין לא הופעל. אפשר לראות את ה-service worker החדש שממתין בכרטיסייה Application (אפליקציה) בכלי הפיתוח:

כלי הפיתוח מציגים Service Worker חדש בהמתנה

גם אם יש לכם רק כרטיסייה אחת פתוחה של הגרסה לדוגמה, רענון הדף לא מספיק כדי שהגרסה החדשה תתחיל לפעול. הסיבה לכך היא האופן שבו הניווט בדפדפן פועל. כשמנווטים, הדף הנוכחי לא נעלם עד שמתקבלות כותרות התגובה, וגם אז הדף הנוכחי עשוי להישאר אם לתגובה יש כותרת Content-Disposition. בגלל החפיפה הזו, ה-Service Worker הנוכחי תמיד שולט בלקוח במהלך רענון.

כדי לקבל את העדכון, צריך לסגור את כל הכרטיסיות שמשתמשות ב-Service Worker הנוכחי או לעבור מהן לכרטיסיות אחרות. אחר כך, כשתעברו שוב לדמו, הסוס אמור להופיע.

הדפוס הזה דומה לאופן שבו Chrome מתעדכן. העדכונים של Chrome יורדים ברקע, אבל הם נכנסים לתוקף רק אחרי שמפעילים מחדש את Chrome. בינתיים, אפשר להמשיך להשתמש בגרסה הנוכחית בלי הפרעות. עם זאת, זה יכול להיות מעצבן במהלך הפיתוח, אבל יש דרכים להקל על התהליך באמצעות כלי הפיתוח, שאותן אסביר בהמשך המאמר.

הפעלה

האירוע הזה מופעל אחרי ש-Service Worker הישן נעלם, ו-Service Worker החדש יכול לשלוט בלקוחות. זה הזמן האידיאלי לבצע פעולות שלא יכולתם לבצע בזמן שה-worker הישן עדיין היה בשימוש, כמו העברת מסדי נתונים וניקוי מטמונים.

בהדגמה שלמעלה, אני שומר רשימה של מטמונים שאני מצפה שיהיו שם, ובאירוע activate אני נפטר מכל האחרים, מה שמסיר את המטמון הישן של static-v1.

אם מעבירים הבטחה (promise) אל event.waitUntil(), היא תאחסן במאגר אירועים פונקציונליים (fetch,‏ push,‏ sync וכו') עד שההבטחה תתממש. לכן, כשהאירוע fetch מופעל, ההפעלה הושלמה.

דילוג על שלב ההמתנה

שלב ההמתנה מאפשר להפעיל רק גרסה אחת של האתר בכל פעם. אם אתם לא צריכים את התכונה הזו, אתם יכולים להפעיל את ה-Service Worker החדש מוקדם יותר על ידי קריאה ל-self.skipWaiting().

הפעולה הזו גורמת ל-service worker להוציא את ה-worker הפעיל הנוכחי ולהפעיל את עצמו ברגע שהוא נכנס לשלב ההמתנה (או מיד אם הוא כבר בשלב ההמתנה). היא לא גורמת ל-worker לדלג על ההתקנה, אלא רק להמתין.

לא משנה מתי קוראים ל-skipWaiting(), כל עוד זה קורה לפני או במהלך ההמתנה. בדרך כלל קוראים לה באירוע install:

self.addEventListener('install', event => {
  self.skipWaiting();

  event.waitUntil(
    // caching etc
  );
});

אבל יכול להיות שתרצו לקרוא לה כתוצאה מ-postMessage() ל-Service Worker. כלומר, אתם רוצים skipWaiting() אחרי אינטראקציה של משתמש.

הנה הדגמה שמשתמשת ב-skipWaiting(). תמונה של פרה אמורה להופיע בלי שתצטרכו לעבור למקום אחר. כמו clients.claim(), זהו מרוץ, כך שהפרה תופיע רק אם קובץ השירות החדש יאחזר, יתקין ויפעיל לפני שהדף ינסה לטעון את התמונה.

עדכונים ידניים

כמו שציינתי קודם, הדפדפן בודק אם יש עדכונים באופן אוטומטי אחרי ניווטים ואירועים פונקציונליים, אבל אפשר גם להפעיל אותם באופן ידני:

navigator.serviceWorker.register('/sw.js').then(reg => {
  // sometime later…
  reg.update();
});

אם אתם מצפים שהמשתמש ישתמש באתר שלכם במשך זמן רב בלי לטעון אותו מחדש, כדאי להפעיל את update() במרווחי זמן (למשל, מדי שעה).

הימנעו משינוי כתובת ה-URL של הסקריפט של ה-service worker

אם קראתם את הפוסט שלי על שיטות מומלצות לשמירה במטמון, אולי כדאי לכם לתת לכל גרסה של ה-Service Worker כתובת URL ייחודית. אל תעשו את זה! בדרך כלל לא מומלץ לעשות את זה עם קובצי Service Worker. פשוט צריך לעדכן את הסקריפט במיקום הנוכחי שלו.

זה יכול לגרום לבעיה כזו:

  1. ‫index.html רושם את sw-v1.js כ-service worker.
  2. ‫sw-v1.js שומר במטמון ומציג את index.html כך שהוא פועל קודם במצב אופליין.
  3. אתם מעדכנים את index.html כדי שירשום את sw-v2.js החדש והנוצץ שלכם.

אם תעשו את זה, המשתמש אף פעם לא יקבל את sw-v2.js, כי sw-v1.js מציג את הגרסה הישנה של index.html מהמטמון שלו. הגעתם למצב שבו אתם צריכים לעדכן את ה-service worker כדי לעדכן את ה-service worker. איכס.

עם זאת, בהדגמה שלמעלה, שיניתי את כתובת ה-URL של קובץ השירות (service worker). כך תוכלו לעבור בין הגרסאות לצורך ההדגמה. זה לא משהו שהייתי עושה בסביבת ייצור.

פיתוח בקלות

מחזור החיים של קובץ השירות (service worker) נוצר מתוך מחשבה על המשתמש, אבל במהלך הפיתוח הוא קצת מעצבן. למרבה המזל, יש כמה כלים שיכולים לעזור:

עדכון בטעינה מחדש

זה האהוב עליי.

כלי הפיתוח מציגים את האפשרות &quot;עדכון בטעינה מחדש&quot;

השינוי הזה הופך את מחזור החיים לידידותי למפתחים. בכל ניווט:

  1. אחזור מחדש של ה-service worker.
  2. צריך להתקין אותו כגרסה חדשה גם אם הוא זהה מבחינת בייטים, כלומר האירוע install פועל והמטמונים מתעדכנים.
  3. מדלגים על שלב ההמתנה כדי להפעיל את קובץ השירות (service worker) החדש.
  4. מנווטים בדף.

כלומר, העדכונים יתקבלו בכל ניווט (כולל רענון) בלי שתצטרכו לטעון מחדש פעמיים או לסגור את הכרטיסייה.

דילוג על ההמתנה

כלי הפיתוח מציגים את האפשרות &quot;דילוג על המתנה&quot;

אם יש לכם קובץ Service Worker בהמתנה, אתם יכולים ללחוץ על "דילוג על ההמתנה" בכלי הפיתוח כדי להעביר אותו מיד לסטטוס "פעיל".

Shift-reload

אם תאלצו את הדף להיטען מחדש (shift-reload), הוא יעקוף את ה-service worker לחלוטין. הוא לא יהיה מבוקר. התכונה הזו נמצאת במפרט, ולכן היא פועלת בדפדפנים אחרים שתומכים ב-Service Worker.

טיפול בעדכונים

ה-service worker תוכנן כחלק מהרשת הניתנת להרחבה. הרעיון הוא שאנחנו, כמפתחי דפדפנים, מכירים בכך שאנחנו לא טובים יותר בפיתוח אתרים מאשר מפתחי אתרים. לכן, אנחנו לא צריכים לספק ממשקי API צרים ברמה גבוהה שפותרים בעיה מסוימת באמצעות דפוסים שאנחנו אוהבים, אלא לתת לכם גישה לקרביים של הדפדפן ולאפשר לכם לעשות את זה איך שאתם רוצים, בדרך שהכי מתאימה למשתמשים שלכם.

כדי לאפשר כמה שיותר דפוסים, כל מחזור העדכון ניתן לצפייה:

navigator.serviceWorker.register('/sw.js').then(reg => {
  reg.installing; // the installing worker, or undefined
  reg.waiting; // the waiting worker, or undefined
  reg.active; // the active worker, or undefined

  reg.addEventListener('updatefound', () => {
    // A wild service worker has appeared in reg.installing!
    const newWorker = reg.installing;

    newWorker.state;
    // "installing" - the install event has fired, but not yet complete
    // "installed"  - install complete
    // "activating" - the activate event has fired, but not yet complete
    // "activated"  - fully active
    // "redundant"  - discarded. Either failed install, or it's been
    //                replaced by a newer version

    newWorker.addEventListener('statechange', () => {
      // newWorker.state has changed
    });
  });
});

navigator.serviceWorker.addEventListener('controllerchange', () => {
  // This fires when the service worker controlling this page
  // changes, eg a new worker has skipped waiting and become
  // the new active worker.
});

מחזור החיים נמשך לנצח

כמו שאתם רואים, כדאי להבין את מחזור החיים של ה-service worker. אחרי שתבינו אותו, ההתנהגות של ה-service worker תיראה לכם הגיונית יותר ופחות מסתורית. הידע הזה ייתן לכם יותר ביטחון כשאתם פורסים ומעדכנים קובצי Service Worker.