ナビゲーション プリロードで Service Worker を高速化する

ナビゲーション プリロードを使用すると、リクエストを並行して実行することで、Service Worker の起動時間を短縮できます。

対応ブラウザ

  • Chrome: 59。 <ph type="x-smartling-placeholder">
  • Edge: 18。 <ph type="x-smartling-placeholder">
  • Firefox: 99。 <ph type="x-smartling-placeholder">
  • Safari: 15.4。 <ph type="x-smartling-placeholder">

ソース

概要

問題

Service Worker を使用してフェッチ イベントを処理するサイトにアクセスすると、ブラウザは Service Worker に応答を求めます。これには、Service Worker の起動(まだ実行されていない場合)と、fetch イベントのディスパッチが含まれます。

起動時間はデバイスと条件によって異なります。通常は 50 ミリ秒程度です。モバイルでは 250 ミリ秒程度です。極端なケース(速度の遅いデバイス、CPU が動作不良)の場合、500 ミリ秒を超えることもあります。ただし、ブラウザで設定されたイベントとイベントの間は Service Worker がスリープ状態から復帰しないため、この遅延が発生するのはユーザーが新しいタブや別のサイトからサイトにアクセスした場合などです。

キャッシュから応答する場合、ネットワークをスキップするメリットは起動の遅延よりも大きいため、起動時間は問題になりません。ただし、ネットワークを使用して応答する場合は...

SW ブート
ナビゲーション リクエスト

Service Worker の起動によりネットワーク リクエストが遅延する。

Google は、V8 のコード キャッシュの使用フェッチ イベントがない Service Worker のスキップService Worker の投機的起動、その他の最適化により、起動時間の短縮を続けています。ただし、起動時間は常に 0 より大きくなります。

Facebook はこの問題の影響を認識し、ナビゲーション リクエストを並行して実行する方法を求めました。

SW ブート
ナビゲーション リクエスト

ナビゲーションのプリロード

ナビゲーション プリロードは、「ユーザーが GET ナビゲーション リクエストを行ったら、Service Worker の起動中にネットワーク リクエストを開始」と言える機能です。

起動の遅延は変わりませんが、ネットワーク リクエストはブロックされないため、ユーザーはコンテンツをより早く取得できます。

次の動画では実際に動作しています。ここでは、while ループを使用して Service Worker に意図的に起動遅延 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 は、次の場合にレスポンスで解決される Promise です。

  • ナビゲーションのプリロードが有効になっています。
  • リクエストが 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 は、各リクエストのストリームを統合する小さな関数です。つまり、ネットワーク コンテンツのストリーミング中に、キャッシュされたヘッダーを表示できます。

これは「App Shell」よりも高速です。ネットワーク リクエストはページ リクエストとともに行われ、コンテンツは大規模なハッキングなしにストリーミングできます。

ただし、includeURL のリクエストは Service Worker の起動時間によって遅延します。これもナビゲーションのプリロードを使用して修正できますが、この場合はページ全体をプリロードするのではなく、インクルードをプリロードします。

これをサポートするため、プリロード リクエストごとにヘッダーが送信されます。

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 氏に感謝します。標準化の取り組みに関わってくださった皆様に深く感謝いたします