embryo

エンジニアの備忘録

PWAを支える技術とPRPLパターンの実装

この記事はトレタ Advent Calendar 2017の11日目の記事です。

dev.toで最近話題になったPWA実装ですが、今日はそのPWA設計パターンの1つであるPRPLを実践するために必要な技術スタックとその実装方法についてまとめました。

PRPLパターンとは

Googleが提唱するPWAの設計パターンの1つです。

PRPL パターン  |  Web  |  Google Developers

  • (P)ush: HTTP/2 Pushを用いて初期表示に必要なリソースを配信します
  • (R)ender: 最小限の初期描画を行い、インタラクティブな状態にします
  • (P)re-cache: ServiceWorkerを用いて事前に他のルートのリソースをキャッシュします
  • (L)azy-load: ユーザー操作に合わせてオンデマンドにリソースの配信と生成を行います

これらの頭文字をとってPRPLと呼んでいるようです。

PRPLを支える技術

PRPLを実現するための重要な技術としてHTTP/2 Server PushServiceWorkerがあります。

HTTP/2 Server Push

従来のHTTP/1.1のリクエストでは

  • htmlファイルをリクエストする
  • htmlファイルを解析する
  • Javscript, cssなどのリソースが見つかったら読み込む

というステップで初期表示が行われていましたが、HTTP/2 Server Pushを用いることで1つのリクエストに対して複数のレスポンスを送信することが可能になります、つまり要求されたリソースに加えて、リソースを追加して送信することができるので一度のリクエストで初期の表示に必要なリソースを返却することが出来ます。

実装方法として後述する2つの方法があげられます。

ストリームを直接操作する方法

下記の例はnodejs+expressの実装例です。streamを操作し、レスポンスにリソースを追加しています。

gist.github.com

Linkヘッダを利用する方法

下記のようなLinkヘッダををレスポンスに追加することでServerPushのトリガーとなります。 厳密にはリソースヒントと呼ばれる仕様でH2O, nghttp2などではServerPushのトリガーとして扱っているようです。

link: <[url]>; rel=preload; as=[file type]

[url]にはリソースへのURLが、[file type]にはstyle, scriptなどのファイルタイプが入ります。

ServiceWorker

Service Workerはページ上で実行されるスクリプトとは別のスレッドでで別のスクリプトを実行することができる機能です。ネットワークプロキシとしてリクエストをコントロールしたりデータをキャッシュすることが可能です。

ライフサイクル

ServiceWorkerは登録→インストール→アクティベートの順に状態が遷移します。

登録

Service Workerスクリプトを登録します。<scope>を渡すことで特定のルート以下をコントロールすることを明示できます。

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register(‘/sw.js’, {scope: <scope>)
  .then(function(registration) {
    console.log(’Success registration’);
  })
  ;
}
インストール

登録が完了するとinstall状態へ移行します。install時に処理を登録した場合は下記のようにハンドリングできます。

self.addEventListener('install', function(event) {
  console.log(“Service worker is installed")
});
アクティベート

Workerに登録したjsの更新時、ServiceWorkerがすでにインストールされている場合はデータの不整合を防ぐために、Workerが更新可能な状態まで待つことになります(ページを閉じるなどした場合に更新可能になります)。そのため、コントロール可能な状態をハンドリングしたい場合はactivateイベントに処理を登録する必要があります。

不整合が起きないことが予めわかっている場合に、すぐにactivateな状態に移したい場合は

self.addEventListener(‘activate', function(event) {
     event.waitUntil(self.skipWaiting());
});

また、workerがコントロールするのは登録後に開かれたページなので、すぐさまページをコントロールしたい場合は

self.addEventListener(‘activate', function(event) {
    event.waitUntil(self.clients.claim());
});

のようにすることですぐにページのコントロールを開始することが出来ます。

キャッシュ戦略

Service Workerをキャシュに用いる場合、2つの方法があります。

  • Install時にキャッシュを更新する
  • オンデマンドにキャッシュを更新する
Install時にキャッシュを更新する

予めキャッシュするリソースを指定し、installイベント時にキャッシュを更新します。PreCacheを実現するためにはこの方法を用いるのが良さそうです。

var CACHE_NAME = 'cache-v1';
var urlsToCache = [
  '/',
  '/styles/main.css',
  '/script/main.js'
];

self.addEventListener('install', function(event) {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(function(cache) {
        console.log('Opened cache');
        return cache.addAll(urlsToCache);
      })
  );
});
オンデマンドにキャッシュを更新する

fetchイベントを通してファイル単位でキャッシュを更新します。リクエストを逐次キャッシュすることが出来るため2回目のアクセスを高速化できます。streamは用途に応じて複製する必要があるため扱いに注意してください。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request)

      .then(function(response) {
        if (response) {
          return response;

        }

          // キャッシュ用とリクエスト用が必要なのでclone
        var fetchRequest = event.request.clone();

        return fetch(fetchRequest).then(

          function(response) {
            // リクエストが成功しており、なおかつoriginからの要求であること
            if(!response || response.status !== 200 || response.type !== 'basic') {
              return response;

            }
            // キャッシュ用とブラウザに返却する用が必要なのでclone
            var responseToCache = response.clone();

            caches.open(CACHE_NAME)
              .then(function(cache) {
                cache.put(event.request, responseToCache);
              });

            return response;
          }
        );
      }
    )
  );
});

PRPLをReactで実装する

以下の一般的なフロントエンド技術スタックでPRPLを実装する方法を考えます。

  • react
  • react-router
  • webpack

Push

HTTP/2 Server Pushに対応した配信サーバーを構築します。

webpackを使用する場合はpreact-cliの実装を参考にするのが良さそうです。 preact-cliの実装ではビルド時にこちらのフォーマットでファイルを出力し、配信時にLinkヘッダを付加することでServerPushを実現しているようです。

https://github.com/GoogleChromeLabs/simplehttp2server#http2-push

PreCache

初期表示完了後に残りのルートに必要なファイルをServiceWorkerのinstall時にキャッシュします。Install時のキャッシュ方法に関しては前項で説明しましたが、

sw-precache-webpack-pluginを使用することでServiceWorker用のスクリプトをwebpackビルド時に自動で生成することが出来ます。キャッシュ対象としてwebpackでビルドしたリソースが自動で追加されますが、設定項目でカスタマイズが可能です。

生成されたservice-worker.jsをエントリポイントで登録します。

Lazy-Load

オンデマンドロードを実現するために、リソースの分割とロード方法について考えます。

webpackによるリソース分割

リソースを取得する際、極端な例として2つの方針が考えられます。

  • 1リクエストに対して1モジュールを返す方法
  • 1リクエストに対してバンドルされた1ファイルを返す方法

前者は必要なリソースのみを取得できますが、リクエストが多くなってしまうためリクエストのオーバーヘッドが大きくなります。 後者は1度のリクエストで済むためオーバーヘッドは小さくなりますが不要なファイルも読み込むことになります。

これに対してwebpackでは、その折衷案としてある程度のまとまり(chunk)にファイルを分割する機能が提供されています。Push, PreCacheを有効に使うためにアプリケーションのバンドルを適切に分割する必要があるため、アプリケーションのコア(AppShell)と各ルートのchunkに分割するのが良さそうです。

リソース読み込み時にrequire.ensureを呼び出すことでchunkの分割対象と見なしてくれるようになります。(webpack2以降ではDymanic Importもサポートされているようです)

ルーティング

遷移時に各ルートのリソースを取得するためにreact-routerのgetComponentを呼び出します。webpackの設定ファイルにてchunkFilenameを指定しておくことで、require.ensure で指定した名前でファイルが作成されます。

  • react-routerの設定
<Route
     path=“/list"
     getComponent={(location, callback) => {
         (require as any).ensure([], () => {
             callback(null, require("./List"))
          }, "List")
      }}
/>
  • wepackの設定
output: {
     path: outputPath,
     filename: "[name].js”,
     chunkFilename: "[name].js"
}

対応状況

ServiceWorkerに関してはまだ非対応ブラウザも多いですが、フォールバックが可能な設計になっているため、現段階で使用可能な技術です。あまりにキャッシュに頼った設計にしてしまうと、非対応ブラウザとのパフォーマンス差が大きくなってしまうため注意が必要です。

まとめ

PRPLパターンを実装する上で必要な技術スタックと設計についてまとめました。dev.toはPRPLライクな実装に加えてレンダリングのチューニングやInstantClick+SSRを利用した静的コンテンツキャッシュにより高速な遷移を実現しています。Instantly Fastを実現するためにはサービスの設計段階からパフォーマンスを考慮しておく必要がありそうです。

参考

PRPLパターン

HTTP/2

ServiceWorker