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

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

Browser Support

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

Source

概要

問題

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

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

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

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

サービス ワーカーの起動により、ネットワーク リクエストが遅延しています。

Google では、V8 でのコード キャッシュの使用fetch イベントのないサービス ワーカーのスキップサービス ワーカーの投機的起動などの最適化により、起動時間の短縮を継続しています。ただし、起動時間は常に 0 より大きくなります。

Facebook は、この問題の影響を Google に伝え、ナビゲーション リクエストを並行して実行する方法を尋ねました。

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

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

ナビゲーション プリロードは、「ユーザーが GET ナビゲーション リクエストを行ったときに、サービス ワーカーの起動中にネットワーク リクエストを開始する」という処理を可能にする機能です。

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

次の動画は、サービス ワーカーに while ループを使用して 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 で解決されます。

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

記事を表示したいとします。

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

たとえば、ローカルにキャッシュ保存した最後の投稿の 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 に感謝します。標準化の取り組みに関わったすべての人に感謝します。