運用導覽預先載入功能加快服務工作處理程序的速度

Navigation 預先載入功能會平行發出要求,藉此超過服務工作處理程序的啟動時間。

Jake Archibald
Jake Archibald

瀏覽器支援

  • Chrome:59。
  • Edge:18。
  • Firefox:99。
  • Safari:15.4。

資料來源

摘要

問題

當您前往的網站使用 Service Worker 處理擷取事件時,瀏覽器會要求 Service Worker 回應。這牽涉到啟動 Service Worker (如果尚未啟動),然後分派擷取事件。

開機時間視裝置和狀況而定。通常需要 50 毫秒。在行動裝置上則比較像是 250 毫秒。在極端情況下 (例如緩慢裝置、CPU 連線不良),可能超過 500 毫秒。不過,由於服務工作處理程序在瀏覽器於事件發生間隔時間後才處於喚醒狀態,您偶爾才會發生這種延遲,例如使用者從新分頁或其他網站前往您的網站時。

如果您來自快取,則開機時間不會造成問題,因為略過網路的好處大於開機延遲時間。但如果您使用網路回應...

軟體啟動
導航要求

網路要求因 Service Worker 開機而延遲。

我們將在 V8 中使用程式碼快取功能略過沒有擷取事件的 Service Worker適時啟動 Service Worker,並執行其他最佳化措施來縮短啟動時間。但是,開機時間永遠都會大於零。

Facebook 讓我們注意到這個問題的影響,並要求進行同時執行導航要求的方法:

軟體啟動
導航要求

預先載入救援方針

Navigation 預先載入功能可讓您說出「當使用者提出 GET 瀏覽要求時,在服務工作處理程序啟動時開始網路要求」的功能。

啟動延遲仍然存在,但不會封鎖網路要求,因此使用者更快取得內容。

以下是實際運作情況的影片,Service Worker 會使用 Wake 迴圈執行刻意 500 毫秒的啟動延遲時間:

按這裡查看示範內容。如要享有預先載入功能的好處,請使用支援預先載入功能的瀏覽器

啟用導覽預先載入功能

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 事件需要使用該事件,因此建議您在 Service Worker 的 activate 事件中啟用及停用該事件。

使用預先載入的回應

現在瀏覽器將執行預先載入進行導覽,但您仍需「使用」回應:

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 要求。
  • 這項要求是瀏覽要求 (瀏覽器在載入網頁時產生的要求,包含 iframe 在內)。

否則 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 的要求會因為服務工作站的啟動時間延遲。我們也可以使用導覽預先載入來修正這個問題,但是在這種情況下,我們不想要預先載入完整網頁,因此我們想預先載入 include。

為支援這項功能,每個預先載入要求都會傳送標頭:

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!');
});

例如,您可以將其設為您在本機快取的最後一則訊息 ID,讓伺服器只傳回較新的資料。

取得狀態

您可以使用 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 為這項功能開發出更多心力,也感謝大家閱讀這篇文章。非常感謝各位參與標準化作業