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

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

対応ブラウザ

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

ソース

概要

問題

サービス ワーカーを使用してフェッチ イベントを処理するサイトに移動すると、ブラウザはサービス ワーカーにレスポンスを要求します。これには、Service Worker を起動(まだ実行されていない場合)し、fetch イベントをディスパッチすることが含まれます。

起動時間は、デバイスと状況によって異なります。通常は 50 ミリ秒ほどです。モバイルでは 250 ミリ秒程度です。極端な場合(デバイスが遅い、CPU が過負荷状態)は 500 ミリ秒を超えることがあります。ただし、サービス ワーカーはイベント間のブラウザが決定した時間だけアウェイク状態になるため、この遅延が発生するのは、ユーザーが新しいタブまたは別のサイトからサイトに移動したときなど、まれなケースです。

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

SW の起動
ナビゲーション リクエスト

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

Google は、V8 でコード キャッシュを使用するフェッチ イベントのないサービス ワーカーをスキップするサービス ワーカーを推測的に起動するなどの最適化により、起動時間を短縮し続けています。ただし、起動時間は常にゼロより大きくなります。

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 イベントで使用するため、サービス ワーカーの 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 に解決されます。

ページにネットワークからのデータが必要な場合は、サービス ワーカーでデータをリクエストし、キャッシュとネットワークの一部を含む単一のストリーミング レスポンスを作成するのが最も簡単な方法です。

記事を表示するとします。

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 の起動時間によって遅延します。ナビゲーション プリロードを使用してこの問題を解決することもできますが、この場合はページ全体をプリロードするのではなく、インクルードをプリロードします。

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

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 氏に感謝いたします。標準化の取り組みに関わったすべての方々に心より感謝いたします。