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

最近では、ウェブサイト(またはウェブアプリ)は、次の 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 ウェブサイトの共通要素の内訳境界線を引く共通領域は、「ヘッダー」、「コンテンツ」、「フッター」のマークによって示されます。

ページの一部を識別する目的は、ネットワークを経由せずに事前キャッシュして取得できる情報(すべてのページに共通のヘッダーとフッターのマークアップ)と、ページ内で常に最初にネットワークにアクセスする部分(この場合はコンテンツ)を決定することです。

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

次に、workbox-streams を介して Streams API を使用して、これらすべての部分をつなぎ合わせ、ナビゲーション リクエストに即座に応答します。しかも、ネットワークに必要最低限のマークアップを要求します。

ストリーミング Service Worker をビルドする

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

ウェブサイトを部分的に分割する

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

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

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

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

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

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 つは、ネットワーク接続が遅い場合に発生することがあります。問題は、プリキャッシュのヘッダー マークアップは即座に到着するが、ネットワークからのコンテンツ部分は、ヘッダー マークアップの最初のペイントから到着するまでにかなり時間がかかる可能性があることです。

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

その方法の一つが 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 またはその他の中間/公開キャッシュを使用します。
  • Cache-Control ヘッダーに、ゼロ以外の max-age または s-maxage ディレクティブを public ディレクティブと組み合わせて指定しています。

両方が当てはまる場合、中間キャッシュはナビゲーション リクエストのレスポンスを保持することがあります。ただし、このパターンを使用する場合、任意の URL に対して 2 種類のレスポンスが返される可能性があります。

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

これにより、望ましくない動作が発生し、Service Worker が CDN キャッシュから完全なレスポンスを取得して、それを事前にキャッシュに保存されたヘッダーとフッターのマークアップと組み合わせる可能性があるため、ヘッダーとフッターのマークアップが 2 倍になる可能性があります。

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

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

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

結果

読み込み時間のパフォーマンスに関するほとんどのアドバイスは、要するに「実際に得られた結果を示す」ことにあります。ためらわずに、すべてを用意できるまで待たずにユーザーに何かを表示します。

Jake Archibald(Fun Hacks for Faster Content

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

これは、ストリーミング Service Worker パターンを使用する場合に効果的です。最初から Service Worker のキャッシュからのリクエストに応答すると、ほぼ瞬時にレスポンスの開始が到着します。事前にキャッシュに保存されたヘッダーとフッターのマークアップをネットワークからのレスポンスとつなぎ合わせると、次のような顕著なパフォーマンス向上が得られます。

  • ナビゲーション リクエストに対するレスポンスの最初のバイトが即座に処理されるため、最初のバイトまでの時間(TTFB)が大幅に短縮されることがよくあります。
  • First Contentful Paint(FCP)は、非常に高速になります。事前キャッシュされたヘッダー マークアップには、キャッシュされたスタイルシートへの参照が含まれます。つまり、ページがごく短時間で描画されます。
  • 場合によっては、Largest Contentful Paint(LCP)も高速化されることがあります。特に、事前キャッシュされたヘッダーの部分的な部分によって画面上の最大要素が提供される場合は、さらに処理が速くなります。それでも、小規模なマークアップ ペイロードと並行して、できるだけ早く Service Worker のキャッシュからなんらかの配信を行うだけで、LCP が向上する場合があります。

複数ページのアーキテクチャのストリーミングは、セットアップとイテレーションが少し難しい場合がありますが、複雑さは多くの場合、理論的には SPA ほど厄介ではありません。主なメリットは、ブラウザのデフォルトのナビゲーション スキームが置き換えられるのではなく、強化されることです。

さらに、Workbox ではこのアーキテクチャが可能であるだけでなく、独自に実装するよりも簡単です。ご自身のウェブサイトでお試しいただき、複数ページ構成のウェブサイトを実際に利用するユーザーにとってどれだけ高速化できるか試してみてください。

関連情報