ストリームによるマルチページ アプリケーションの高速化

最近では、ウェブサイトやウェブアプリでは、次の 2 つのナビゲーション スキームのいずれかを使用する傾向があります。

  • ブラウザには、デフォルトで用意されているナビゲーション スキームが用意されています。つまり、ブラウザのアドレスバーに URL を入力すると、ナビゲーション リクエストでドキュメントがレスポンスとして返されます。リンクをクリックすると、現在のドキュメントがアンロードされます。ドキュメントは ad infinitum です。
  • シングルページ アプリケーション パターン。アプリケーション シェルを読み込むための最初のナビゲーション リクエストが必要です。また、JavaScript を使用して、ナビゲーションごとにバックエンド API のコンテンツをクライアントがレンダリングするマークアップをアプリケーション シェルに入力します。

各アプローチのメリットは、次のように提案されています。

  • ブラウザがデフォルトで提供するナビゲーション スキームには復元力があります。ルートは JavaScript にアクセスしなくてもよいためです。JavaScript によるマークアップのクライアント レンダリングも高コストな処理になる可能性があります。つまり、ローエンドのデバイスでは、コンテンツを提供するスクリプトの処理がブロックされているためにコンテンツが遅延する可能性があります。
  • 一方、シングルページ アプリケーション(SPA)では、初期読み込み後のナビゲーションが高速になる場合があります。まったく新しいドキュメントのためにブラウザに依存してドキュメントをアンロードする(そしてナビゲーションのたびにこれを繰り返す)のではなく、より高速で「アプリ的」なものを提供できるJavaScript が必要な場合でも問題ありません。

この投稿では、前述の 2 つのアプローチのバランスを取る 3 つ目の方法について説明します。Service Worker を使用してウェブサイトの一般的な要素(ヘッダーとフッターのマークアップなど)を事前キャッシュに保存する方法と、ストリームを使用して、ブラウザのデフォルトのナビゲーション スキームを使用しながら可能な限り迅速にクライアントに HTML レスポンスを提供する方法です。

Service Worker で HTML レスポンスをストリーミングする理由

ストリーミングは、ウェブブラウザがリクエストしたときにすでに行っている処理です。これは、ナビゲーション リクエストのコンテキストでは非常に重要です。これにより、ブラウザがドキュメントのマークアップの解析を開始してページをレンダリングする前に、ブラウザがレスポンス全体を待ちブロックされることがなくなるためです。

非ストリーミング HTML とストリーミング HTML を示す図。前者の場合、マークアップ ペイロード全体が到着するまで処理されません。後者では、マークアップがネットワークからチャンクで到着するたびに段階的に処理されます。

Service Worker の場合、ストリーミングは JavaScript Streams API を使用するため若干異なります。Service Worker が実現する最も重要なタスクは、ナビゲーション リクエストを含むリクエストをインターセプトして応答することです。

これらのリクエストはさまざまな方法でキャッシュとやり取りできますが、マークアップの一般的なキャッシュ パターンは、最初にネットワークからのレスポンスを優先し、古いコピーが利用可能な場合はキャッシュにフォールバックすることです。使用可能なレスポンスがキャッシュにない場合は、オプションで汎用的なフォールバック レスポンスを返すこともできます。

これはマークアップの実績のあるパターンで、適切に機能しますが、オフライン アクセスに関する信頼性には役立ちますが、ネットワーク優先またはネットワークのみの戦略に依存するナビゲーション リクエストに対しては、本質的にパフォーマンス上のメリットはありません。そこで役に立つのがストリーミングです。Workbox Service Worker で Streams API を活用した workbox-streams モジュールを使用して、複数ページのウェブサイトでのナビゲーション リクエストを高速化する方法について説明します。

一般的なウェブページの内訳

構造的には、ウェブサイトはすべてのページに存在する共通の要素を持つ傾向があります。多くの場合、ページ要素の一般的な配置は次のようになります。

  • ヘッダー。
  • コンテンツ。
  • フッター。

たとえば、web.dev でよく使用される要素の内訳は次のようになります。

web.dev ウェブサイトの共通要素の内訳。図で示される共通領域は、「header」、「content」、「footer」です。

ページの一部を特定する目的は、ネットワークを経由せずにプリキャッシュおよび取得できるもの、つまりすべてのページに共通するヘッダーとフッターのマークアップ、そして常に最初にネットワークにアクセスするページの一部(この場合はコンテンツ)を特定することです。

ページの各部分をセグメント化して共通の要素を特定する方法がわかっていれば、Service Worker を作成できます。この Service Worker は、ヘッダーとフッターのマークアップを常にキャッシュから即座に取得しながら、ネットワークからコンテンツのみをリクエストします。

そして、workbox-streams を介して Streams API を使用することで、これらの要素をすべてつなぎ合わせ、ナビゲーション リクエストに即座に応答でき、同時に必要最小限のマークアップをネットワークからリクエストできます。

ストリーミング Service Worker の構築

Service Worker で部分的なコンテンツをストリーミングする場合、多くの流動的な要素がありますが、プロセスの各ステップを詳しく見ながら、まずはウェブサイトの構成方法を見ていきます。

ウェブサイトを部分的なセグメントに分割する

ストリーミング Service Worker の作成を開始する前に、次の 3 つの作業を行う必要があります。

  1. ウェブサイトのヘッダー マークアップのみを含むファイルを作成します。
  2. ウェブサイトのフッター マークアップのみを含むファイルを作成します。
  3. 各ページのメイン コンテンツを個別のファイルに pull するか、HTTP リクエスト ヘッダーに基づいてページ コンテンツのみを条件付きで配信するようにバックエンドを設定します。
で確認できます。

ご想像のとおり、特にウェブサイトが静的な場合は、最後のステップが最も難しくなります。その場合は、各ページの 2 つのバージョンを生成する必要があります。1 つのバージョンにはページのマークアップを含み、もう 1 つのバージョンにはコンテンツのみを含めます。

ストリーミング Service Worker の作成

workbox-streams モジュールをまだインストールしていない場合は、現在インストールされている Workbox モジュールに加えてインストールする必要があります。この特定の例では、次のパッケージを使用します。

npm i workbox-navigation-preload workbox-strategies workbox-routing workbox-precaching workbox-streams --save

次に、新しい Service Worker を作成し、ヘッダーとフッターの一部を事前キャッシュに保存します。

部分的なプリキャッシュ

まず、sw.js(または任意のファイル名)という名前のプロジェクトのルートに Service Worker を作成します。まず、以下の内容から始めます。

// sw.js
import * as navigationPreload from 'workbox-navigation-preload';
import {NetworkFirst} from 'workbox-strategies';
import {registerRoute} from 'workbox-routing';
import {matchPrecache, precacheAndRoute} from 'workbox-precaching';
import {strategy as composeStrategies} from 'workbox-streams';

// Enable navigation preload for supporting browsers
navigationPreload.enable();

// Precache partials and some static assets
// using the InjectManifest method.
precacheAndRoute([
  // The header partial:
  {
    url: '/partial-header.php',
    revision: __PARTIAL_HEADER_HASH__
  },
  // The footer partial:
  {
    url: '/partial-footer.php',
    revision: __PARTIAL_FOOTER_HASH__
  },
  // The offline fallback:
  {
    url: '/offline.php',
    revision: __OFFLINE_FALLBACK_HASH__
  },
  ...self.__WB_MANIFEST
]);

// To be continued...

このコードは、いくつかの処理を行います。

  1. ナビゲーションのプリロードサポートしているブラウザで有効にします。
  2. ヘッダーとフッターのマークアップを事前キャッシュします。つまり、すべてのページのヘッダーとフッターのマークアップは、ネットワークによってブロックされないため、即座に取得されます。
  3. injectManifest メソッドを使用する __WB_MANIFEST プレースホルダの静的アセットを事前キャッシュします。
で確認できます。

ストリーミング レスポンス

連結されたレスポンスを Service Worker にストリーミングさせる作業が、この作業全体の中で最も大きな部分を占めます。それでも、Workbox とその workbox-streams を使用すると、すべての作業を独力で行うよりもはるかに簡潔になります。

// sw.js
import * as navigationPreload from 'workbox-navigation-preload';
import {NetworkFirst} from 'workbox-strategies';
import {registerRoute} from 'workbox-routing';
import {matchPrecache, precacheAndRoute} from 'workbox-precaching';
import {strategy as composeStrategies} from 'workbox-streams';

// ...
// Prior navigation preload and precaching code omitted...
// ...

// The strategy for retrieving content partials from the network:
const contentStrategy = new NetworkFirst({
  cacheName: 'content',
  plugins: [
    {
      // NOTE: This callback will never be run if navigation
      // preload is not supported, because the navigation
      // request is dispatched while the service worker is
      // booting up. This callback will only run if navigation
      // preload is _not_ supported.
      requestWillFetch: ({request}) => {
        const headers = new Headers();

        // If the browser doesn't support navigation preload, we need to
        // send a custom `X-Content-Mode` header for the back end to use
        // instead of the `Service-Worker-Navigation-Preload` header.
        headers.append('X-Content-Mode', 'partial');

        // Send the request with the new headers.
        // Note: if you're using a static site generator to generate
        // both full pages and content partials rather than a back end
        // (as this example assumes), you'll need to point to a new URL.
        return new Request(request.url, {
          method: 'GET',
          headers
        });
      },
      // What to do if the request fails.
      handlerDidError: async ({request}) => {
        return await matchPrecache('/offline.php');
      }
    }
  ]
});

// Concatenates precached partials with the content partial
// obtained from the network (or its fallback response).
const navigationHandler = composeStrategies([
  // Get the precached header markup.
  () => matchPrecache('/partial-header.php'),
  // Get the content partial from the network.
  ({event}) => contentStrategy.handle(event),
  // Get the precached footer markup.
  () => matchPrecache('/partial-footer.php')
]);

// Register the streaming route for all navigation requests.
registerRoute(({request}) => request.mode === 'navigate', navigationHandler);

// Your service worker can end here, or you can add more
// logic to suit your needs, such as runtime caching, etc.

このコードは、次の要件を満たす 3 つの主要部分で構成されています。

  1. NetworkFirst 戦略は、部分的なコンテンツのリクエストを処理するために使用されます。この戦略を使用して、コンテンツ部分を含むカスタム キャッシュ名 content を指定します。また、ナビゲーションのプリロードをサポートしていない(したがって Service-Worker-Navigation-Preload ヘッダーを送信しない)ブラウザに対して X-Content-Mode リクエスト ヘッダーを設定するかどうかを処理するカスタム プラグインを指定します。このプラグインは、コンテンツの一部の最後のキャッシュ バージョンを送信するか、現在のリクエストのキャッシュ バージョンが保存されていない場合にオフラインの代替ページを送信するかも判断します。
  2. workbox-streamsstrategy メソッド(ここでは composeStrategies と別名)は、ネットワークからリクエストされたコンテンツの部分的と、事前キャッシュされたヘッダーとフッターの部分的を連結するために使用されます。
  3. スキーム全体は、ナビゲーション リクエスト用に registerRoute を介して実装されます。

このロジックが配置されたので、レスポンスのストリーミングが設定されました。ただし、ネットワークからのコンテンツを、事前キャッシュされた部分的なページと統合できる部分ページとして確保するには、バックエンドでなんらかの作業が必要になることがあります。

ウェブサイトにバックエンドがある場合

ナビゲーションのプリロードが有効になっている場合、ブラウザは値が trueService-Worker-Navigation-Preload ヘッダーを送信します。ただし、上記のコードサンプルでは、ブラウザでサポートされていないイベント ナビゲーションのプリロードで X-Content-Mode のカスタム ヘッダーを送信しました。バックエンドでは、これらのヘッダーの存在に基づいてレスポンスを変更します。PHP バックエンドで、特定のページについては次のようになります。

<?php
// Check if we need to render a content partial
$navPreloadSupported = isset($_SERVER['HTTP_SERVICE_WORKER_NAVIGATION_PRELOAD']) && $_SERVER['HTTP_SERVICE_WORKER_NAVIGATION_PRELOAD'] === 'true';
$partialContentMode = isset($_SERVER['HTTP_X_CONTENT_MODE']) && $_SERVER['HTTP_X_CONTENT_MODE'] === 'partial';
$isPartial = $navPreloadSupported || $partialContentMode;

// Figure out whether to render the header
if ($isPartial === false) {
  // Get the header include
  require_once($_SERVER['DOCUMENT_ROOT'] . '/includes/site-header.php');

  // Render the header
  siteHeader();
}

// Get the content include
require_once('./content.php');

// Render the content
content($isPartial);

// Figure out whether to render the footer
if ($isPartial === false) {
  // Get the footer include
  require_once($_SERVER['DOCUMENT_ROOT'] . '/includes/site-footer.php');

  // Render the footer
  siteFooter();
}
?>

上記の例では、コンテンツの一部は関数として呼び出され、$isPartial の値を受け取って部分的なレンダリング方法を変更します。たとえば、content レンダラ関数では、部分として取得された場合にのみ、条件に特定のマークアップを含めることができます。これについては後ほど説明します。

考慮事項

部分的なストリーミングと合成を行う Service Worker をデプロイする前に、考慮すべき点がいくつかあります。この方法で Service Worker を使用してもブラウザのデフォルトのナビゲーション動作が根本的に変わることはありませんが、対処が必要な問題がいくつかあります。

操作時のページ要素の更新

このアプローチの最も難しい部分は、クライアントでいくつかの項目の更新が必要になることです。たとえば、ヘッダー マークアップの事前キャッシュでは、ページの <title> 要素に同じコンテンツが含まれ、ナビゲーション アイテムのオン/オフ状態の管理も、ナビゲーションごとに更新する必要があります。これらおよびその他の項目は、ナビゲーション リクエストごとにクライアントで更新する必要があります。

この問題を回避するには、ネットワークから取得されるコンテンツ パーシャルにインラインの <script> 要素を配置し、重要な点を更新します。

<!-- The JSON below contains information about the current page. -->
<script id="page-data" type="application/json">'{"title":"Sand Wasp &mdash; World of Wasps","description":"Read all about the sand wasp in this tidy little post."}'</script>
<script>
  const pageData = JSON.parse(document.getElementById('page-data').textContent);

  // Update the page title
  document.title = pageData.title;
</script>
<article>
  <!-- Page content omitted... -->
</article>

これは、この Service Worker 設定で必要な作業の一例にすぎません。ユーザー情報を扱う複雑なアプリケーションの場合、localStorage などのウェブストアに関連データを保存し、そこからページを更新する必要があります。

低速のネットワークへの対処

プレキャッシュからのマークアップを使用してレスポンスをストリーミングすることの欠点の 1 つは、ネットワーク接続が遅い場合に発生します。問題は、プリキャッシュからのヘッダー マークアップが即座に到着するものの、ネットワークからのコンテンツ部分がヘッダー マークアップの最初のペイント後に届くまでにかなりの時間がかかる場合があることです。

これは混乱を招くエクスペリエンスをもたらし、ネットワークが非常に遅い場合、ページが壊れているように感じられ、それ以上レンダリングされないことがあります。このような場合は、コンテンツ部分のマークアップに読み込み中アイコンやメッセージを配置して、コンテンツの読み込み後に非表示にすることができます。

その方法の 1 つは CSS を使用することです。たとえば、ヘッダーの一部分が開始の <article> 要素で終わっているとします。この要素は、コンテンツの部分が到着してデータが挿入されるまで空のままとなります。次のような CSS ルールを作成できます。

article:empty::before {
  text-align: center;
  content: 'Loading...';
}

これは機能しますが、ネットワーク速度に関係なくクライアントに読み込みメッセージが表示されます。おかしな表示を避けたい場合は、上記のスニペットのセレクタを slow クラス内にネストしている、この方法をお試しください。

.slow article:empty::before {
  text-align: center;
  content: 'Loading...';
}

ここから、ヘッダーの部分で JavaScript を使用して(少なくとも Chromium ブラウザで)有効な接続タイプを読み取り、選択した接続タイプの <html> 要素に slow クラスを追加できます。

<script>
  const effectiveType = navigator?.connection?.effectiveType;

  if (effectiveType !== '4g') {
    document.documentElement.classList.add('slow');
  }
</script>

これにより、4g タイプよりも低速な有効な接続タイプに読み込みメッセージが表示されるようになります。次に、コンテンツ部分にインライン <script> 要素を配置して、HTML から slow クラスを削除して、読み込みメッセージを取り除きます。

<script>
  document.documentElement.classList.remove('slow');
</script>

フォールバック レスポンスの提供

たとえば、部分的なコンテンツに対してネットワーク ファースト戦略を使用しているとしましょう。ユーザーがオフラインで、以前訪れたことのあるページにアクセスすると、保護されます。ただし、まだ訪れたことがないページにアクセスした場合は、何も表示されません。これを回避するには、フォールバック レスポンスを提供する必要があります。

フォールバック レスポンスを実現するために必要となるコードは、以前のコードサンプルで示されています。このプロセスには、次の 2 つのステップが必要です。

  1. オフラインのフォールバック レスポンスを事前キャッシュに保存する。
  2. ネットワーク ファースト戦略でプラグインに handlerDidError コールバックを設定し、ページに最後にアクセスされたバージョンのキャッシュをチェックします。そのページにアクセスしたことがない場合は、workbox-precaching モジュールmatchPrecache メソッドを使用して、プレキャッシュから代替レスポンスを取得する必要があります。

キャッシュと CDN

Service Worker でこのストリーミング パターンを使用している場合は、次の状況に当てはまるかどうかを評価してください。

  • CDN またはその他の種類の中間/パブリック キャッシュを使用している。
  • public ディレクティブと組み合わせて、ゼロ以外の max-age ディレクティブまたは s-maxage ディレクティブ(あるいはその両方)を含む Cache-Control ヘッダーを指定した場合。

この両方に当てはまる場合、中間キャッシュでナビゲーション リクエストのレスポンスが保持されることがあります。ただし、このパターンを使用すると、特定の URL に対して次の 2 つの異なる応答が返される可能性があるので注意してください。

  • ヘッダー、コンテンツ、フッターのマークアップを含む完全なレスポンス。
  • コンテンツのみを含む部分的なレスポンス。

これにより、Service Worker が CDN キャッシュから完全なレスポンスを取得し、事前キャッシュされたヘッダーとフッターのマークアップと結合して、ヘッダーとフッターのマークアップが二重に増え、望ましくない動作が発生することがあります。

これを回避するには、Vary ヘッダーを使用する必要があります。このヘッダーは、リクエストに存在する 1 つ以上のヘッダーにキャッシュ可能なレスポンスをキー付けすることで、キャッシュ動作に影響を与えます。ナビゲーション リクエストに対するレスポンスは、Service-Worker-Navigation-Preload とカスタム X-Content-Mode リクエスト ヘッダーに基づいて異なるため、レスポンスに次の Vary ヘッダーを指定する必要があります。

Vary: Service-Worker-Navigation-Preload,X-Content-Mode

このヘッダーを使用すると、ブラウザはナビゲーション リクエストに対して完全なレスポンスと部分的なレスポンスを区別し、中間キャッシュと同様に、ヘッダーとフッターのマークアップが二重化される問題を回避します。

結果

読み込み時間のパフォーマンスに関するアドバイスの要点は、ほとんどの場合、「得られたものを見せる」ことです。ためらわずに、必要なものがすべて揃うまで待たずにユーザーに何かを見せましょう。

<ph type="x-smartling-placeholder"></ph> Jake Archibald の「Fun Hacks for Faster Content

ブラウザは、巨大な HTML レスポンス本文であっても、ナビゲーション リクエストへのレスポンスを処理する点で優れています。デフォルトでは、ブラウザは長いタスクを避けてチャンク形式でマークアップを段階的にストリーミングして処理するため、起動時のパフォーマンスが向上します。

これは、ストリーミング Service Worker パターンを使用する際に役立ちます。Service Worker のキャッシュからのリクエストに対して最初から応答するときは常に、レスポンスの開始がほぼ瞬時に到着します。プリキャッシュされたヘッダーとフッターのマークアップをネットワークからのレスポンスと組み合わせると、次のようなパフォーマンス面で顕著な利点が得られます。

  • ナビゲーション リクエストに対するレスポンスの最初のバイトが即時であるため、多くの場合、Time to First Byte(TTFB)は大幅に短縮されます。
  • First Contentful Paint(FCP)非常に高速です。キャッシュされたヘッダー マークアップにキャッシュされたスタイルシートへの参照が含まれるため、ページがすぐに描画されます。
  • 場合によっては、Largest Contentful Paint(LCP)の方が処理速度が速い場合もあります。特に、画面上の最大の要素が事前キャッシュされたヘッダー部分によって提供される場合などです。それでも、できるだけ早く Service Worker のキャッシュから、小さなマークアップ ペイロードと併せて何かを提供するだけで、LCP が向上する可能性があります。

複数ページのストリーミング アーキテクチャは、設定とイテレーションが少し難しい場合がありますが、それに伴う複雑さは、理論上は SPA ほど面倒なものではありません。主な利点は、ブラウザのデフォルトのナビゲーション スキームを置き換えるのではなく、拡張していることです。

さらに優れた点として、Workbox では、このアーキテクチャを単に実現できるだけでなく、自分で実装する場合よりも簡単です。ご自身のウェブサイトで試してみて、現場のユーザー向けに複数ページのウェブサイトがどれくらい高速になるか試してみましょう。

リソース