JavaScript
Polymer
preact
prpl
gatsby

PRPL パターン実装の具体例調査と比較

PRPL パターンとは

Google I/O 2016 で提案されたものですが、詳しくはこちらの記事がとても分かりやすいです。

https://developers.google.com/web/fundamentals/performance/prpl-pattern/?hl=ja

上の記事から一部を以下に引用します。

PRPL は、Progressive Web App(PWA)を構築および配信するためのパターンで、アプリの配信と起動時のパフォーマンスに重点を置いています。 PRPL は次の言葉を表しています。

Push: 最初の URL ルートに不可欠なリソースを Push(プッシュ)する。
Render: 最初のルートを Render(レンダリング)する。
Pre-cache: 残りのルートを Pre-cache(事前キャッシュ)する。
Lazy-load: オンデマンドで残りのルートを Lazy-load(遅延読み込み)する。

少しだけ補足すると、ここでいう Push というのは、 HTTP/2 Push を指します。
例えば JS ファイルをロードする場合、従来 (HTTP/1.1) は一度 HTML をロードしてから、 <script> タグで指定された JS ファイルをリクエストしていました。
また、 HTTP/1.1 では可能な平行リクエスト数にも制約があるため、多くのリソースファイルが必要になる場合はさらに遅延が大きくなってしまいます。
HTTP/2 Push を使う場合は、サーバーはブラウザに HTML を返すと同時に JS ファイルも push するため、必要な JS ファイルがロードされるまでの時間が大きく短縮されます。
またこのとき、必要なすべてのリソースファイルを平行に Push できます。

調査内容

以下に挙げる OSS, サイトの実装や挙動を参考に、それぞれがどのように PRPL パターンを実現しているかを調べました。

  • polymer-cli
  • preact-cli
  • Gatsby
  • dev.to
    • PRPL パターンを謳ってはいないようですが、考え方として近いものがあると思うので、取り上げました。

他にも、「この OSS で PRPL パターンやってるよ」みたいなのがあったら、コメント等いただければとても嬉しいです。

目的

PRPL パターンの具体的な実装方法を知ることを目的としています。
「これを使って作れば何も考えなくても勝手に PRPL パターンになる」というようなツール (polymer-cli や preact-cli のような) もありますが、それでも少なくとも原理を理解しておくことは重要でしょう。

調査結果

polymer-cli

polymer-cli は、 Web Components のライブラリ Polymer の CLI ツールです。
PRPL パターンが登場したのは、 Google I/O 2016 の Polymer and Progressive Web Apps: Building on the modern web のセッションなので、 PRPL パターンの本家と言えるでしょう。

デモとして Shop app が公開されていますが、これは Polymer CLI をインストールすると local でも簡単に動かせるようになります (詳細は https://github.com/Polymer/shop を参照)。

Push

Polymer の Push 戦略は、このページに詳しく説明されています。
"PRPL Pattern" というタイトルですが、内容はほぼ Push についてです。
特徴的なのは、ブラウザが HTTP/2 Push に対応しているか否かに応じて、異なるビルド産物を利用することです。
polymer-cli は $ polymer build というコマンドによってビルドを行いますが、このコマンドが複数のビルド結果を出力するようになっています (polymer.json の設定によります)。

HTTP/2 Push 対応ブラウザ向けのビルド産物

ビルド時に bundle を生成しません。
Shop app のページを Chrome で開き、 devtool の network タブを開くと、サーバーから大量のファイルが Push されてくることに気がつくと思います。

あるページがリクエストされた際に、どのファイルを Push すべきかは、ビルド時に出力される push-manifest.json によって知ることができます。
Shop app を local でビルドすると、以下のような push-manifest.json が出力されているのを確認できます。

{
  "src/shop-app.html": {
    "bower_components/polymer/polymer-element.html": {
      "type": "document",
      "weight": 1
    },
    "bower_components/polymer/lib/mixins/element-mixin.html": {
      "type": "document",
      "weight": 1
    },
    // ...以下略
  }
}

weight は Push する優先度を表すと思われますが (後述の preact-cli はそうなので)、ここでは全部 1 (最優先) になっていました。

この JSON ファイルがどのように使われるかというと、 レスポンスに preload link ヘッダー を付与するのに使われます。
このあたりの話は、 Polymer organization 配下で開発されている prpl-server-node の README で解説されていますが1

Link preload headers

prpl-server is designed to be used behind an HTTP/2 reverse proxy, and currently does not generate push responses itself. Instead it sets preload link headers, which are intercepted by cooperating reverse proxy servers and upgraded into push responses. Servers that implement this upgrading behavior include Apache, nghttpx, and Google App Engine.

要するに、レスポンスに preload link ヘッダーをつけてしまえば、 reverse proxy がそれを解釈して、レスポンスを返すのと一緒に preload link ヘッダーで指定されたリソースを Push してくれる、ということです。
prpl-server-node のコードを見てみると、実際に PushManifest というクラスで push-manifest.json の情報を元に preload link ヘッダーを付与する実装がされているのが確認できます2
また、 Shop app を開いて Chrome devtool の network タブを確認すると、ページのレスポンスヘッダーに以下のような preload link ヘッダーが付与されていることと、ここに記載されたリソースが実際に Push されていることが分かります。

link:</es6-unbundled/src/shop-button.html>; rel=preload; as=document
link:</es6-unbundled/bower_components/polymer/lib/utils/array-splice.html>; rel=preload; as=document
link:</es6-unbundled/src/shop-image.html>; rel=preload; as=document
link:</es6-unbundled/src/shop-app.html>; rel=preload; as=document
link:</es6-unbundled/bower_components/polymer/polymer-element.html>; rel=preload; as=document
link:</es6-unbundled/bower_components/polymer/lib/mixins/element-mixin.html>; rel=preload; as=document
# 量が非常に多いため以下略

HTTP/2 Push 非対応ブラウザ向けのビルド産物

ビルド時に以下のように bundle を生成します。

  • shell bundle
    • アプリあたり一つ生成される
    • アプリのトップレベルロジック、 router, 複数の fragment で共有されるコードを含む
  • fragment bundle
    • fragment (ページ等) ごとに一つ生成される

HTTP/2 Push 非対応ブラウザ向け bundle

https://www.polymer-project.org/2.0/toolbox/prpl

HTTP/2 Push 対応/非対応ブラウザの見分け方

prpl-server-node には、 UA を元に HTTP/2 Push に対応しているかどうかを判定し、適切なビルド産物を返す機能があります3
prpl-server-node を使わない場合は、同様の機能を自作する必要があるでしょう。

Render

この点に関しては特筆すべき工夫はされていなさそうです。
SSR (server side rendering) もサポートされていません4

Pre-cache

sw-precache を利用した、 service worker による Pre-cache の仕組みがあります。
service worker の各イベントのリスナーで、次のような処理を行います。

  • install (service worker の更新後最初にページにアクセスしたタイミングで発生)
    • sw-precache-config.js で指定したキャッシュ対象ファイルのキャッシュ
      • Shop app では、使用する HTML, JS, CSS, 画像ファイルはすべてキャッシュ対象に含まれている
    • skipWaiting() による即時 activate
      • service worker はデフォルトでは install 後は waiting の状態になり、リロード等で再度ページを開くと activate されるが、 skipWaiting() により waiting になることなく activate される
      • これにより、上記のキャッシュがすぐに有効になり、これ以降ナビゲーション等により新たなリソースファイルが必要になった場合、サーバーからのレスポンスを待つことなく利用可能
  • activate (skipWaiting() で activate された時に発生)
    • 古いキャッシュの削除
  • fetch
    • fetch 対象が install 時にキャッシュしたファイルに該当する場合、キャッシュされたファイルを返す

なお、 sw-precache とは別に、 sw-toolbox という service worker のライブラリも使われています。
実装の詳細は追えていませんが、 sw-precache が Web アプリのリソースファイルの Pre-cache を担当するのに対し、 sw-toolbox は API コールやサードパーティのリソースファイルなど、 Pre-cache に不向きなコンテンツの cache を担当するとのことです。

Specifically, it provides common caching strategies for dynamic content, such as API calls, third-party resources, and large or infrequently used local resources that you don't want precached.

sw-toolbox の README より

なお、 sw-toolbox の README の Support のセクションを読むと、現在 sw-precache, sw-toolbox の開発チームは Workbox という、両者の機能を合わせたライブラリの開発に取り組んでおり、新しいプロジェクトでは sw-precache, sw-toolbox ではなく Workbox を使うことが推奨されています。

Lazy-load

service worker 非対応ブラウザの場合は、上記の Pre-cache は行われないため、 route を遷移するタイミングでの Lazy-load になります。
また、 service worker 対応ブラウザに対しても、アプリの要件に応じて sw-precache-config.js で指定するキャッシュ対象ファイルから一部のファイルを除外することで、そのファイルを Pre-cache するものではなく Lazy-load するものとして扱うことができるでしょう。

preact-cli

preact-cli は、軽量さが売りの React like な UI ライブラリ Preact の CLI ツールです。
preact-cli も PRPL パターンのサポートを謳っています5

Polymer の Shop app と比べるとだいぶシンプルですが、デモも公開されています。

Push

内部的に PushManifestPlugin という webpack の plugin を作っていて6、これがビルド時に以下のような push-manifest.json を出力します。

{
  "/": {
    "style.eb021.css": {
      "type": "style",
      "weight": 1
    },
    "bundle.a5db3.js": {
      "type": "script",
      "weight": 1
    },
    "route-home.chunk.f678b.js": {
      "type": "script",
      "weight": 0.9
    }
  },
  "/profile": {
    "style.eb021.css": {
      "type": "style",
      "weight": 1
    },
    "bundle.a5db3.js": {
      "type": "script",
      "weight": 1
    },
    "route-profile.chunk.27ce5.js": {
      "type": "script",
      "weight": 0.9
    }
  }
}

polymer-cli で出力される push-manifest.json と同じフォーマットなので、 prpl-server-node にそのまま持っていっても動きそうです。
こちらでは weight の値に 0.9 というのも登場します。

preact-cli のコードにも、 push-manifest.json に基づいて preload link ヘッダーを付与する実装が存在することを確認できます7

polymer-cli との違い

polymer-cli との決定的な違いは、 HTTP/2 Push に対応しているか否かにかかわらず常に bundle することです。
ブラウザが HTTP/2 Push に対応していてもいなくても bundle 済みの同じファイルが使われますが、 HTTP/2 Push に対応していれば Push でファイルを取得できる分、非対応のブラウザより早くページの初期化を行えます。

どのような bundle ができるかというと、デフォルトでは node_modules 配下や router などのアプリケーションロジックをまとめた bundle.{hash}.js と、 route ごとに生成される route-{route-name}.chunk.{hash}.js しか生成されないようです。
例えば route a, route b で共通して使用するコンポーネントがあった場合、そのコードは route-a.chunk.{hash}.js にも、 route-b.chunk.{hash}.js にも入ってしまいます。
これには、以下のような問題があります。

  • 同じコードを重複してロードすることになる
  • 共通部分のコードが少しでも修正された場合、それに依存する route の bundle がすべて更新されてしまう (cache を破棄して読み込み直す必要がある)

これが気になる場合は、利用側で webpack のビルド設定をカスタマイズする8ことで、より効率の良い bundle を生成する余地はあるでしょう。
ただ、 PushManifestPlugin が、その辺柔軟に対応してくれなさそうな実装なので、このようなカスタマイズを行う場合はおそらく、 push-manifest.json への追記まで自分でやってあげる必要があります。

polymer-cli のやり方と比べると、サーバーサイドで UA を見て判定するような処理が不要になるメリットはあるものの、 HTTP/2 Push の良さを活かしきれない印象はあります。

Render

/ を静的な index.html として出力してくれる機能があります9
prerender-urls.json の記述により、 / 以外の route も静的ファイルとして出力可能です10

// prerender-urls.json
[{
  "url": "/",
  "title": "Homepage"
}, {
  "url": "/route/random"
}]

最初にアクセスされた route に対してはこの HTML を返すことにより、高速にページが表示されます。

Pre-cache, Lazy-load

preact-cli も sw-precache を使っているので、この部分は polymer-cli と基本的に同様です。
ただし、 sw-toolbox は使っていないので、例えば API コールの結果は cache されません。

Gatsby

Gatsby は React 製の静的サイトジェネレーターです。
2018/02/01 現在 v1.9.175 が最新ですが、 v1.0 をリリースに向けた作業の中で、 PRPL パターンを意識したパフォーマンス向上策が採用されました11

React のドキュメントが Gatsby でできているので、 Gatsby で作ったサイトがどのような挙動をするのか知りたい場合は、これをいじってみるといいでしょう。

Push

HTTP/2 Push に関する機能は特に無いようです。

Render

最初にアクセスする route は、静的な HTML ファイルとして取得されます。
Gatsby は SPA を作ってくれるので、最初にアクセスした route 以外はクライアントサイドでページが描画されます。

Pre-cache

https://www.gatsbyjs.org/docs/prpl-pattern/ の記述にある通り、

Gatsby sites render a static HTML version of the initial route and then load the code bundle for the page. Then immediately starts pre-caching resources for pages linked to from the initial route.

Gatsby は最初の route へのアクセス時、静的な HTML を取得し、その route 用の code bundle をロードした後、その route から link されているすべてのページのリソースを取得します。

https://reactjs.org/ を Chrome devtool の network タブを開きながら見るとこの様子がよく分かりますが、なかなか思い切った Pre-cache だと思います。

スクリーンショット 2018-02-02 1.27.50.png
メニューに載っている、まだ表示していないリンク先のリソース (`path--docs-*.js`) が Pre-cache で大量にロードされている様子

興味深いのは、この時 Pre-cache されるのは「コード」だけではなく、「データ」もだということです。
どういうことかというと、 Gatsby では、それぞれのページで必要なデータを、 GraphQL の query という形で宣言的に記述するようになっています。

import React from "react";

export default ({ data }) => (
  <div>
    <h1>About {data.site.siteMetadata.title}</h1>
    <p>We're a very cool website you should return to often.</p>
  </div>
);

export const query = graphql`
  query AboutQuery {
    site {
      siteMetadata {
        title
      }
    }
  }
`;

Querying data with GraphQL の sample より

上記で説明した Pre-cache の際には、この GraphQL の記述により取得されるデータも一緒に読み込まれます。
(Gatsby は静的サイトジェネレーターなので、そもそもビルド時にデータが書き込まれた状態で JS が生成されるというのが実態ではあるのですが)
このあたりの話は、 Gatsby v1.x の PRPL パターンの基本的な考え方について記述された以下の issue でも触れられています。

https://github.com/gatsbyjs/gatsby/issues/431

in short, each page can now specify exactly the critical data it needs to render which gets written out to a JSON file and loaded along with the page component code.

polymer-cli や preact-cli では、 Pre-cache されるのはあくまでもその route のリソースファイルだけなので、動的に情報を表示するような route では、遷移後に AJAX でデータを取ってくる必要があり、その分表示に遅延を生じるでしょう。
Gatsby の考え方を真似れば、データについても然るべきタイミングで Pre-cache することで、この遅延をなくすことができます。

Gatsby では、 service worker での cache はデフォルトでは行われません。
Gatsby は monorepo になっているのですが、 gatsby-plugin-offline という plugin があって、これによって service worker の機能を使えるようです。
具体的にどのような挙動になるかは調べていません。

Lazy-load

Pre-cache で Service Worker を利用しておらず、ブラウザに依存せず Pre-cache の段階で route に必要なファイルを読み込んでしまうため、 Lazy-load に該当するものは特になさそうです。

dev.to

Qiita と同様のプログラミング情報共有サービスです。
すごく速いということで、少し前に話題になりました。

なぜ dev.to がこんなにも速く、こんなにも自分にとって感動的なのか

dev.to はオープンソースではありませんが、いかにして高速なサイトを実装しているかについては、以下のような記事で紹介されています。

PRPL パターンを謳っている訳ではありませんが、考え方として同じことをやっていると考えて良いでしょう。

Push

今の所していません。

Render

SSR です。
初期レンダリングに必要な CSS は style タグとして HTML に埋め込み、 JS は全て async で取得することで、レンダリングブロックによる遅延を排除しています。
ブラウザは初期表示に際して HTML 以外のリソースの取得や JS の処理を必要としないため、迅速にレンダリングを行えます。

CSS を HTML の中に書いてしまうやり方は、 HTTP/2 Push を取り入れた場合でも有意に効果のあるものなのか、気になるところです。

Pre-cache

service worker による cache が行われています。
sw-precache や sw-toolbox は使われていませんが、両者を合わせたような実装 (Pre-cache + Pre-cache されないコンテンツの動的な cache) になっています。
ただし、 install 時に skipWaiting() を実行しないため、初回アクセス時は service worker でcache されたリソースは利用されません。

Lazy-load

タイミングとしては、 Pre-lazy-load とも呼ぶべき感があるので、 Lazy-load のところに書くべきかは迷うのですが、 InstantClick を利用して、 link への hover (mobile の場合は touchstart) のタイミングで link 先のページを先読みする仕組みがあります。
touchstart で先読みしてもそんなに変わらないのではないかと思ったのですが、 InstantClick のドキュメントによれば、

letting 300 ms (Android) to 450 ms (iOS) for preloading the page

だそうです。
それだけの時間があれば、かなり効果がありそうですね。

dev.to では、 InstantClick により取得される HTML には CSS の style タグがない (最初のアクセスで取得済みなので必要ない) など、余分なものを削ることでさらに高速化する工夫がなされています。

Polymer, React, Preact などの UI ライブラリを使っている場合は、 HTML ではなく JSON でデータだけ取得するところでしょうから、これは本質的には Gatsby のところで述べた「データ」の事前取得と同様のことを行なっていると考えられます。
先読みのタイミング的には、 Gatsby と比べると、先読みしたデータが無駄になる可能性がだいぶ低くなると見込まれます。

CDN での cache

"PRPL" には含まれていませんが、高速なサイトを実現する上で、 CDN から cache 済みコンテンツを配信する戦略は非常に重要であると考えられます。
dev.to の高速さを語る上で、この部分は避けては通れないでしょう。

dev.to は CDN として Fastly を利用していますが、ページをロードする際はまず静的なコンテンツを CDN のキャッシュから配信し、ログインユーザーによって変わるような動的なコンテンツは後から AJAX により origin サーバーから取得するようになっています。

これを実現するには、どのデータを更新したらどのページが stale になるのかを管理、運用する仕組みが必要になります (例えば dev.to のようなサイトであれば、記事が編集されたタイミングで CDN 上に存在するその記事の cache が stale になったことを CDN に通知する必要がある)。

dev.to が具体的にどのようにこれを実現しているのかは分かりませんが、 Fastly では Surrogate-Key という、 cache にタグをつけるような機能があり、 cache の管理に役立ちそうです。
例えば、 userId = 100 のユーザーの情報が表示される全てのページに /user/100 のような Surrogate-Key をつけておくと、そのユーザーのデータを更新した時に、この /user/100 という Surrogate-Key を指定して CDN の cache 削除 (purge) の API を実行すれば、 /user/100 の Surrogate-Key のついた全てのページの cache が消えてくれる、というものです。

まとめ

PRPL パターンの様々な実装方式について調べていくことで、だいぶ具体的なイメージが掴めるようになりました。
自分で作るとしたら、最初は preact-cli のようなシンプルな仕組みから作って、 polymer-cli のようにブラウザが HTTP/2 Push に対応しているか否かによってビルド産物を分けたり、 Gatsby と dev.to を参考に InstantClick で link 先のページのデータを取得したり、 dev.to にならって CDN を有効に活用したり、といった施策を追加していければいいのかなと思います。

注釈