ES6 ModulesはHTTP/2によってconcat無しで使えるようになるのか

HTTP2 時代のサーバサイドアーキテクチャフィードバック - Togetterまとめ のあたりで話していたことのまとめ。

補足

タイトルで「ES6 Modulesってconcatしないと動かないの?」と一部に誤解を与えてしまったようなので補足。ES6 Modulesがブラウザにネイティブ実装されたら、当然concatしなくても動きます。

ここで書きたかったテーマは「ES6 Modules + HTTP/2 + concat無しES6 Modules + HTTP/1 + concat と同等の速度で動作するのか」です。

追追記 (2016/01)

kazuhoさんはh2oで Cache Aware Server Push という解決策を提案しています。

Jxckによる日本語解説記事: HTTP/2 Push を Service Worker + Cache Aware Server Push で効率化したい話 - Block Rockin’ Codes

なぜ今までconcatしていたのか

理由は2つ。

  • コネクションを減らすため
  • ラウンドトリップを減らすため

前者は、page1.htmlがa.js, b.js, c.jsに依存してるとき、scriptタグを3つ書くと3本コネクション張っちゃうのでHTTP1では遅い。連結して1本で済ませようという話。

後者はAMDとかを使ってブラウザで動的な依存解決をする文脈の話。page1.html => a.js => b.js => c.js というネストした依存があった場合、

  1. page1.htmlをリクエスト
  2. page1.htmlをパースして依存するa.jsをリクエスト
  3. a.jsをパースして依存を検出、依存するb.jsをリクエスト
  4. b.jsをパースして依存を検出、依存するc.jsをリクエスト
  5. c.jsをパースして、依存の順にスクリプトを実行

のように通信が直列で4往復かかってしまうので遅い。動的な依存解決ではなく、事前にビルドして連結しとけばHTMLとJSの2往復で済むので速い、という話。

concatのデメリット

ビルドしたくないってのもあるけど、それは「ビルドすればいいじゃん、どうせバベるんでしょ」とも言えるので実質あまり問題ではない。デバッグはsoucemapを使えばなんとかなる。

本当の問題は、concatするとクライアントキャッシュの粒度が大きいためにキャッシュヒット率が落ちることだと思う。

例えばページごとに1ファイルにconcatする戦略として、page1.htmlを表示してからpage2.htmlに遷移したとする。

  • page1.html => page1.js (a.js, b.js, c.jsを連結)
  • page2.html => page2.js (a.js, b.js, d.jsを連結)

このとき、page1.jsはキャッシュ済みで、page2.jsと共通部分(a.jsとb.js)があるのに、キャッシュは使われずpage2.jsはまるまるダウンロードされる。連結してなければ、差分のd.jsだけを新たにダウンロードすれば良い。

もちろん手動で共通部分はcommon/*に置いて別途common.jsにまとめるとかもできるけど、大規模化したときにある1ページがcommon/*の全ファイルを使うのか?っていうとそんなことは無いので、無駄な転送が増えてしまう。じゃあcommonを分割して粒度を下げよう、みたいに進めていくと究極は全ファイル連結しないのが理想ということになる。

SPAの場合はサイト全体で1ページしかなくてバンドルファイルが1つになるのでこの問題は起きないけど、大規模化するとファイルサイズが大きくなりすぎて結局動的ローディングが必要になって同じ問題に直面する。

ES6 Modules

ES6 ModulesはES6の仕様内ではimportするモジュールの読み込み方法は決めてない。同期的に読むとか非同期で読むとかは実行環境側が決める。

ブラウザがES6 Modulesにネイティブ対応した場合、同期で読むのはムリなので必然非同期になるだろう。つまり、動作としてはAMDと同じになるので、これまでconcatによって対処していた冒頭の問題が発生する。

せっかくES6 Modulesがブラウザにネイティブ実装される未来になっても、「パフォーマンスのためには事前にビルドが必要」となったら結局CommonJS/Browserifyの時代とやることは変わらないじゃないか!となるので、できれば生で使ってそれなりのパフォーマンスが出てほしい。

これに対して「それはHTTP/2で解決されるよ」というコメントが稀によく見られる気がする(要出典)が、本当なのか?というのが本題。

ラウンドトリップ問題

最初に挙げたconcatの理由のうち、コネクション数問題はHTTP/2の多重化で解決される。残るラウンドトリップ問題はどうなのか?

当初はHTTP/2のサーバープッシュで解決できると楽観的に考えていた。

  1. サーバーはHTML及びJSの依存関係を事前に把握しておく*1
  2. ブラウザはpage1.htmlをリクエストする
  3. サーバーは、page1.htmlが依存するa.js、さらにa.jsが依存するb.js、さらにその依存先のc.jsをサーバープッシュによりブラウザに送信し、page1.html本体も送信する。
  4. ブラウザは、page1.htmlをパースして依存先のa.jsをリクエストするが、サーバープッシュによりキャッシュ済みなのでキャッシュから取得
  5. ブラウザはa.jsのES6 Modulesをパースして依存先のb.jsをキャッシュから取得
  6. 同様にc.jsもキャッシュから取得

となるので、HTMLへのリクエストを含めて1往復で画面を表示できる!解決!

サーバープッシュはクライアントキャッシュを認識できない問題

と思っていたらこんなご指摘が。

上の戦略は、初回訪問でキャッシュがないときはベストケースだけど、クライアントキャッシュがあるとき、無駄に帯域を食ってしまう問題がある。ブラウザで画面を開いた直後はTCPのslow start問題により帯域は潤沢というわけではない。

サーバープッシュするとき、サーバーはクライアントにキャッシュがあるかどうか分からない。Cookieにキャッシュ済みのファイルリストを載せれば?という意見もあるけど、concatをしないと数百ファイルとかになるので現実的ではない。

対応策

サーバープッシュをメインにするのはあきらめて、クライアントに依存関係リストを渡してネストによるリクエストの往復を回避する。

あとは初回訪問時は特別対応でサーバープッシュするとか細かいあたり。具体的な実装手段としてはやっぱりService Workerになりそう。

Loader APIのフックを使えば、Service Worker無しでもできるかも。

いずれにせよ、今のままだと「ES6 Modules書いておけばプロダクト環境でもビルド不要」ということにはならなそう。。

HTTP/2サーバープッシュの疑問

HTTP/2サーバープッシュでググっても「HTMLへのリクエストに対してCSSやJSをプッシュする」っていうユースケース以外出てこない。公式FAQにもそれしか書いてない。でも今回の考察ではクライアントキャッシュされるJSやCSSみたいな静的ファイルはサーバープッシュに向かない気がしているが、実際どうなんだろう。

じゃあサーバープッシュって何に使えるのか?動的なコンテンツ?その辺が知りたいです。 SPDYにもサーバープッシュあったって聞いてるけど、何に使ってたんだろう。

というあたりを今日のhttp2studyで聞ければ良いなと思います。


追記

PUSH_PROMISE/RST_STREAMについて

サーバープッシュの意義について

*1:速度が問題なければリクエストが来たときに動的に依存を検出しても良い