האצת קובץ השירות (service worker) באמצעות טעינה מראש של ניווט

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

Jake Archibald
Jake Archibald

תמיכה בדפדפנים

  • Chrome: 59.
  • Edge: ‏ 18.
  • Firefox: ‏ 99.
  • Safari: 15.4.

מקור

סיכום

הבעיה

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

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

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

אתחול תוכנה
בקשת ניווט

בקשת הרשת מתעכבת בגלל ההפעלה של ה-service worker.

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

Facebook הסב את תשומת לבנו להשפעה של הבעיה הזו, וביקשה דרך לבצע בקשות ניווט במקביל:

אתחול תוכנה
בקשת ניווט

טעינה מראש של מסלולי ניווט – הפתרון

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

עיכוב ההפעלה עדיין קיים, אבל הוא לא חוסם את הבקשה לרשת, כך שהמשתמש מקבל תוכן מוקדם יותר.

הנה סרטון שבו אפשר לראות את הבדיקה בפעולה. בבדיקה הזו, זמן ההפעלה של ה-service worker מוגדר מראש ל-500 אלפיות השנייה באמצעות לולאת while:

כאן אפשר לראות את הדמו עצמו. כדי ליהנות מהיתרונות של טעינה מראש של נתוני הניווט, צריך דפדפן שתומך בכך.

הפעלת טעינה מראש של מסלולי ניווט

addEventListener('activate', event => {
  event.waitUntil(async function() {
    // Feature-detect
    if (self.registration.navigationPreload) {
      // Enable navigation preloads!
      await self.registration.navigationPreload.enable();
    }
  }());
});

אפשר להתקשר למספר navigationPreload.enable() מתי שרוצים, או להשבית אותו באמצעות navigationPreload.disable(). עם זאת, מכיוון שאירוע fetch צריך להשתמש בו, עדיף להפעיל אותו ולהשבית אותו באירוע activate של ה-service worker.

שימוש בתשובה שהוטענה מראש

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

addEventListener('fetch', event => {
  event.respondWith(async function() {
    // Respond from the cache if we can
    const cachedResponse = await caches.match(event.request);
    if (cachedResponse) return cachedResponse;

    // Else, use the preloaded response, if it's there
    const response = await event.preloadResponse;
    if (response) return response;

    // Else try the network.
    return fetch(event.request);
  }());
});

event.preloadResponse הוא הבטחה שמתקבלת עם תשובה, אם:

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

אחרת, event.preloadResponse עדיין קיים, אבל הוא מטופל על ידי undefined.

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

נניח שאנחנו רוצים להציג מאמר:

addEventListener('fetch', event => {
  const url = new URL(event.request.url);
  const includeURL = new URL(url);
  includeURL.pathname += 'include';

  if (isArticleURL(url)) {
    event.respondWith(async function() {
      // We're going to build a single request from multiple parts.
      const parts = [
        // The top of the page.
        caches.match('/article-top.include'),
        // The primary content
        fetch(includeURL)
          // A fallback if the network fails.
          .catch(() => caches.match('/article-offline.include')),
        // The bottom of the page
        caches.match('/article-bottom.include')
      ];

      // Merge them all together.
      const {done, response} = await mergeResponses(parts);

      // Wait until the stream is complete.
      event.waitUntil(done);

      // Return the merged response.
      return response;
    }());
  }
});

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

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

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

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

Service-Worker-Navigation-Preload: true

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

עכשיו אפשר להשתמש בבקשה לטעינת נתונים מראש:

// Try to use the preload
const networkContent = Promise.resolve(event.preloadResponse)
  // Else do a normal fetch
  .then(r => r || fetch(includeURL))
  // A fallback if the network fails.
  .catch(() => caches.match('/article-offline.include'));

const parts = [
  caches.match('/article-top.include'),
  networkContent,
  caches.match('/article-bottom')
];

שינוי הכותרת

כברירת מחדל, הערך של הכותרת Service-Worker-Navigation-Preload הוא true, אבל אפשר להגדיר אותו לכל ערך אחר:

navigator.serviceWorker.ready.then(registration => {
  return registration.navigationPreload.setHeaderValue(newValue);
}).then(() => {
  console.log('Done!');
});

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

אחזור המצב

אפשר לבדוק את הסטטוס של הטעינה מראש של הניווט באמצעות getState:

navigator.serviceWorker.ready.then(registration => {
  return registration.navigationPreload.getState();
}).then(state => {
  console.log(state.enabled); // boolean
  console.log(state.headerValue); // string
});

תודה רבה למאט פאלקנהגן (Matt Falkenhagen) ולצוושי הורה (Tsuyoshi Horo) על העבודה על התכונה הזו ועל העזרה במאמר הזה. ותודה רבה לכל מי שהיה מעורב במאמץ התקינה